Merge sai changes with integration fixes
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
"run:zopu": "bun --env-file=../../.env flue run zopu"
|
||||
},
|
||||
"dependencies": {
|
||||
"@code/backend": "workspace:*",
|
||||
"@code/env": "workspace:*",
|
||||
"@flue/runtime": "latest",
|
||||
"convex": "catalog:",
|
||||
|
||||
30
packages/agents/src/agents/project-manager.ts
Normal file
30
packages/agents/src/agents/project-manager.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { parseAgentEnv } from "@code/env/agent";
|
||||
import { defineAgent } from "@flue/runtime";
|
||||
import type { AgentRouteHandler } from "@flue/runtime";
|
||||
|
||||
import { agentOs } from "../sandboxes/agent-os";
|
||||
import { createProjectTools } from "../tools/project";
|
||||
|
||||
export const description =
|
||||
"Works one repository issue inside an isolated AgentOS workspace.";
|
||||
|
||||
export const route: AgentRouteHandler = (_context, next) => next();
|
||||
|
||||
export default defineAgent(({ env, id }) => {
|
||||
const { AGENT_MODEL_NAME, AGENT_MODEL_PROVIDER } = parseAgentEnv(env);
|
||||
|
||||
return {
|
||||
cwd: "/workspace",
|
||||
description,
|
||||
instructions: `You are the issue-scoped project manager and coding agent.
|
||||
|
||||
Start every run by calling lookup_issue_context, reading issue.md, project.md, business.md, design.md, agent.md, work.md, steps.md, artifacts.md, signals.md, agent-manager.md, context.md, and card.md from the AgentOS workspace. Call report_work_status with working before editing.
|
||||
|
||||
Work only on the bound issue. Inspect existing files before changing them. If a missing product decision makes safe progress impossible, publish the current work.md and steps.md, report needs-input, then ask one focused question. Otherwise implement the complete issue, run the relevant command or scenario in AgentOS, and preserve command evidence.
|
||||
|
||||
Before finishing, update the local work.md, steps.md, artifacts.md, and context.md files and publish each changed canonical artifact with publish_project_artifact. Report completed only after verification. On an unrecoverable error, report failed with the exact blocker. Never claim a repository change or command result you did not observe.`,
|
||||
model: `${AGENT_MODEL_PROVIDER}/${AGENT_MODEL_NAME}`,
|
||||
sandbox: agentOs(env),
|
||||
tools: createProjectTools(id, env),
|
||||
};
|
||||
});
|
||||
234
packages/agents/src/sandboxes/agent-os.ts
Normal file
234
packages/agents/src/sandboxes/agent-os.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { api } from "@code/backend/convex/_generated/api";
|
||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import { parseAgentEnv } from "@code/env/agent";
|
||||
import { createSandboxSessionEnv } from "@flue/runtime";
|
||||
import type { FileStat, SandboxApi, SandboxFactory } from "@flue/runtime";
|
||||
import { ConvexHttpClient } from "convex/browser";
|
||||
import * as v from "valibot";
|
||||
|
||||
const execResultSchema = v.object({
|
||||
exitCode: v.number(),
|
||||
stderr: v.string(),
|
||||
stdout: v.string(),
|
||||
});
|
||||
|
||||
const statResultSchema = v.object({
|
||||
isDirectory: v.boolean(),
|
||||
isSymbolicLink: v.boolean(),
|
||||
mtimeMs: v.number(),
|
||||
size: v.number(),
|
||||
});
|
||||
|
||||
interface ExecuteOptions {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly timeoutMs?: number;
|
||||
}
|
||||
|
||||
class AgentOsSandboxApi implements SandboxApi {
|
||||
readonly #actorKey: string[];
|
||||
readonly #client: ConvexHttpClient;
|
||||
readonly #daemonId: string;
|
||||
|
||||
constructor(
|
||||
client: ConvexHttpClient,
|
||||
daemonId: string,
|
||||
actorKey: readonly string[]
|
||||
) {
|
||||
this.#client = client;
|
||||
this.#daemonId = daemonId;
|
||||
this.#actorKey = [...actorKey];
|
||||
}
|
||||
|
||||
async #execute(
|
||||
method: string,
|
||||
args: unknown[],
|
||||
options: ExecuteOptions = {}
|
||||
): Promise<unknown> {
|
||||
const commandId = await this.#client.mutation(api.daemonCommands.enqueue, {
|
||||
actorKey: this.#actorKey,
|
||||
args,
|
||||
daemonId: this.#daemonId,
|
||||
method,
|
||||
});
|
||||
const timeoutMs = options.timeoutMs ?? 120_000;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (options.signal?.aborted) {
|
||||
// oxlint-disable-next-line no-await-in-loop -- cancellation must settle before the poll exits.
|
||||
await this.#client.mutation(api.daemonCommands.cancel, { commandId });
|
||||
throw options.signal.reason ?? new Error("AgentOS command aborted");
|
||||
}
|
||||
// oxlint-disable-next-line no-await-in-loop -- each query observes the next durable command state.
|
||||
const command = await this.#client.query(api.daemonCommands.get, {
|
||||
commandId,
|
||||
});
|
||||
if (!command) {
|
||||
throw new Error(`AgentOS command ${commandId} disappeared`);
|
||||
}
|
||||
if (command.status === "succeeded") {
|
||||
return command.result;
|
||||
}
|
||||
if (command.status === "failed" || command.status === "cancelled") {
|
||||
throw new Error(
|
||||
command.error ?? `AgentOS command ${method} ${command.status}`
|
||||
);
|
||||
}
|
||||
const { promise, resolve } = Promise.withResolvers<null>();
|
||||
setTimeout(() => resolve(null), 200);
|
||||
// oxlint-disable-next-line no-await-in-loop -- polling intentionally waits between sequential reads.
|
||||
await promise;
|
||||
}
|
||||
|
||||
await this.#client.mutation(api.daemonCommands.cancel, { commandId });
|
||||
throw new Error(`AgentOS command ${method} timed out after ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
async readFile(path: string): Promise<string> {
|
||||
return new TextDecoder().decode(await this.readFileBuffer(path));
|
||||
}
|
||||
|
||||
async readFileBuffer(path: string): Promise<Uint8Array> {
|
||||
const result = await this.#execute("readFile", [path]);
|
||||
if (result instanceof ArrayBuffer) {
|
||||
return new Uint8Array(result);
|
||||
}
|
||||
if (ArrayBuffer.isView(result)) {
|
||||
return new Uint8Array(
|
||||
result.buffer.slice(
|
||||
result.byteOffset,
|
||||
result.byteOffset + result.byteLength
|
||||
)
|
||||
);
|
||||
}
|
||||
throw new TypeError(
|
||||
`AgentOS readFile returned non-binary data for ${path}`
|
||||
);
|
||||
}
|
||||
|
||||
async writeFile(path: string, content: string | Uint8Array): Promise<void> {
|
||||
const serialized =
|
||||
typeof content === "string" ? content : Uint8Array.from(content).buffer;
|
||||
await this.#execute("writeFile", [path, serialized]);
|
||||
}
|
||||
|
||||
async stat(path: string): Promise<FileStat> {
|
||||
const result = v.parse(
|
||||
statResultSchema,
|
||||
await this.#execute("stat", [path])
|
||||
);
|
||||
return {
|
||||
isDirectory: result.isDirectory,
|
||||
isFile: !result.isDirectory && !result.isSymbolicLink,
|
||||
isSymbolicLink: result.isSymbolicLink,
|
||||
mtime: new Date(result.mtimeMs),
|
||||
size: result.size,
|
||||
};
|
||||
}
|
||||
|
||||
async readdir(path: string): Promise<string[]> {
|
||||
return v.parse(v.array(v.string()), await this.#execute("readdir", [path]));
|
||||
}
|
||||
|
||||
async exists(path: string): Promise<boolean> {
|
||||
return v.parse(v.boolean(), await this.#execute("exists", [path]));
|
||||
}
|
||||
|
||||
async mkdir(
|
||||
path: string,
|
||||
options?: { readonly recursive?: boolean }
|
||||
): Promise<void> {
|
||||
if (!options?.recursive) {
|
||||
await this.#execute("mkdir", [path]);
|
||||
return;
|
||||
}
|
||||
const quotedPath = `'${path.replaceAll("'", `'"'"'`)}'`;
|
||||
const result = v.parse(
|
||||
execResultSchema,
|
||||
await this.#execute("exec", [`mkdir -p ${quotedPath}`])
|
||||
);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(result.stderr || `Could not create ${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
async rm(
|
||||
path: string,
|
||||
options?: { readonly force?: boolean; readonly recursive?: boolean }
|
||||
): Promise<void> {
|
||||
if (options?.force && !(await this.exists(path))) {
|
||||
return;
|
||||
}
|
||||
await this.#execute("deleteFile", [
|
||||
path,
|
||||
{ recursive: options?.recursive },
|
||||
]);
|
||||
}
|
||||
|
||||
async exec(
|
||||
command: string,
|
||||
options?: {
|
||||
readonly cwd?: string;
|
||||
readonly env?: Record<string, string>;
|
||||
readonly signal?: AbortSignal;
|
||||
readonly timeoutMs?: number;
|
||||
}
|
||||
): Promise<{ exitCode: number; stderr: string; stdout: string }> {
|
||||
return v.parse(
|
||||
execResultSchema,
|
||||
await this.#execute(
|
||||
"exec",
|
||||
[
|
||||
command,
|
||||
{
|
||||
...(options?.cwd === undefined ? {} : { cwd: options.cwd }),
|
||||
...(options?.env === undefined ? {} : { env: options.env }),
|
||||
},
|
||||
],
|
||||
{ signal: options?.signal, timeoutMs: options?.timeoutMs }
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const agentOs = (
|
||||
runtimeEnv: Record<string, string | undefined>
|
||||
): SandboxFactory => {
|
||||
const env = parseAgentEnv(runtimeEnv);
|
||||
const client = new ConvexHttpClient(env.CONVEX_URL);
|
||||
|
||||
return {
|
||||
async createSessionEnv({ id }) {
|
||||
// Flue controls this id from the issue document selected by the web app.
|
||||
const issueId = id as Id<"projectIssues">;
|
||||
const run = await client.mutation(api.agentWorkspace.ensureRun, {
|
||||
daemonId: env.DAEMON_ID,
|
||||
issueId,
|
||||
token: env.FLUE_DB_TOKEN,
|
||||
});
|
||||
if (!run) {
|
||||
throw new Error(`Could not initialize AgentOS work run for ${id}`);
|
||||
}
|
||||
const sandbox = new AgentOsSandboxApi(
|
||||
client,
|
||||
env.DAEMON_ID,
|
||||
run.actorKey
|
||||
);
|
||||
const context = await client.query(api.agentWorkspace.get, {
|
||||
issueId,
|
||||
token: env.FLUE_DB_TOKEN,
|
||||
});
|
||||
await sandbox.mkdir("/workspace", { recursive: true });
|
||||
await Promise.all(
|
||||
context.artifacts.map((artifact) =>
|
||||
sandbox.writeFile(`/workspace/${artifact.path}`, artifact.content)
|
||||
)
|
||||
);
|
||||
await sandbox.writeFile(
|
||||
"/workspace/issue.md",
|
||||
`# Issue ${context.issue.number}: ${context.issue.title}\n\n${context.issue.body}\n`
|
||||
);
|
||||
return createSandboxSessionEnv(sandbox, "/workspace");
|
||||
},
|
||||
};
|
||||
};
|
||||
70
packages/agents/src/tools/project.ts
Normal file
70
packages/agents/src/tools/project.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { api } from "@code/backend/convex/_generated/api";
|
||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import { ARTIFACT_PATHS } from "@code/backend/convex/artifactModel";
|
||||
import { parseAgentEnv } from "@code/env/agent";
|
||||
import { defineTool } from "@flue/runtime";
|
||||
import { ConvexHttpClient } from "convex/browser";
|
||||
import * as v from "valibot";
|
||||
|
||||
export const createProjectTools = (
|
||||
issueAgentId: string,
|
||||
runtimeEnv: Record<string, string | undefined>
|
||||
) => {
|
||||
const env = parseAgentEnv(runtimeEnv);
|
||||
const client = new ConvexHttpClient(env.CONVEX_URL);
|
||||
// Flue receives this controlled id from the issue card that starts the run.
|
||||
const issueId = issueAgentId as Id<"projectIssues">;
|
||||
|
||||
return [
|
||||
defineTool({
|
||||
description:
|
||||
"Read the repository, issue, and canonical project artifacts bound to this agent.",
|
||||
name: "lookup_issue_context",
|
||||
async run() {
|
||||
return await client.query(api.agentWorkspace.get, {
|
||||
issueId,
|
||||
token: env.FLUE_DB_TOKEN,
|
||||
});
|
||||
},
|
||||
}),
|
||||
defineTool({
|
||||
description:
|
||||
"Publish the complete current content of one canonical project markdown artifact after changing it in AgentOS.",
|
||||
input: v.object({
|
||||
content: v.pipe(v.string(), v.maxLength(200_000)),
|
||||
path: v.picklist(ARTIFACT_PATHS),
|
||||
}),
|
||||
name: "publish_project_artifact",
|
||||
async run({ input }) {
|
||||
const revision = await client.mutation(
|
||||
api.agentWorkspace.updateArtifact,
|
||||
{
|
||||
content: input.content,
|
||||
issueId,
|
||||
path: input.path,
|
||||
token: env.FLUE_DB_TOKEN,
|
||||
}
|
||||
);
|
||||
return { path: input.path, revision };
|
||||
},
|
||||
}),
|
||||
defineTool({
|
||||
description:
|
||||
"Report the durable issue state. Use needs-input before asking a blocking question and completed only after verification.",
|
||||
input: v.object({
|
||||
status: v.picklist(["working", "needs-input", "completed", "failed"]),
|
||||
summary: v.pipe(v.string(), v.maxLength(4000)),
|
||||
}),
|
||||
name: "report_work_status",
|
||||
async run({ input }) {
|
||||
await client.mutation(api.agentWorkspace.setStatus, {
|
||||
issueId,
|
||||
status: input.status,
|
||||
summary: input.summary,
|
||||
token: env.FLUE_DB_TOKEN,
|
||||
});
|
||||
return { recorded: true, status: input.status };
|
||||
},
|
||||
}),
|
||||
];
|
||||
};
|
||||
37
packages/auth/package.json
Normal file
37
packages/auth/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
23
packages/auth/src/native/auth-client.ts
Normal file
23
packages/auth/src/native/auth-client.ts
Normal 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 }),
|
||||
],
|
||||
});
|
||||
16
packages/auth/src/native/auth-provider.tsx
Normal file
16
packages/auth/src/native/auth-provider.tsx
Normal 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>
|
||||
);
|
||||
62
packages/auth/src/native/hooks.ts
Normal file
62
packages/auth/src/native/hooks.ts
Normal 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 };
|
||||
4
packages/auth/src/native/index.ts
Normal file
4
packages/auth/src/native/index.ts
Normal 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";
|
||||
107
packages/auth/src/native/login-form.tsx
Normal file
107
packages/auth/src/native/login-form.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
138
packages/auth/src/native/signup-form.tsx
Normal file
138
packages/auth/src/native/signup-form.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
1
packages/auth/src/server/index.ts
Normal file
1
packages/auth/src/server/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { authComponent, createAuth } from "@code/backend/convex/auth";
|
||||
38
packages/auth/src/shared/forms.ts
Normal file
38
packages/auth/src/shared/forms.ts
Normal 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;
|
||||
};
|
||||
8
packages/auth/src/web/auth-client.ts
Normal file
8
packages/auth/src/web/auth-client.ts
Normal 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()],
|
||||
});
|
||||
14
packages/auth/src/web/auth-provider.tsx
Normal file
14
packages/auth/src/web/auth-provider.tsx
Normal 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>
|
||||
);
|
||||
51
packages/auth/src/web/hooks.ts
Normal file
51
packages/auth/src/web/hooks.ts
Normal 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 },
|
||||
});
|
||||
4
packages/auth/src/web/index.ts
Normal file
4
packages/auth/src/web/index.ts
Normal 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";
|
||||
114
packages/auth/src/web/login-form.tsx
Normal file
114
packages/auth/src/web/login-form.tsx
Normal 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't have an account? <a href={signUpHref}>Sign up</a>
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
)}
|
||||
</form.Subscribe>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
147
packages/auth/src/web/signup-form.tsx
Normal file
147
packages/auth/src/web/signup-form.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
10
packages/auth/tsconfig.json
Normal file
10
packages/auth/tsconfig.json
Normal 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"]
|
||||
}
|
||||
10
packages/backend/.oxlintrc.json
Normal file
10
packages/backend/.oxlintrc.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["convex/**/*.ts"],
|
||||
"rules": {
|
||||
"unicorn/filename-case": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
13
packages/backend/CLAUDE.md
Normal file
13
packages/backend/CLAUDE.md
Normal file
@@ -0,0 +1,13 @@
|
||||
<!-- convex-ai-start -->
|
||||
|
||||
This project uses [Convex](https://convex.dev) as its backend.
|
||||
|
||||
When working on Convex code, **always read
|
||||
`convex/_generated/ai/guidelines.md` first** for important guidelines on
|
||||
how to correctly use Convex APIs and patterns. The file contains rules that
|
||||
override what you may have learned about Convex from training data.
|
||||
|
||||
Convex agent skills for common tasks can be installed by running
|
||||
`npx convex ai-files install`.
|
||||
|
||||
<!-- convex-ai-end -->
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"guidelinesHash": "e72f83c02fca6a3a20f4d53731bd803a6c22ae4f9507ef575407aff86f35aa06",
|
||||
"guidelinesHash": "f730e6620e882fef21a3e00c5539cc0b472ef26688efd92bf3a42f0711de6888",
|
||||
"agentsMdSectionHash": "5934f676ea9a332e7cd4a4f64aa23b59d926e9faca026c758d4b1f87d2101cc3",
|
||||
"claudeMdHash": "5934f676ea9a332e7cd4a4f64aa23b59d926e9faca026c758d4b1f87d2101cc3",
|
||||
"agentSkillsSha": "ec1e6baae7d86c7843c22938c75979c016f5c6e9"
|
||||
|
||||
@@ -67,8 +67,8 @@ export default defineSchema({
|
||||
```
|
||||
|
||||
- Here are the valid Convex types along with their respective validators:
|
||||
Convex Type | TS/JS type | Example Usage | Validator for argument validation and schemas | Notes |
|
||||
| ----------- | ------------| -----------------------| -----------------------------------------------| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| Convex Type | TS/JS type | Example Usage | Validator for argument validation and schemas | Notes |
|
||||
| ----------- | ----------- | -------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Id | string | `doc._id` | `v.id(tableName)` | |
|
||||
| Null | null | `null` | `v.null()` | JavaScript's `undefined` is not a valid Convex value. Functions the return `undefined` or do not return will return `null` when called from a client. Use `null` instead. |
|
||||
| Int64 | bigint | `3n` | `v.int64()` | Int64s only support BigInts between -2^63 and 2^63-1. Convex supports `bigint`s in most modern browsers. |
|
||||
@@ -77,8 +77,9 @@ export default defineSchema({
|
||||
| String | string | `"abc"` | `v.string()` | Strings are stored as UTF-8 and must be valid Unicode sequences. Strings must be smaller than the 1MB total size limit when encoded as UTF-8. |
|
||||
| Bytes | ArrayBuffer | `new ArrayBuffer(8)` | `v.bytes()` | Convex supports first class bytestrings, passed in as `ArrayBuffer`s. Bytestrings must be smaller than the 1MB total size limit for Convex types. |
|
||||
| Array | Array | `[1, 3.2, "abc"]` | `v.array(values)` | Arrays can have at most 8192 values. |
|
||||
| Object | Object | `{a: "abc"}` | `v.object({property: value})` | Convex only supports "plain old JavaScript objects" (objects that do not have a custom prototype). Objects can have at most 1024 entries. Field names must be nonempty and not start with "$" or "_". |
|
||||
| Record | Record | `{"a": "1", "b": "2"}` | `v.record(keys, values)` | Records are objects at runtime, but can have dynamic keys. Keys must be only ASCII characters, nonempty, and not start with "$" or "\_". |
|
||||
| Object | Object | `{a: "abc"}` | `v.object({property: value})` | Convex only supports "plain old JavaScript objects" (objects that do not have a custom prototype). Objects can have at most 1024 entries. Field names must be nonempty and not start with "$" or "\_". |
|
||||
|
||||
| Record | Record | `{"a": "1", "b": "2"}` | `v.record(keys, values)` | Records are objects at runtime, but can have dynamic keys. Keys must be only ASCII characters, nonempty, and not start with "$" or "\_". |
|
||||
|
||||
### Function registration
|
||||
|
||||
@@ -317,6 +318,9 @@ export default app;
|
||||
- Component reads and writes participate in the calling mutation's transaction. When a component mirrors state from one of your tables (like an aggregate over a table), update the component in the SAME mutation as every insert, patch, replace, or delete of that table - never from a separate function - so the two can never drift.
|
||||
- To author a LOCAL component: a directory under `convex/` with its own `convex.config.ts` (`export default defineComponent("myName");` - the argument is the name string), its own `schema.ts`, and functions built from that directory's own `_generated/server`. Mount it from the root config (`app.use(myName)` - no options), and reference its functions through the generated `components` object INCLUDING the module segment: a function in `convex/myName/index.ts` is `components.myName.index.myFunction`, never `components.myName.myFunction`.
|
||||
- For per-key quotas, cooldowns, or throttling (N operations per period, retry-after), use the `@convex-dev/rate-limiter` component - hand-rolled counter or window-scan implementations admit races under concurrency and lose quota when a mutation fails.
|
||||
- For chat or assistant features where an LLM replies inside a durable conversation - per-user resumable histories, recorded tool-call steps, several assistants sharing one conversation - use the `@convex-dev/agent` component: mount it, create one component thread per conversation, and generate/read through it (`createThread(ctx, components.agent, ...)`, `new Agent(components.agent, { name, languageModel, tools }).generateText(ctx, { threadId }, { prompt })`, `listMessages`). Do not hand-roll a messages table or call an LLM SDK directly from your functions for these.
|
||||
- For async Convex functions needing bounded parallelism, serialized mutation work, or completion callbacks, use `@convex-dev/workpool`; retry only idempotent actions.
|
||||
- For ephemeral presence - who is online/viewing/typing in a room, tracked by client heartbeats with session tokens, multi-session aggregation (one entry per user across tabs), and timeout-to-offline - use the `@convex-dev/presence` component - hand-rolled lastSeen tables need wall-clock query filters that go stale, and per-session rows break the one-entry-per-user contract.
|
||||
- Calling a component mutation is a subtransaction: if it throws and the caller catches the error, the component's writes roll back while the calling mutation continues and can still commit its own writes.
|
||||
- To pass a function across a component boundary, mint a handle in the app: `const handle = await createFunctionHandle(internal.index.myCallback);` (from `convex/server`; async, takes only the function reference - `getFunctionHandle` and `getFunctionName` are not this API). Send it as a string; the receiver casts it back and invokes it: `await ctx.runMutation(args.handle as FunctionHandle<"mutation">, callbackArgs);`.
|
||||
|
||||
|
||||
12
packages/backend/convex/_generated/api.d.ts
vendored
12
packages/backend/convex/_generated/api.d.ts
vendored
@@ -8,7 +8,10 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type * as agentWorkspace from "../agentWorkspace.js";
|
||||
import type * as artifactModel from "../artifactModel.js";
|
||||
import type * as auth from "../auth.js";
|
||||
import type * as authz from "../authz.js";
|
||||
import type * as daemonCommands from "../daemonCommands.js";
|
||||
import type * as daemonRuntime from "../daemonRuntime.js";
|
||||
import type * as daemons from "../daemons.js";
|
||||
@@ -16,6 +19,9 @@ import type * as fluePersistence from "../fluePersistence.js";
|
||||
import type * as healthCheck from "../healthCheck.js";
|
||||
import type * as http from "../http.js";
|
||||
import type * as privateData from "../privateData.js";
|
||||
import type * as projectArtifacts from "../projectArtifacts.js";
|
||||
import type * as projectIssues from "../projectIssues.js";
|
||||
import type * as projects from "../projects.js";
|
||||
import type * as todos from "../todos.js";
|
||||
|
||||
import type {
|
||||
@@ -25,7 +31,10 @@ import type {
|
||||
} from "convex/server";
|
||||
|
||||
declare const fullApi: ApiFromModules<{
|
||||
agentWorkspace: typeof agentWorkspace;
|
||||
artifactModel: typeof artifactModel;
|
||||
auth: typeof auth;
|
||||
authz: typeof authz;
|
||||
daemonCommands: typeof daemonCommands;
|
||||
daemonRuntime: typeof daemonRuntime;
|
||||
daemons: typeof daemons;
|
||||
@@ -33,6 +42,9 @@ declare const fullApi: ApiFromModules<{
|
||||
healthCheck: typeof healthCheck;
|
||||
http: typeof http;
|
||||
privateData: typeof privateData;
|
||||
projectArtifacts: typeof projectArtifacts;
|
||||
projectIssues: typeof projectIssues;
|
||||
projects: typeof projects;
|
||||
todos: typeof todos;
|
||||
}>;
|
||||
|
||||
|
||||
174
packages/backend/convex/agentWorkspace.ts
Normal file
174
packages/backend/convex/agentWorkspace.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { env } from "@code/env/convex";
|
||||
import { ConvexError, v } from "convex/values";
|
||||
|
||||
import { mutation, query } from "./_generated/server";
|
||||
import { artifactPath } from "./artifactModel";
|
||||
|
||||
const requireAgent = (token: string) => {
|
||||
if (token !== env.FLUE_DB_TOKEN) {
|
||||
throw new ConvexError("Invalid agent control token");
|
||||
}
|
||||
};
|
||||
|
||||
const agentStatus = v.union(
|
||||
v.literal("working"),
|
||||
v.literal("needs-input"),
|
||||
v.literal("completed"),
|
||||
v.literal("failed")
|
||||
);
|
||||
|
||||
export const get = query({
|
||||
args: { issueId: v.id("projectIssues"), token: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
requireAgent(args.token);
|
||||
const issue = await ctx.db.get("projectIssues", args.issueId);
|
||||
if (!issue) {
|
||||
throw new ConvexError("Issue not found");
|
||||
}
|
||||
const project = await ctx.db.get("projects", issue.projectId);
|
||||
if (!project) {
|
||||
throw new ConvexError("Project not found");
|
||||
}
|
||||
const artifacts = await ctx.db
|
||||
.query("projectArtifacts")
|
||||
.withIndex("by_project", (q) => q.eq("projectId", project._id))
|
||||
.take(25);
|
||||
return { artifacts, issue, project };
|
||||
},
|
||||
});
|
||||
|
||||
export const ensureRun = mutation({
|
||||
args: {
|
||||
daemonId: v.string(),
|
||||
issueId: v.id("projectIssues"),
|
||||
token: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
requireAgent(args.token);
|
||||
const issue = await ctx.db.get("projectIssues", args.issueId);
|
||||
if (!issue) {
|
||||
throw new ConvexError("Issue not found");
|
||||
}
|
||||
const existing = await ctx.db
|
||||
.query("projectWorkRuns")
|
||||
.withIndex("by_issue", (q) => q.eq("issueId", issue._id))
|
||||
.unique();
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const timestamp = Date.now();
|
||||
const actorKey = [
|
||||
"project",
|
||||
String(issue.projectId),
|
||||
"issue",
|
||||
String(issue._id),
|
||||
];
|
||||
const runId = await ctx.db.insert("projectWorkRuns", {
|
||||
actorKey,
|
||||
agentId: String(issue._id),
|
||||
createdAt: timestamp,
|
||||
daemonId: args.daemonId,
|
||||
issueId: issue._id,
|
||||
projectId: issue.projectId,
|
||||
status: "queued",
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
await ctx.db.insert("projectSignals", {
|
||||
createdAt: timestamp,
|
||||
data: { actorKey, daemonId: args.daemonId },
|
||||
issueId: issue._id,
|
||||
kind: "agent.workspace.created",
|
||||
projectId: issue.projectId,
|
||||
runId,
|
||||
});
|
||||
return await ctx.db.get("projectWorkRuns", runId);
|
||||
},
|
||||
});
|
||||
|
||||
export const updateArtifact = mutation({
|
||||
args: {
|
||||
content: v.string(),
|
||||
issueId: v.id("projectIssues"),
|
||||
path: artifactPath,
|
||||
token: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
requireAgent(args.token);
|
||||
if (args.content.length > 200_000) {
|
||||
throw new ConvexError("Artifact content exceeds 200000 characters");
|
||||
}
|
||||
const issue = await ctx.db.get("projectIssues", args.issueId);
|
||||
if (!issue) {
|
||||
throw new ConvexError("Issue not found");
|
||||
}
|
||||
const artifact = await ctx.db
|
||||
.query("projectArtifacts")
|
||||
.withIndex("by_project_and_path", (q) =>
|
||||
q.eq("projectId", issue.projectId).eq("path", args.path)
|
||||
)
|
||||
.unique();
|
||||
if (!artifact) {
|
||||
throw new ConvexError(`Artifact ${args.path} not found`);
|
||||
}
|
||||
const timestamp = Date.now();
|
||||
await ctx.db.patch("projectArtifacts", artifact._id, {
|
||||
content: args.content,
|
||||
revision: artifact.revision + 1,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
await ctx.db.insert("projectSignals", {
|
||||
createdAt: timestamp,
|
||||
data: { path: args.path, revision: artifact.revision + 1 },
|
||||
issueId: issue._id,
|
||||
kind: "artifact.updated",
|
||||
projectId: issue.projectId,
|
||||
});
|
||||
return artifact.revision + 1;
|
||||
},
|
||||
});
|
||||
|
||||
export const setStatus = mutation({
|
||||
args: {
|
||||
issueId: v.id("projectIssues"),
|
||||
status: agentStatus,
|
||||
summary: v.optional(v.string()),
|
||||
token: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
requireAgent(args.token);
|
||||
const issue = await ctx.db.get("projectIssues", args.issueId);
|
||||
if (!issue) {
|
||||
throw new ConvexError("Issue not found");
|
||||
}
|
||||
const run = await ctx.db
|
||||
.query("projectWorkRuns")
|
||||
.withIndex("by_issue", (q) => q.eq("issueId", issue._id))
|
||||
.unique();
|
||||
if (!run) {
|
||||
throw new ConvexError("Agent work run not found");
|
||||
}
|
||||
const timestamp = Date.now();
|
||||
const terminal = args.status === "completed" || args.status === "failed";
|
||||
await ctx.db.patch("projectIssues", issue._id, {
|
||||
status: args.status,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
await ctx.db.patch("projectWorkRuns", run._id, {
|
||||
status: args.status,
|
||||
summary: args.summary?.slice(0, 4000),
|
||||
updatedAt: timestamp,
|
||||
...(args.status === "working" && run.startedAt === undefined
|
||||
? { startedAt: timestamp }
|
||||
: {}),
|
||||
...(terminal ? { completedAt: timestamp } : {}),
|
||||
});
|
||||
await ctx.db.insert("projectSignals", {
|
||||
createdAt: timestamp,
|
||||
data: { summary: args.summary?.slice(0, 1000) },
|
||||
issueId: issue._id,
|
||||
kind: `agent.${args.status}`,
|
||||
projectId: issue.projectId,
|
||||
runId: run._id,
|
||||
});
|
||||
},
|
||||
});
|
||||
100
packages/backend/convex/artifactModel.ts
Normal file
100
packages/backend/convex/artifactModel.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
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")
|
||||
);
|
||||
|
||||
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:
|
||||
"# 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: "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",
|
||||
},
|
||||
];
|
||||
};
|
||||
17
packages/backend/convex/authz.ts
Normal file
17
packages/backend/convex/authz.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { ConvexError } from "convex/values";
|
||||
|
||||
export interface 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;
|
||||
};
|
||||
@@ -52,6 +52,11 @@ export const list = query({
|
||||
.take(100);
|
||||
},
|
||||
});
|
||||
export const get = query({
|
||||
args: { commandId: v.id("daemonCommands") },
|
||||
handler: async (ctx, args) =>
|
||||
await ctx.db.get("daemonCommands", args.commandId),
|
||||
});
|
||||
|
||||
export const available = query({
|
||||
args: { daemonId: v.string() },
|
||||
|
||||
19
packages/backend/convex/projectArtifacts.ts
Normal file
19
packages/backend/convex/projectArtifacts.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
149
packages/backend/convex/projectIssues.ts
Normal file
149
packages/backend/convex/projectIssues.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
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;
|
||||
};
|
||||
|
||||
export const create = mutation({
|
||||
args: {
|
||||
body: v.string(),
|
||||
projectId: v.id("projects"),
|
||||
title: 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", {
|
||||
body,
|
||||
createdAt: timestamp,
|
||||
number,
|
||||
projectId: args.projectId,
|
||||
status: "open",
|
||||
title,
|
||||
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", {
|
||||
createdAt: timestamp,
|
||||
data: { number, title },
|
||||
issueId,
|
||||
kind: "issue.created",
|
||||
projectId: args.projectId,
|
||||
});
|
||||
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", {
|
||||
createdAt: timestamp,
|
||||
data: { number: issue.number },
|
||||
issueId: issue._id,
|
||||
kind: "issue.queued",
|
||||
projectId: issue.projectId,
|
||||
});
|
||||
return "queued" as const;
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
const timestamp = Date.now();
|
||||
await ctx.db.patch("projectIssues", issue._id, {
|
||||
status: "failed",
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
await ctx.db.insert("projectSignals", {
|
||||
createdAt: timestamp,
|
||||
data: { error: args.error.slice(0, 1000), phase: "dispatch" },
|
||||
issueId: issue._id,
|
||||
kind: "agent.failed",
|
||||
projectId: issue.projectId,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
},
|
||||
});
|
||||
176
packages/backend/convex/projects.ts
Normal file
176
packages/backend/convex/projects.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { ConvexError, v } from "convex/values";
|
||||
import { z } from "zod";
|
||||
|
||||
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";
|
||||
|
||||
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() }),
|
||||
});
|
||||
|
||||
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({
|
||||
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", {
|
||||
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("projectSignals", {
|
||||
createdAt: timestamp,
|
||||
data: { provider: "github", repository: args.repository.url },
|
||||
kind: "project.connected",
|
||||
projectId,
|
||||
});
|
||||
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;
|
||||
},
|
||||
});
|
||||
@@ -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(),
|
||||
@@ -117,11 +197,7 @@ export default defineSchema({
|
||||
settlementRecordId: v.optional(v.string()),
|
||||
settlementRecordJson: v.optional(v.string()),
|
||||
settledOutcome: v.optional(
|
||||
v.union(
|
||||
v.literal("completed"),
|
||||
v.literal("failed"),
|
||||
v.literal("aborted")
|
||||
)
|
||||
v.union(v.literal("completed"), v.literal("failed"), v.literal("aborted"))
|
||||
),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
@@ -155,8 +231,7 @@ export default defineSchema({
|
||||
nextOffset: v.number(),
|
||||
closed: v.boolean(),
|
||||
createdAt: v.number(),
|
||||
})
|
||||
.index("by_path", ["path"]),
|
||||
}).index("by_path", ["path"]),
|
||||
|
||||
// Conversation-stream batches: one row per appended batch. Offset is
|
||||
// 0-based; `seq` is the row's position in the stream.
|
||||
@@ -172,10 +247,12 @@ export default defineSchema({
|
||||
appendedAt: v.number(),
|
||||
})
|
||||
.index("by_path_and_seq", ["path", "seq"])
|
||||
.index(
|
||||
"by_path_producer_epoch_producerSequence",
|
||||
["path", "producerId", "producerEpoch", "producerSequence"]
|
||||
),
|
||||
.index("by_path_producer_epoch_producerSequence", [
|
||||
"path",
|
||||
"producerId",
|
||||
"producerEpoch",
|
||||
"producerSequence",
|
||||
]),
|
||||
|
||||
// Event-stream metadata: one row per stream path.
|
||||
flueEventStreams: defineTable({
|
||||
@@ -249,5 +326,5 @@ export default defineSchema({
|
||||
"streamPath",
|
||||
"conversationId",
|
||||
"attachmentId",
|
||||
])
|
||||
]),
|
||||
});
|
||||
|
||||
41
packages/backend/skills-lock.json
Normal file
41
packages/backend/skills-lock.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"convex": {
|
||||
"source": "get-convex/agent-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/convex/SKILL.md",
|
||||
"computedHash": "c5f3622c64ef550aac27d1dbc041f0c7c40d9119863c9fb8bac180b0498ee8ed"
|
||||
},
|
||||
"convex-create-component": {
|
||||
"source": "get-convex/agent-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/convex-create-component/SKILL.md",
|
||||
"computedHash": "012acb639fccc22a47e89ef69941689f9328ac9ff5b872d77af6328407ec8876"
|
||||
},
|
||||
"convex-migration-helper": {
|
||||
"source": "get-convex/agent-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/convex-migration-helper/SKILL.md",
|
||||
"computedHash": "1ed5ef230d484e9884d881ddbd15158f0070382f8c6097364cb8ed2ec458decd"
|
||||
},
|
||||
"convex-performance-audit": {
|
||||
"source": "get-convex/agent-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/convex-performance-audit/SKILL.md",
|
||||
"computedHash": "2eaf22b20f74ab39f336b41845b834b8796b6d2b380bc078a1ab54769c3f233c"
|
||||
},
|
||||
"convex-quickstart": {
|
||||
"source": "get-convex/agent-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/convex-quickstart/SKILL.md",
|
||||
"computedHash": "54e5271d5d613cc4c0068baa250613b5abb6e4533df1e67adc26d2e0c7d68ded"
|
||||
},
|
||||
"convex-setup-auth": {
|
||||
"source": "get-convex/agent-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/convex-setup-auth/SKILL.md",
|
||||
"computedHash": "b1a940758751c5b2fdc6ced105b19927a1655f0c1d4bd2fd5536dc3264202c00"
|
||||
}
|
||||
}
|
||||
}
|
||||
3
packages/env/src/agent.ts
vendored
3
packages/env/src/agent.ts
vendored
@@ -8,6 +8,9 @@ const agentEnvSchema = z.object({
|
||||
AGENT_MODEL_MAX_TOKENS: z.coerce.number().int().positive(),
|
||||
AGENT_MODEL_NAME: z.string().min(1),
|
||||
AGENT_MODEL_PROVIDER: z.string().min(1),
|
||||
CONVEX_URL: z.url(),
|
||||
DAEMON_ID: z.string().min(1),
|
||||
FLUE_DB_TOKEN: z.string().min(1),
|
||||
});
|
||||
|
||||
export type AgentEnv = z.infer<typeof agentEnvSchema>;
|
||||
|
||||
236
packages/ui/src/components/field.tsx
Normal file
236
packages/ui/src/components/field.tsx
Normal 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,
|
||||
}
|
||||
25
packages/ui/src/components/separator.tsx
Normal file
25
packages/ui/src/components/separator.tsx
Normal 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 }
|
||||
Reference in New Issue
Block a user