Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/api/test/api-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ describe("versioned API contract", () => {
});
}
}
});
}, 15_000);

it("keeps the exact committed REST artifact available in the Reference group", () => {
const docs = JSON.parse(
Expand Down
8 changes: 4 additions & 4 deletions packages/api/test/helpers/loading-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type { Env, OAuthProps } from "../../src/types";
export async function loadingFixture(url: string) {
let admin!: Database, db!: Database, env!: Env, auth!: OAuthProps, cookie!: string;
const name = `loading_test_${crypto.randomUUID().replaceAll("-", "")}`;
const tenantId = crypto.randomUUID(), userId = crypto.randomUUID(), otherTenant = crypto.randomUUID();
const tenantId = crypto.randomUUID(), userId = crypto.randomUUID(), otherTenant = crypto.randomUUID(), projectId = crypto.randomUUID();
const jobIds = Array.from({ length: 10 }, () => crypto.randomUUID());
const runId = crypto.randomUUID();
async function setup() {
Expand All @@ -28,13 +28,13 @@ export async function loadingFixture(url: string) {
} } as unknown as Fetcher, DATABASE: db, APP_ORIGIN: "https://factorize.test", SESSION_SIGNING_SECRET: "local-test-signing", OAUTH_PROVIDER: { listUserGrants: async () => ({ items: [] }) } as any, CREDENTIAL_ENCRYPTION_KEY: btoa("a".repeat(32)) } as Env;
await new IdentityRepository(db, tenantId).upsertOwner(userId, "owner@example.test");
await db.pool.query("INSERT INTO app.tenants(id) VALUES ($1)", [otherTenant]);
await db.pool.query("INSERT INTO app.projects(tenant_id,id,name) VALUES ($1,md5(($1::uuid)::text || ':default')::uuid,'Default'),($2,md5(($2::uuid)::text || ':default')::uuid,'Default')", [tenantId, otherTenant]);
await db.pool.query("INSERT INTO app.projects(tenant_id,id,name) VALUES ($1,$2,'Default'),($1,md5(($1::uuid)::text || ':legacy')::uuid,'Legacy'),($3,md5(($3::uuid)::text || ':default')::uuid,'Default')", [tenantId, projectId, otherTenant]);
auth = { tenantId, userId, sessionVersion: 0, scopes: ["flows:read", "flows:write", "runs:read", "runs:write"], authMethod: "session" };
const member = await new IdentityRepository(db, tenantId).member(userId); auth.sessionVersion = member!.sessionVersion;
cookie = `factorize_session=${await signSession({ ...auth, email: "owner@example.test", exp: Math.floor(Date.now()/1000)+3600 }, env.SESSION_SIGNING_SECRET)}`;
const prompt = await encrypt("Prompt", env.CREDENTIAL_ENCRYPTION_KEY);
for (const [i, id] of jobIds.entries()) {
await db.pool.query("INSERT INTO app.jobs(tenant_id,id,project_id,name,slug,encrypted_prompt_template,execution_target,concurrency_limit) VALUES ($1, $2, md5(($1::uuid)::text || ':default')::uuid, $3, $3, $4, $5, 2)", [tenantId, id, `Job-${i}`, prompt, { agentKind: "codex", connectionId: "local" }]);
await db.pool.query("INSERT INTO app.jobs(tenant_id,id,project_id,name,slug,encrypted_prompt_template,execution_target,concurrency_limit) VALUES ($1, $2, $3, $4, $4, $5, $6, 2)", [tenantId, id, projectId, `Job-${i}`, prompt, { agentKind: "codex", connectionId: "local" }]);
for (const [position, kind] of ["manual", "schedule"].entries()) await db.pool.query("INSERT INTO app.triggers(tenant_id,id,job_id,kind,slug,position,config) VALUES ($1,$2,$3,$4,$4,$5,$6)", [tenantId, crypto.randomUUID(), id, kind, position, kind === "schedule" ? { cron: "0 * * * *", timezone: "UTC" } : {}]);
}
// Same job ID in another tenant must never contribute triggers or statistics.
Expand All @@ -55,5 +55,5 @@ export async function loadingFixture(url: string) {
const request = (path: string, value = cookie) => worker.fetch(new Request(`https://factorize.test${path}`, { headers: { Cookie: value } }), env, { waitUntil() {} } as any);

try { await setup(); } catch (error) { await cleanup(); throw error; }
return { db, env, auth, cookie, jobIds, runId, tenantId, userId, otherTenant, request, cleanup };
return { db, env, auth, cookie, jobIds, runId, tenantId, userId, otherTenant, projectId, request, cleanup };
}
4 changes: 2 additions & 2 deletions packages/api/test/static-slice.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ vi.mock("@cloudflare/workers-oauth-provider", () => ({ OAuthProvider: class {},
vi.mock("cloudflare:workers", () => ({ WorkerEntrypoint: class {}, DurableObject: class {} }));
const url = process.env.AUTH_TEST_DATABASE_URL;
describe.skipIf(!url)("compiled React + actual Worker API + PostgreSQL", () => {
it("authenticates, navigates jobs, invokes once, refreshes the run and resets a projected trace", async () => {
it("authenticates, navigates a project's jobs, invokes once, refreshes the run and resets a projected trace", async () => {
const f = await loadingFixture(url!), browser = await chromium.launch({ headless: true });
const failures: string[] = [], calls: string[] = [];
try {
Expand Down Expand Up @@ -62,7 +62,7 @@ describe.skipIf(!url)("compiled React + actual Worker API + PostgreSQL", () => {
await page.getByRole("img", { name: "Status: Running", exact: true }).waitFor();
await page.getByRole("img", { name: "Status: Succeeded", exact: true }).waitFor();
}
await page.goto("https://factorize.test/jobs");
await page.goto(`https://factorize.test/projects/${f.projectId}`);
await page.locator("header img").waitFor();
expect(await page.locator("header img").evaluate(async (image: any) => { await image.decode(); return image.complete && image.naturalWidth > 0; })).toBe(true);
expect(await page.locator("html").getAttribute("data-theme")).toBe("light");
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/features/jobs/detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export function JobDetail() {
const query = useQuery(jobQuery(jobId!));
const job = query.data;
const project = useQuery({ ...projectQuery(job?.projectId ?? ""), enabled: !!job?.projectId });
return <Page className="max-w-6xl"><nav aria-label="Breadcrumb">{project.data && job?.projectId ? <Link to="/projects/$projectId" params={{ projectId: job.projectId }}>{project.data.name}</Link> : <Link to="/jobs" search={{ q: "" }}>Jobs</Link>}</nav>
return <Page className="max-w-6xl"><nav aria-label="Breadcrumb">{project.data && job?.projectId ? <Link to="/projects/$projectId" params={{ projectId: job.projectId }}>{project.data.name}</Link> : <Link to="/projects">Projects</Link>}</nav>
{query.isPending && <p role="status">Loading job…</p>}{query.error && <p role="alert">{query.error.message}</p>}
{job && <><JobHeader job={job} /><div className="mb-6">
<RunHistory jobId={job.id} />
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/features/jobs/editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ function EditorForm({ initial }: { initial?: Job }) {
};
const [localConflictDraft, setLocalConflictDraft] = useState(draft);
const showJobBreadcrumb = Boolean(initial && !location.pathname.endsWith("/settings"));
return <Page className="max-w-6xl"><nav aria-label="Breadcrumb" className="flex min-w-0 flex-wrap items-center gap-2">{project.data && initial?.projectId ? <Link to="/projects/$projectId" params={{ projectId: initial.projectId }}>{project.data.name}</Link> : <Link to="/jobs" search={{ q: "" }}>Jobs</Link>}{showJobBreadcrumb && initial && <><span aria-hidden="true">→</span><Link className="break-words min-w-0" to="/jobs/$jobId" params={{ jobId: initial.id }} search={{}}> {initial.name}</Link></>}</nav>{initial && <JobHeader job={initial} />}
return <Page className="max-w-6xl"><nav aria-label="Breadcrumb" className="flex min-w-0 flex-wrap items-center gap-2">{project.data && initial?.projectId ? <Link to="/projects/$projectId" params={{ projectId: initial.projectId }}>{project.data.name}</Link> : <Link to="/projects">Projects</Link>}{showJobBreadcrumb && initial && <><span aria-hidden="true">→</span><Link className="break-words min-w-0" to="/jobs/$jobId" params={{ jobId: initial.id }} search={{}}> {initial.name}</Link></>}</nav>{initial && <JobHeader job={initial} />}
{(remote || reconciling || refreshConflict.error) && <Card role="alert" className="my-4 border p-4"><h2>This job changed while you were editing</h2><p>Your unsaved draft is retained. Review the latest configuration before retrying.</p>
{refreshConflict.isPending && <p>Loading latest configuration…</p>}{refreshConflict.error && <><p>{refreshConflict.error.message}</p><Button onClick={() => refreshConflict.mutate()}>Retry loading latest</Button></>}
{remote && !conflicts.length && <><pre className="max-h-64 overflow-auto whitespace-pre-wrap">{JSON.stringify(editableJob(remote), null, 2)}</pre><Button onClick={acceptRemote}>Reload latest and discard my changes</Button><Button onClick={() => { setLocalConflictDraft(draft); merge(); }}>Reconcile my changes</Button></>}
Expand Down
38 changes: 18 additions & 20 deletions packages/app/src/features/jobs/list.tsx
Original file line number Diff line number Diff line change
@@ -1,36 +1,34 @@
import { Card, buttonClasses, Page, PageHeader, SortableHeader, Table, tableCellClasses, tableHeadClasses, tableRowClasses } from "../../shared/ui";
import { Card, SortableHeader, Table, tableCellClasses, tableHeadClasses, tableRowClasses } from "../../shared/ui";
import { Link, useNavigate, useSearch } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { jobsQuery, type JobsOrder, type JobsSearch, type JobsSort } from "./queries";
export function JobsList() {

export function JobsTable({ projectId }: { projectId: string }) {
const search = useSearch({ strict: false }) as JobsSearch;
const navigate = useNavigate({ from: "/jobs" });
const query = useQuery(jobsQuery(search));
const navigate = useNavigate({ from: "/projects/$projectId" });
const query = useQuery(jobsQuery({ ...search, projectId }));
const sortLabel = (sort: JobsSort) => sort === "running" ? "Running" : "Title";
const sortOrder = (sort: JobsSort): JobsOrder => search.sort === sort && search.order === "asc" ? "desc" : "asc";
const chooseSort = (sort: JobsSort) => void navigate({ search: { ...search, cursor: undefined, sort, order: sortOrder(sort) } });
return <Page>
<PageHeader title="Jobs">
<Link to="/jobs/new" className={`shrink-0 whitespace-nowrap rounded-lg border px-4 py-2.5 text-sm font-semibold ${buttonClasses.primary}`}>Create job</Link>
</PageHeader>
return <>
{query.isPending && <p role="status">Loading jobs…</p>}
{query.error && <p role="alert">{query.error.message}</p>}
{query.data && <>
{!query.data.items.length && <Card className="py-12 text-center"><h2>No jobs found</h2><p className="text-sm text-slate-600 dark:text-slate-400">Create your first job or adjust your search.</p></Card>}
{!query.data.items.length && <Card className="py-12 text-center"><h2>No jobs in this project yet.</h2><p className="text-sm text-slate-600 dark:text-slate-400">Create your first job to get started.</p></Card>}
{!!query.data.items.length && <Table className="table-fixed" aria-label="Jobs">
<thead className={tableHeadClasses}><tr>
<SortableHeader label="Running" ariaLabel={`${sortLabel("running")} sort`} active={search.sort === "running"} direction={search.order} className="w-20" onSort={() => chooseSort("running")} />
<SortableHeader label="Title" ariaLabel={`${sortLabel("title")} sort`} active={search.sort === "title"} direction={search.order} onSort={() => chooseSort("title")} />
</tr></thead>
<tbody>{query.data.items.map(job => <tr key={job.id} className={`${tableRowClasses} ${job.enabled ? "" : "text-slate-500 dark:text-slate-500"}`}>
<td className={`whitespace-nowrap ${tableCellClasses} font-mono tabular-nums ${job.enabled && job.runningCount > 0 ? "font-semibold text-factorize-700 dark:text-factorize-500" : job.enabled ? "text-slate-600 dark:text-slate-400" : ""}`}>{job.runningCount}/{job.concurrencyLimit}</td>
<td className={tableCellClasses}><div className="relative flex min-w-0 items-center gap-2"><Link to="/jobs/$jobId" params={{ jobId: job.id }} title={job.name} className="relative min-w-0 flex-1 truncate font-semibold outline-none after:absolute after:inset-0 focus-visible:after:rounded-sm focus-visible:after:ring-2 focus-visible:after:ring-inset focus-visible:after:ring-factorize-500">{job.name}</Link>{!job.enabled && <span className="shrink-0 text-xs font-medium lowercase tracking-wide">disabled</span>}</div></td>
</tr>)}</tbody>
<thead className={tableHeadClasses}><tr>
<SortableHeader label="Running" ariaLabel={`${sortLabel("running")} sort`} active={search.sort === "running"} direction={search.order} className="w-20" onSort={() => chooseSort("running")} />
<SortableHeader label="Title" ariaLabel={`${sortLabel("title")} sort`} active={search.sort === "title"} direction={search.order} onSort={() => chooseSort("title")} />
</tr></thead>
<tbody>{query.data.items.map(job => <tr key={job.id} className={`${tableRowClasses} ${job.enabled ? "" : "text-slate-500 dark:text-slate-500"}`}>
<td className={`whitespace-nowrap ${tableCellClasses} font-mono tabular-nums ${job.enabled && job.runningCount > 0 ? "font-semibold text-factorize-700 dark:text-factorize-500" : job.enabled ? "text-slate-600 dark:text-slate-400" : ""}`}>{job.runningCount}/{job.concurrencyLimit}</td>
<td className={tableCellClasses}><div className="relative flex min-w-0 items-center gap-2"><Link to="/jobs/$jobId" params={{ jobId: job.id }} title={job.name} className="relative min-w-0 flex-1 truncate font-semibold outline-none after:absolute after:inset-0 focus-visible:after:rounded-sm focus-visible:after:ring-2 focus-visible:after:ring-inset focus-visible:after:ring-factorize-500">{job.name}</Link>{!job.enabled && <span className="shrink-0 text-xs font-medium lowercase tracking-wide">disabled</span>}</div></td>
</tr>)}</tbody>
</Table>}
<nav aria-label="Job pages" className="mt-6 flex gap-4 text-sm font-semibold">
{search.cursor && <Link to="/jobs" search={{ ...search, cursor: undefined }}>First page</Link>}
{query.data.nextCursor && <Link to="/jobs" search={{ ...search, cursor: query.data.nextCursor }}>Next page</Link>}
{search.cursor && <Link to="/projects/$projectId" params={{ projectId }} search={{ ...search, cursor: undefined }}>First page</Link>}
{query.data.nextCursor && <Link to="/projects/$projectId" params={{ projectId }} search={{ ...search, cursor: query.data.nextCursor }}>Next page</Link>}
</nav>
</>}
</Page>;
</>;
}
9 changes: 2 additions & 7 deletions packages/app/src/features/jobs/routes.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createRoute, lazyRouteComponent, redirect, type AnyRootRoute } from "@tanstack/react-router";
import { sessionQuery } from "../auth/session";
import { queryClient } from "../../shared/query-client";
import { jobsQuery, jobQuery, type JobsSearch } from "./queries";
import { jobQuery } from "./queries";
import { runStatuses } from "../../shared/ui";
const runStates = runStatuses;
const parseRunStates = (value: unknown) => {
Expand All @@ -12,12 +12,7 @@ export function jobsRoutes(root: AnyRootRoute) {
const guard = async ({ location }: { location: { href: string } }) => {
if (!(await queryClient.ensureQueryData(sessionQuery)).authenticated) throw redirect({ to: "/auth/login", search: { returnTo: location.href, token: undefined } });
};
const jobs = createRoute({ getParentRoute: () => root, path: "/jobs", beforeLoad: guard,
validateSearch: (s: Record<string, unknown>): JobsSearch => ({ q: typeof s.q === "string" ? s.q.slice(0, 120) : "", cursor: typeof s.cursor === "string" ? s.cursor : undefined, enabled: s.enabled === "true" || s.enabled === "false" ? s.enabled : undefined, sort: s.sort === "running" || s.sort === "title" ? s.sort : undefined, order: s.order === "asc" || s.order === "desc" ? s.order : undefined }),
loaderDeps: ({ search }) => search,
loader: ({ deps }) => queryClient.ensureQueryData(jobsQuery(deps)),
component: lazyRouteComponent(() => import("./list"), "JobsList"),
});
const jobs = createRoute({ getParentRoute: () => root, path: "/jobs", beforeLoad: () => { throw redirect({ to: "/projects" }); } });
const job = createRoute({ getParentRoute: () => root, path: "/jobs/$jobId", beforeLoad: guard,
validateSearch: (s: Record<string, unknown>) => ({ cursor: typeof s.cursor === "string" ? s.cursor : undefined, previous: typeof s.previous === "string" ? s.previous.slice(0, 20_000) : undefined, states: parseRunStates(s.states) as any, sort: ["run", "status", "created", "agent"].includes(String(s.sort)) ? s.sort as "run" | "status" | "created" | "agent" : undefined, direction: s.direction === "asc" || s.direction === "desc" ? s.direction : undefined }),
loader: ({ params }) => queryClient.ensureQueryData(jobQuery(params.jobId)),
Expand Down
6 changes: 3 additions & 3 deletions packages/app/src/features/projects/detail.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Link, useParams } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { Button, Card, Page } from "../../shared/ui";
import { Button, Page, PageHeader } from "../../shared/ui";
import { projectQuery } from "./queries";
import { jobsQuery } from "../jobs/queries";
export function ProjectDetail() { const { projectId } = useParams({ from: "/projects/$projectId" }); const project=useQuery(projectQuery(projectId)); const jobs=useQuery(jobsQuery({q:"",projectId})); return <Page className="max-w-6xl"><Link to="/projects" className="text-sm font-semibold">Projects</Link>{project.data&&<><div className="mb-6 mt-3 flex items-center justify-between gap-4"><div><h1>{project.data.name}</h1><p className="text-sm text-slate-600 dark:text-slate-400">{project.data.description}</p></div><Link to="/jobs/new" search={{ projectId }}><Button>Create job</Button></Link></div><Card>{jobs.isPending&&<p role="status">Loading jobs…</p>}{jobs.error&&<p role="alert">{jobs.error.message}</p>}{jobs.data?.items.map(job=><div key={job.id} className="border-b border-stone-200 py-3 last:border-0 dark:border-slate-800"><Link to="/jobs/$jobId" params={{jobId:job.id}} className="font-semibold">{job.name}</Link><span className="ml-3 text-sm text-slate-500">{job.enabled?"Enabled":"Disabled"}</span></div>)}{jobs.data?.items.length===0&&<p>No jobs in this project yet.</p>}</Card></>}</Page>; }
import { JobsTable } from "../jobs/list";
export function ProjectDetail() { const { projectId } = useParams({ from: "/projects/$projectId" }); const project=useQuery(projectQuery(projectId)); return <Page className="max-w-6xl"><Link to="/projects" className="text-sm font-semibold">Projects</Link>{project.data&&<><PageHeader title={project.data.name} description={project.data.description}><Link to="/jobs/new" search={{ projectId }}><Button>Create job</Button></Link></PageHeader><JobsTable projectId={projectId} /></>}</Page>; }
Loading
Loading