diff --git a/cloudflare/README.md b/cloudflare/README.md
index 96e98596..ffd1f705 100644
--- a/cloudflare/README.md
+++ b/cloudflare/README.md
@@ -216,6 +216,20 @@ awaits a decision, and any other whose issue, pull request, or quest is still
observed, so finished work stays set aside. Retrying a failed planning
interview keeps its `plans/` target.
+Beside the decisions (below them on a phone), an Agents card shows each job
+on the maintainer's projects (`src/inbox/activity.ts`): every target's latest
+job while it is queued, running, or waiting, and an ended one while the pull
+request it delivered is open and no later job works on that pull request. A
+card names the job's target and state (queued, working, runner offline,
+waiting on you, account limited, PR open, or stopped), its runner and account,
+the agent's latest checkpoint or result summary as escaped text, and how long
+ago it wrote it. A working job silent for 15 minutes says "No update since"
+instead of guessing. Each card stands in for its job's in-progress row. Raw
+output stays behind the card's disclosure: `/app.js` fetches the output route
+above only once a maintainer opens it, and shows it as text. An enrolled
+project's quest log also charts its tree as the quest map, ringing the quests
+those same jobs work on.
+
A user's projects are those in `github_project_members`. Enrolling a project
adds its enroller, and any other user joins by passing the maintainer check on
one of its pages, such as its setup page; no extra GitHub calls list
diff --git a/cloudflare/src/app.js b/cloudflare/src/app.js
index 2aa4ab57..34baf087 100644
--- a/cloudflare/src/app.js
+++ b/cloudflare/src/app.js
@@ -62,6 +62,48 @@ for (const form of document.querySelectorAll('form[action="/sign-out"]')) {
);
}
+// A job's raw output loads only when a maintainer opens its disclosure, a page
+// at a time, and the server rechecks their authority on every page. It is
+// shown as text, never markup.
+for (const details of document.querySelectorAll("details[data-output]")) {
+ const output = details.querySelector("pre");
+ const status = details.querySelector("[data-output-status]");
+ const more = details.querySelector("[data-output-more]");
+ let next = null;
+ let started = false;
+ async function load() {
+ more.disabled = true;
+ try {
+ const url = new URL(details.dataset.output, location.href);
+ if (next) url.searchParams.set("after", `${next.attempt}.${next.seq}`);
+ const response = await fetch(url, { headers: { Accept: "application/json" } });
+ const page = await response.json().catch(() => null);
+ if (!response.ok) throw new Error(page?.error || "The raw output could not be read.");
+ output.append(page.output.map((chunk) => chunk.text).join(""));
+ output.hidden = !output.textContent;
+ next = page.next;
+ more.hidden = !next;
+ status.textContent = !output.textContent
+ ? "No raw output is stored for this job."
+ : page.truncated.length
+ ? "Some output was dropped at the size limit."
+ : "";
+ } catch (error) {
+ status.textContent = error.message || "Something went wrong.";
+ // Opening the disclosure again retries a first page that failed.
+ if (!output.textContent) started = false;
+ } finally {
+ more.disabled = false;
+ }
+ }
+ details.addEventListener("toggle", () => {
+ if (!details.open || started) return;
+ started = true;
+ load();
+ });
+ more.addEventListener("click", load);
+}
+
// Whether the server notifies this browser's subscription for whoever is
// signed in. It forgets one when a session signs out, and never hands one to
// another user, so the browser's own subscription is not enough to show "on".
diff --git a/cloudflare/src/board/pages.tsx b/cloudflare/src/board/pages.tsx
index c39389dd..ee599138 100644
--- a/cloudflare/src/board/pages.tsx
+++ b/cloudflare/src/board/pages.tsx
@@ -2,6 +2,7 @@ import type { Child } from "hono/jsx";
import { raw } from "hono/html";
import { type Doc, entries, rooted } from "quest/core";
import { type Crumb, Difficulty, Marker, Paste, SignIn, Token, type User } from "../layout";
+import { QuestMap } from "../map";
import { render } from "./markdown";
import {
type Board,
@@ -191,7 +192,11 @@ function Tally(props: { board: Board; show: Status["kind"] | null }) {
);
}
-export function BoardPage(props: { board: Board; show: Status["kind"] | null }) {
+/**
+ * A project's quest log. An enrolled project's also charts its tree as the
+ * quest map, highlighting the quests `agents` work on.
+ */
+export function BoardPage(props: { board: Board; show: Status["kind"] | null; agents?: Map }) {
const { board, show } = props;
const { project } = board;
const recent = board.finished.slice(0, 8);
@@ -223,6 +228,8 @@ export function BoardPage(props: { board: Board; show: Status["kind"] | null })
)}
+ {props.agents && }
+
{milestones.map(({ milestone, items }) => (
))}
diff --git a/cloudflare/src/inbox/activity.ts b/cloudflare/src/inbox/activity.ts
new file mode 100644
index 00000000..f8a6964b
--- /dev/null
+++ b/cloudflare/src/inbox/activity.ts
@@ -0,0 +1,131 @@
+// Agent cards: what each job on a maintainer's projects is doing, told by the
+// agent's own checkpoint summaries. Summaries are untrusted agent text, shown
+// as escaped text and never read as an answer. Raw output is not part of a
+// card; it loads behind a per-job disclosure that rechecks maintainer authority.
+
+import { parse } from "quest/core";
+import type { Enrollment } from "../github/service";
+import { type Item, type JobView, type ProjectView, type Runners, entities, fallback } from "./model";
+
+/** How long a working job may go without a summary before its card says so, rather than guessing. */
+export const QUIET = 15 * 60_000;
+
+/**
+ * Where a job stands: waiting for a runner, working, on a runner that went
+ * offline, waiting on a maintainer's answer, paused on a limited account, or
+ * ended with the pull request it delivered still open, either finished or stopped.
+ */
+export type Phase = "queued" | "working" | "offline" | "waiting" | "limited" | "pull" | "stopped";
+
+export const PHASES: Record = {
+ queued: "Queued",
+ working: "Working",
+ offline: "Runner offline",
+ waiting: "Waiting on you",
+ limited: "Account limited",
+ pull: "PR open",
+ stopped: "Stopped",
+};
+
+export type Card = {
+ project: Enrollment;
+ job: JobView;
+ /** The target the job works on: its issue, pull request, or quest. */
+ title: string;
+ href: string;
+ /** The quest the job works on, which the quest map highlights. */
+ quest: string | null;
+ phase: Phase;
+ runner: string | null;
+ account: string | null;
+ /** The pull request the job delivered. */
+ pull: { number: number; href: string } | null;
+ /** When the agent last wrote a summary, or when the job was requested. */
+ at: number;
+ /** A working job silent for longer than `QUIET`. */
+ quiet: boolean;
+ /** The inbox item for the job's target, where its thread and stop control live. */
+ item: Item | null;
+};
+
+export function phase(job: JobView, runners: Runners): Phase {
+ if (job.state === "queued") return "queued";
+ if (job.state === "running") {
+ const runner = runners.find((r) => r.id === job.runner);
+ return job.lease !== null || !runner?.online ? "offline" : "working";
+ }
+ if (job.state === "waiting") {
+ const question = job.questions.find((q) => q.attempt === job.attempt && q.approvedAt === null);
+ return question?.kind === "account" && question.answer === null ? "limited" : "waiting";
+ }
+ return job.state === "completed" ? "pull" : "stopped";
+}
+
+// Cards that wait on a maintainer come first, then trouble, then work under way.
+const ORDER: Phase[] = ["waiting", "limited", "offline", "working", "queued", "stopped", "pull"];
+
+/** The cards for each project's jobs, with `items` from the same inbox load. */
+export function cards(
+ views: ProjectView[],
+ jobs: JobView[][],
+ runners: Map,
+ items: Item[],
+ now: number,
+): Card[] {
+ return views
+ .flatMap((view, i) => {
+ const { project, state } = view;
+ const found = entities(project, state);
+ const web = `https://github.com/${project.owner}/${project.name}`;
+ return jobs[i]!.map((job): Card => {
+ const own = runners.get(job.actor) ?? [];
+ const runner = own.find((r) => r.id === job.runner);
+ const entity = found.get(job.target) ?? fallback(project, job.target);
+ const kind = entity.kind;
+ // A pull request on a quest's branch works on that quest, so the map keeps it highlighted.
+ const number = kind === "pull" ? Number(job.target.split("/")[1]) : null;
+ const quest =
+ kind === "quest"
+ ? job.target
+ : (state?.project.changes.find((change) => change.number === number)?.quest ?? null);
+ // A claimed quest is no longer an observed entity, so its title comes from the tree.
+ const document = kind === "quest" ? state?.project.documents.find((d) => d.path === quest) : undefined;
+ const summaries = job.events.filter((e) => e.kind === "summary" || e.kind === "result");
+ const at = summaries.at(-1)?.at ?? job.createdAt;
+ const current = phase(job, own);
+ return {
+ project,
+ job,
+ title: document ? (parse(document.path, document.content).title?.text ?? quest!) : entity.title,
+ href: entity.href,
+ quest,
+ phase: current,
+ runner: runner?.name ?? null,
+ account: runner?.accounts.find((a) => a.id === job.account)?.label ?? null,
+ pull: job.pull === null ? null : { number: job.pull, href: `${web}/pull/${job.pull}` },
+ at,
+ quiet: current === "working" && now - at > QUIET,
+ item: items.find((it) => it.project.id === project.id && it.target === job.target) ?? null,
+ };
+ });
+ })
+ .sort((a, b) => ORDER.indexOf(a.phase) - ORDER.indexOf(b.phase) || b.at - a.at);
+}
+
+/** How long ago `at` was, roughly: just now, minutes, hours, then days. */
+export function ago(now: number, at: number): string {
+ const minutes = Math.max(0, Math.round((now - at) / 60_000));
+ if (minutes === 0) return "just now";
+ const [count, unit] =
+ minutes < 60
+ ? [minutes, "minute"]
+ : minutes < 48 * 60
+ ? [Math.round(minutes / 60), "hour"]
+ : [Math.round(minutes / 1440), "day"];
+ return `${count} ${unit}${count === 1 ? "" : "s"} ago`;
+}
+
+/** The quest map's highlights: what the agent on each quest is doing, by the quest's path. */
+export function highlights(list: Card[]) {
+ return new Map(list.flatMap((card) => (card.quest ? [[card.quest, PHASES[card.phase]] as const] : [])));
+}
diff --git a/cloudflare/src/inbox/model.ts b/cloudflare/src/inbox/model.ts
index a963c110..fb499c76 100644
--- a/cloudflare/src/inbox/model.ts
+++ b/cloudflare/src/inbox/model.ts
@@ -112,7 +112,8 @@ function held(entity: Pick) {
return entity.kind === "pull" && !!entity.activity?.data && owned(entity.activity.data as Pull);
}
-function fallback(project: Enrollment, target: string): Entity {
+/** What an item is called and links to when nothing observed names its target. */
+export function fallback(project: Enrollment, target: string): Entity {
const web = `https://github.com/${project.owner}/${project.name}`;
const kind = kindOf(target);
const number = target.split("/")[1];
@@ -264,22 +265,36 @@ export async function load(env: Env, user: string) {
}),
);
const projects = views.filter((view) => view !== null);
- const items = await collect(
- env.DB,
- user,
- projects.filter((view) => !view.error),
- );
- // Jobs run on their requester's runners, so presence comes from each requester's hub.
- const actors = [...new Set(items.flatMap((item) => (item.job ? [item.job.actor] : [])))];
- const runners = new Map(
+ const readable = projects.filter((view) => !view.error);
+ const [items, activity] = await Promise.all([
+ collect(env.DB, user, readable),
+ Promise.all(readable.map((view) => store.listActivityJobs(env.DB, view.project.id, openPulls(view)))),
+ ]);
+ const actors = [
+ ...items.flatMap((item) => (item.job ? [item.job.actor] : [])),
+ ...activity.flat().map((j) => j.actor),
+ ];
+ return { projects, readable, items, activity, runners: await presence(env, actors) };
+}
+
+/** The open pull requests the project's observations list. */
+export function openPulls(view: ProjectView) {
+ return [...entities(view.project, view.state).keys()].flatMap((target) => {
+ const number = target.match(/^pulls\/(\d+)$/)?.[1];
+ return number ? [Number(number)] : [];
+ });
+}
+
+/** Each job requester's runners, with presence from their hub: jobs run on their requester's runners. */
+export async function presence(env: Env, actors: Iterable) {
+ return new Map(
await Promise.all(
- actors.map(
+ [...new Set(actors)].map(
async (actor) =>
[actor, await store.listRunners(env.DB, actor, new Set(await hub(env, actor).online()))] as const,
),
),
);
- return { projects, items, runners };
}
/** Record that the maintainer saw an item as it is now. */
diff --git a/cloudflare/src/inbox/page.tsx b/cloudflare/src/inbox/page.tsx
index 007f441d..053517cc 100644
--- a/cloudflare/src/inbox/page.tsx
+++ b/cloudflare/src/inbox/page.tsx
@@ -3,6 +3,7 @@ import { type Prompt, parsePrompt } from "../jobs/prompt";
import { type Done, PUBLICATION_REPLY, inboxURL } from "../jobs/routes";
import { Marker, Notifications } from "../layout";
import { projectURL } from "../projects";
+import { type Card, PHASES, ago } from "./activity";
import type { Item, ProjectView, QuestionView, Runners } from "./model";
// The decision inbox, phone first: one active decision thread, then what is
@@ -21,6 +22,9 @@ export type InboxProps = {
working: Item[];
skipped: Item[];
runners: Map;
+ /** What each agent job is doing, beside the decisions. */
+ cards: Card[];
+ now: number;
/** The VAPID public key, or null where notifications are not configured. */
notifications: string | null;
};
@@ -170,7 +174,7 @@ function Approval(props: { item: Item }) {
);
}
-function jobsURL(item: Item) {
+function jobsURL(item: Pick) {
return `${projectURL(item.project.owner, item.project.name)}/jobs`;
}
@@ -533,76 +537,162 @@ function Project(props: { view: ProjectView }) {
);
}
+const workflows = { plan: "Planning", start: "Implementing", iterate: "Iterating", bootstrap: "Adding a devShell" };
+
+/**
+ * One agent job: its target, state, runner and account, and the agent's latest
+ * summary, as escaped text. Raw output loads only when a maintainer opens its
+ * disclosure, and the server rechecks their authority on every read.
+ */
+function AgentCard(props: { card: Card; runners: Runners; user: string; now: number }) {
+ const { card, now } = props;
+ const { job, item } = card;
+ // Another maintainer's answer waits on the requester's approval, not on this viewer.
+ const label = card.phase === "waiting" && item?.need === null ? "Awaiting approval" : PHASES[card.phase];
+ const status = item && progress(item, props.runners, props.user);
+ const when = ago(now, card.at);
+ const output = `${jobsURL(card)}/${job.id}/output`;
+ return (
+
+ No enrolled projects yet. Enroll a project, or open one a
+ co-maintainer enrolled, to get its issues, pull requests, and ready quests here.
+
+ )}
+
+ Lists enrolled projects you maintain, once you open each one's setup page. Runners and accounts
+ are set up with quest runner; your runners shows them and
+ the order jobs use your accounts in.
- Lists enrolled projects you maintain, once you open each one's setup page. Runners and accounts are
- set up with quest runner; your runners shows them and the order
- jobs use your accounts in.
-