Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
30e3457
sanad: worker runtime schema — workspaces, agents, versions, deployme…
omarsoul09 Aug 13, 2026
3c58de0
sanad: worker runtime schema fixes — trim migration, use non-deprecat…
omarsoul09 Aug 13, 2026
1d613d6
sanad: invoke tokens — itok mint/verify scoped to agent+env, quota at…
omarsoul09 Aug 13, 2026
5bc528d
sanad: agent registry — upsert/versions/deployments with owner-requir…
omarsoul09 Aug 13, 2026
c1fde73
sanad: agent registry — stable ownership on upsert, deployment supers…
omarsoul09 Aug 13, 2026
8073546
sanad: agent registry — version ancestry check on deploy, 404 on no-o…
omarsoul09 Aug 13, 2026
19e5c2b
sanad: workspace machines — per-(workspace,env) fargate wake with wor…
omarsoul09 Aug 13, 2026
91ceb70
sanad: workspace machines — boot-compatible task env, workspace ident…
omarsoul09 Aug 13, 2026
179172f
sanad: sync invoke route — gate, idempotent run rows, machine wake + …
omarsoul09 Aug 13, 2026
a86a93a
sanad: invoke route — deterministic live-deployment selection
omarsoul09 Aug 13, 2026
32fc6f5
sanad: run completion + pricing + read apis + lost-run reaper
omarsoul09 Aug 13, 2026
c400a46
sanad: run reaper — status-guarded batch update with returning count
omarsoul09 Aug 13, 2026
c8a8720
sanad: worker assembly — sidecar spec, input rendering, ReturnOutput …
omarsoul09 Aug 13, 2026
1ad0f6a
sanad: worker assembly — derived spec preserves base tools
omarsoul09 Aug 13, 2026
b9337dd
sanad: agent dev — local ephemeral worker run with nudge-then-no_outp…
omarsoul09 Aug 13, 2026
3bea50b
sanad: agent verbs — deploy bundle flow, runs/logs/pause/resume clients
omarsoul09 Aug 13, 2026
ab624fa
sanad: RunRunner — one-turn wire runner with token budget and finishe…
omarsoul09 Aug 13, 2026
20e1073
sanad: RunRunner — status-guarded token trip, defensive telemetry par…
omarsoul09 Aug 13, 2026
479926a
sanad: worker routes — gated run start/follow/cancel with bundle cont…
omarsoul09 Aug 13, 2026
8b0275d
sanad: worker routes — bundle write hardening, symmetric spawn cleanu…
omarsoul09 Aug 13, 2026
07d04f6
sanad: run completion — trace gzip upload, usage report, no_output fa…
omarsoul09 Aug 13, 2026
8cbc20e
sanad: worker route tests — mock control plane, deterministic replay/…
omarsoul09 Aug 13, 2026
ade6a83
sanad: worker trace upload — injectable transport seam, end-to-end tr…
omarsoul09 Aug 13, 2026
12eb992
sanad: worker parity e2e — dev and cloud runner agree on output and e…
omarsoul09 Aug 13, 2026
2ea7b84
sanad: agent pages + per-agent openapi — org list, run history, typed…
omarsoul09 Aug 13, 2026
9c8dcea
sanad: agent openapi route contract tests — auth, fallback order, sco…
omarsoul09 Aug 13, 2026
63819a0
sanad: run lifecycle hardening — maintained staleness signal, guarded…
omarsoul09 Aug 13, 2026
084ba19
sanad: invoke + completion contract tests
omarsoul09 Aug 13, 2026
4bd9c5f
sanad: merge main — coder panel p1 line composed with worker runtime …
omarsoul09 Aug 13, 2026
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
180 changes: 180 additions & 0 deletions control-plane/artifacts/sanad-web/app/agents/[name]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { auth } from "@clerk/nextjs/server";
import { notFound, redirect } from "next/navigation";
import Link from "next/link";
import type { CSSProperties } from "react";
import Nav from "../../ui/Nav";
import { chip, surface, type } from "../../ui/theme";
import { getAgentDetailByName, getLiveDeployment } from "@/lib/agents/registry";
import { listRuns } from "@/lib/runs/store";
import { formatAge, formatUsd } from "../format";

export const metadata = { title: "sanad — agent" };

/**
* Agent detail (P0 minimal page, Task 14): owner + status + the live
* deployment per env, and the last 20 runs — read-only, server rendered.
* No pause/resume affordance here; those stay CLI verbs (`sanad agent
* pause`/`resume`) in P0.
*/
export default async function AgentDetailPage({
params,
}: {
params: Promise<{ name: string }>;
}) {
const { userId } = await auth();
if (!userId) redirect("/");

const orgId = `personal_${userId}`;
const { name } = await params;
const agent = await getAgentDetailByName(orgId, name);
if (!agent) notFound();

const [dev, prod, runs] = await Promise.all([
getLiveDeployment(agent.id, "dev"),
getLiveDeployment(agent.id, "prod"),
listRuns({ orgId, agentId: agent.id, limit: 20 }),
]);

const deployments = [
{ env: "dev", deployment: dev },
{ env: "prod", deployment: prod },
];

return (
<div style={surface.page}>
<Nav />
<main className="pad-x" style={s.main}>
<p style={s.breadcrumb}>
<Link href="/agents" className="link">
Agents
</Link>
</p>

<header style={s.header}>
<h1 style={type.h1}>{agent.name}</h1>
<p style={s.sub}>
{agent.ownerEmail}
<span style={s.dot}>·</span>
<span style={chip}>{agent.status}</span>
</p>
</header>

<section style={s.section}>
<h2 style={type.eyebrow}>Deployments</h2>
<table style={s.table}>
<thead>
<tr>
<th style={s.th}>Env</th>
<th style={s.th}>Status</th>
<th style={s.th}>Version</th>
<th style={s.th}>Updated</th>
</tr>
</thead>
<tbody>
{deployments.map(({ env, deployment }) => (
<tr key={env}>
<td style={s.td}>{env}</td>
<td style={s.td}>{deployment?.status ?? "not deployed"}</td>
<td style={s.td}>
{deployment ? (
<code style={s.mono}>{deployment.agentVersionId}</code>
) : (
"—"
)}
</td>
<td style={s.td}>
{deployment ? formatAge(deployment.updatedAt) : "—"}
</td>
</tr>
))}
</tbody>
</table>
</section>

<section style={s.section}>
<h2 style={type.eyebrow}>Last runs</h2>
{runs.length === 0 ? (
<p style={s.empty}>No runs yet.</p>
) : (
<table style={s.table}>
<thead>
<tr>
<th style={s.th}>Run</th>
<th style={s.th}>Status</th>
<th style={{ ...s.th, textAlign: "right" }}>Cost</th>
<th style={{ ...s.th, textAlign: "right" }}>Tokens in</th>
<th style={{ ...s.th, textAlign: "right" }}>Tokens out</th>
<th style={s.th}>Age</th>
</tr>
</thead>
<tbody>
{runs.map((run) => (
<tr key={run.id}>
<td style={s.td}>
<code style={s.mono}>{run.id}</code>
</td>
<td style={s.td}>{run.status}</td>
<td style={{ ...s.td, textAlign: "right" }}>
{formatUsd(run.costUsdMicros)}
</td>
<td style={{ ...s.td, textAlign: "right" }}>
{run.tokensIn.toLocaleString()}
</td>
<td style={{ ...s.td, textAlign: "right" }}>
{run.tokensOut.toLocaleString()}
</td>
<td style={s.td}>{formatAge(run.createdAt)}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</main>
</div>
);
}

const s: Record<string, CSSProperties> = {
main: {
maxWidth: "1000px",
margin: "0 auto",
padding: "2.5rem 2.5rem 5rem",
width: "100%",
},
breadcrumb: { ...type.small, marginBottom: "1.5rem" },
header: { marginBottom: "2.5rem" },
sub: {
margin: "0.5rem 0 0",
display: "flex",
alignItems: "center",
color: "var(--ink-muted)",
fontSize: "0.875rem",
},
dot: { margin: "0 0.55rem", color: "var(--rule-strong)" },
section: { marginBottom: "3rem" },
empty: { ...type.small },
mono: {
fontFamily: "var(--font-mono)",
fontSize: "0.8rem",
color: "var(--ink)",
},
table: { width: "100%", borderCollapse: "collapse" },
th: {
padding: "0 0.25rem 0.5rem",
fontFamily: "var(--font-mono)",
fontSize: "0.68rem",
textTransform: "uppercase",
letterSpacing: "0.12em",
color: "var(--ink-muted)",
fontWeight: 500,
textAlign: "left",
borderBottom: "1px solid var(--rule)",
},
td: {
padding: "0.7rem 0.25rem",
fontSize: "0.875rem",
color: "var(--ink-soft)",
borderBottom: "1px solid var(--rule)",
},
};
23 changes: 23 additions & 0 deletions control-plane/artifacts/sanad-web/app/agents/format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* Display formatting for the agents pages. Colocated rather than in lib/ —
* nothing else in the app needs a relative-age string or a run's dollar cost
* formatted this way yet, so this stays local until a second caller shows up.
*/

/** "3m ago" / "2h ago" / "5d ago" — coarse relative age, newest unit only. */
export function formatAge(date: Date): string {
const ms = Date.now() - date.getTime();
const seconds = Math.floor(ms / 1000);
if (seconds < 60) return "just now";
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}

/** A run's cost, stored in micros (1 USD = 1_000_000 micros), as "$0.0031". */
export function formatUsd(micros: number): string {
return `$${(micros / 1e6).toFixed(4)}`;
}
140 changes: 140 additions & 0 deletions control-plane/artifacts/sanad-web/app/agents/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { auth } from "@clerk/nextjs/server";
import { redirect } from "next/navigation";
import Link from "next/link";
import type { CSSProperties } from "react";
import Nav from "../ui/Nav";
import { chip, surface, type } from "../ui/theme";
import { getLiveDeployment, listAgentsForOrgWithOwnerEmail } from "@/lib/agents/registry";
import { listRuns } from "@/lib/runs/store";
import { formatAge } from "./format";

export const metadata = { title: "sanad — agents" };

/**
* Agents (P0 minimal page, Task 14): every agent in the org with its owner,
* status, live deployment per env, and last run — read-only, server
* rendered. Per-agent deployment/run lookups run in parallel (Promise.all)
* rather than one mega-join, matching the same N-small-queries convention
* ProjectsPage uses for its per-project session counts; P0's agent counts
* are small enough that this stays cheap.
*/
export default async function AgentsPage() {
const { userId } = await auth();
if (!userId) redirect("/");

const orgId = `personal_${userId}`;
const agentRows = await listAgentsForOrgWithOwnerEmail(orgId);

const rows = await Promise.all(
agentRows.map(async (agent) => {
const [dev, prod, lastRuns] = await Promise.all([
getLiveDeployment(agent.id, "dev"),
getLiveDeployment(agent.id, "prod"),
listRuns({ orgId, agentId: agent.id, limit: 1 }),
]);
return {
...agent,
devStatus: dev?.status ?? null,
prodStatus: prod?.status ?? null,
lastRun: lastRuns[0] ?? null,
};
})
);

return (
<div style={surface.page}>
<Nav />
<main className="pad-x" style={s.main}>
<header style={s.header}>
<h1 style={type.h1}>Agents</h1>
<p style={{ ...type.small, marginTop: "0.5rem" }}>
{rows.length} agent{rows.length === 1 ? "" : "s"} across this org.
</p>
</header>

{rows.length === 0 ? (
<p style={s.empty}>
No agents yet — push one with{" "}
<code style={s.inlineCode}>sanad agent deploy</code>.
</p>
) : (
<table style={s.table}>
<thead>
<tr>
<th style={s.th}>Name</th>
<th style={s.th}>Owner</th>
<th style={s.th}>Status</th>
<th style={s.th}>Dev</th>
<th style={s.th}>Prod</th>
<th style={s.th}>Last run</th>
</tr>
</thead>
<tbody>
{rows.map((agent) => (
<tr key={agent.id}>
<td style={s.td}>
<Link
href={`/agents/${encodeURIComponent(agent.name)}`}
className="link"
>
{agent.name}
</Link>
</td>
<td style={s.td}>{agent.ownerEmail}</td>
<td style={s.td}>
<span style={chip}>{agent.status}</span>
</td>
<td style={s.td}>{agent.devStatus ?? "—"}</td>
<td style={s.td}>{agent.prodStatus ?? "—"}</td>
<td style={s.td}>
{agent.lastRun
? `${agent.lastRun.status} · ${formatAge(agent.lastRun.createdAt)}`
: "—"}
</td>
</tr>
))}
</tbody>
</table>
)}
</main>
</div>
);
}

const s: Record<string, CSSProperties> = {
main: {
maxWidth: "1000px",
margin: "0 auto",
padding: "3.5rem 2.5rem 5rem",
width: "100%",
},
header: { marginBottom: "2.5rem" },
empty: { ...type.small },
inlineCode: {
fontFamily: "var(--font-mono)",
fontSize: "0.85em",
background: "var(--paper-sunken)",
border: "1px solid var(--rule)",
borderRadius: "5px",
padding: "0.05rem 0.35rem",
color: "var(--ink)",
},
table: { width: "100%", borderCollapse: "collapse" },
th: {
padding: "0 0.25rem 0.5rem",
fontFamily: "var(--font-mono)",
fontSize: "0.68rem",
textTransform: "uppercase",
letterSpacing: "0.12em",
color: "var(--ink-muted)",
fontWeight: 500,
textAlign: "left",
borderBottom: "1px solid var(--rule)",
},
td: {
padding: "0.7rem 0.25rem",
fontSize: "0.875rem",
color: "var(--ink-soft)",
borderBottom: "1px solid var(--rule)",
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { timingSafeEqual } from "crypto";
import { NextRequest } from "next/server";
import { ok, err } from "@/lib/http/envelope";
import { DEFAULT_STALE_MS, sweepLostRuns } from "@/lib/runs/reaper";

// Floor for staleAfterMs — a caller-supplied 0 or negative value must not
// reap every currently-running run instantly.
const MIN_STALE_MS = 60_000;

function secretMatches(header: string | null): boolean {
const secret = process.env.CRON_SECRET;
if (!secret || !header) return false; // unset CRON_SECRET => always 401, fail closed
const a = Buffer.from(header);
const b = Buffer.from(secret);
return a.length === b.length && timingSafeEqual(a, b);
}

/**
* Cron entrypoint for the lost-run reaper — not session- or machine-authed,
* just a shared secret the scheduler holds (same shape as
* ROUTER_SHARED_SECRET's x-router-secret check in
* app/api/v1/compute/route/route.ts).
*/
export async function POST(req: NextRequest) {
if (!secretMatches(req.headers.get("x-cron-secret"))) {
return err(401, "unauthorized", "Invalid cron credential");
}

const raw = (await req.json().catch(() => ({}))) as { staleAfterMs?: unknown };
const requested = typeof raw.staleAfterMs === "number" ? raw.staleAfterMs : DEFAULT_STALE_MS;
const staleAfterMs = Math.max(requested, MIN_STALE_MS);

const reaped = await sweepLostRuns(staleAfterMs);
return ok({ reaped });
}
Loading
Loading