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
14 changes: 14 additions & 0 deletions cloudflare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>` 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
Expand Down
42 changes: 42 additions & 0 deletions cloudflare/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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".
Expand Down
9 changes: 8 additions & 1 deletion cloudflare/src/board/pages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, string> }) {
const { board, show } = props;
const { project } = board;
const recent = board.finished.slice(0, 8);
Expand Down Expand Up @@ -223,6 +228,8 @@ export function BoardPage(props: { board: Board; show: Status["kind"] | null })
)}
</header>

{props.agents && <QuestMap board={board} agents={props.agents} />}

{milestones.map(({ milestone, items }) => (
<MilestoneSection milestone={milestone} items={items} />
))}
Expand Down
131 changes: 131 additions & 0 deletions cloudflare/src/inbox/activity.ts
Original file line number Diff line number Diff line change
@@ -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<Phase, string> = {
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<string, Runners>,
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] : [])));
}
37 changes: 26 additions & 11 deletions cloudflare/src/inbox/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,8 @@ function held(entity: Pick<Item, "kind" | "activity">) {
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];
Expand Down Expand Up @@ -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<string, Runners>(
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<string>) {
return new Map<string, Runners>(
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. */
Expand Down
Loading
Loading