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
40 changes: 38 additions & 2 deletions cloudflare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,16 @@ An account limit pauses the job with an account decision; kix never switches
accounts on its own. An answer requeues the job and resumes its session on the
runner that holds it.

A `question` carries one round of independent questions
(`src/jobs/prompt.ts`): `{ kind, questions: [{ header?, question, options:
[{ label, description?, recommended? }], multiple? }] }`, where `kind` is
`decision` (the default) or `permission` for a tool permission request. The
service refuses a prompt it cannot render. An agent question's answer is
`{ answers: [{ choices, text? }] }`, one per question, naming offered labels,
the maintainer's own words, or both; an account question's answer is
`{ runner, account }`. A job request may name the `revision` the maintainer
saw, and is refused once observations moved past it.

The current attempt publishes its work over HTTPS at `POST /api/runner/publish`,
not over its connection, so slow GitHub writes never hold up the hub. It sends
file changes as data; the Worker commits them as the GitHub App to the job's
Expand Down Expand Up @@ -150,6 +160,30 @@ prefix at once. The cron retries any deletion that failed. An upload racing the
disconnect finds its index row gone, queues its job's prefix for the cron again,
and deletes its own object.

### Decision inbox

After signing in, a maintainer lands on `/inbox` (`src/inbox/`): one active
decision thread across the projects they enrolled, then what is up next, what
is in progress, and their projects. Each item is one job target (`issues/1`,
`pulls/2`, a quest path), so an issue, its planning job, and that job's
questions stay one item, and webhook updates and reconnects never add another
prompt. Agents waiting on an answer come first, then failed jobs, new pull
requests, new issues, and ready quests. An item offers only actions that
would work: none on a paused project, and only a refresh while observations
are incomplete. Every action is a plain form post to the job routes, which
recheck authority and return to the inbox; prompts render as escaped text and
never preselect the recommendation. A `publication` decision lists the refused
paths or why the branch is blocked, and takes the maintainer's reply as text.
The inbox reads each target's latest job however old it is: every one that
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.

Each maintainer's read state lives in `inbox_items`, keyed by a version of what
the item showed (a question, a GitHub change, a quest's text). A newer version
is unread again, and skipping an item lasts until it changes. A finished or
stopped job sets its item aside the same way.

`cloudflare/` is a Bun workspace of the repository root, so the Worker
imports the CLI's core as `quest/core` (`../src/core`), the same parser, checks,
and readiness the CLI runs.
Expand Down Expand Up @@ -212,8 +246,10 @@ maintainer-only routes. Publication tests run against a stateful fake of
GitHub's Git and pull request APIs: fenced and stale writes, workflow path
limits and their decisions, lost branch updates and deliveries, iterated heads,
and token scopes. Output tests use local R2 for chunk ordering, retries, the
caps, expiry, and cleanup after a disconnect. No OAuth credentials or
Cloudflare account are required for checks.
caps, expiry, and cleanup after a disconnect. Inbox tests answer prompts
through the forms, reject stale revisions and repeated answers, coalesce
duplicate webhooks, and hide a project whose maintainer lost authority. No
OAuth credentials or Cloudflare account are required for checks.

After changing bindings, regenerate types:

Expand Down
16 changes: 16 additions & 0 deletions cloudflare/migrations/0010_inbox.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
-- Each maintainer's own view of a project's decision items, keyed by the job
-- target an item coalesces on (`issues/1`, `pulls/2`, a quest path, ...).
-- A version names what the item showed: a question, a GitHub change, a quest's
-- text. A newer version is unread again; a skip lasts until the item changes.
-- Read state is navigation only, never an answer or an approval.
CREATE TABLE inbox_items (
user TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
repository INTEGER NOT NULL REFERENCES github_projects(id) ON DELETE CASCADE,
item TEXT NOT NULL,
seen TEXT,
skipped TEXT,
PRIMARY KEY(user, repository, item)
);

-- The inbox reads each target's latest job.
CREATE INDEX jobs_target ON jobs(repository, target, createdAt);
2 changes: 1 addition & 1 deletion cloudflare/src/board/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export function blobHref(project: Project, path: string, fragment = ""): string
return project.web ? `${project.web}/blob/${project.commit}/${encodePath(path)}${fragment}` : null;
}

function encodePath(path: string): string {
export function encodePath(path: string): string {
return path.split("/").map(encodeURIComponent).join("/");
}

Expand Down
252 changes: 252 additions & 0 deletions cloudflare/src/inbox/model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
// The decision inbox: everything on a maintainer's projects that waits on them,
// one item per job target. An issue, its planning job, and that job's questions
// are one item, so updates, reconnects, and duplicate webhooks coalesce instead
// of adding prompts. Agent text and GitHub data stay untrusted display data.

import { parse } from "quest/core";
import { encodePath } from "../board/model";
import { GitHubError, type Enrollment, GitHubService } from "../github/service";
import type { Activity, ProjectState } from "../github/state";
import { type Prompt, parsePrompt } from "../jobs/prompt";
import { hub } from "../jobs/routes";
import * as store from "../jobs/store";
import { projectURL } from "../projects";

export type JobView = NonNullable<Awaited<ReturnType<typeof store.readJob>>>;
export type QuestionView = JobView["questions"][number];
/** A maintainer's own read state for one item. */
export type Read = { repository: number; item: string; seen: string | null; skipped: string | null };

/**
* What the item asks of the maintainer: answer an agent, pick an account for
* a limited job, retry a failure, or start work on an issue, PR, or ready
* quest. `null` is work in progress, which only informs.
*/
export type Need = "answer" | "account" | "retry" | "start";
export type Kind = "issue" | "pull" | "quest" | "plan" | "devshell";

export type Item = {
project: Enrollment;
/** The job target the item coalesces on, such as `issues/1` or `quest/a.md`. */
target: string;
kind: Kind;
title: string;
href: string;
activity: Activity | null;
/** The target's latest job. */
job: JobView | null;
/** The job's open question, and its prompt when an agent asked it. */
question: QuestionView | null;
prompt: Prompt | null;
need: Need | null;
/** What the item currently shows; a newer version is unread again and undoes a skip. */
version: string;
unread: boolean;
/** Skipped by the maintainer, or finished by its job, until the item changes. */
skipped: boolean;
at: number;
};

/** One project's place in the inbox, or why it is unavailable. */
export type ProjectView = { project: Enrollment; state: ProjectState | null; error: string | null };

// FNV-1a: a short, stable version for a quest's text.
function digest(text: string) {
let hash = 0x811c9dc5;
for (let i = 0; i < text.length; i++) hash = Math.imul(hash ^ text.charCodeAt(i), 0x01000193);
return (hash >>> 0).toString(16);
}

function kindOf(target: string): Kind {
if (target.startsWith("issues/")) return "issue";
if (target.startsWith("pulls/")) return "pull";
if (target.startsWith("plans/")) return "plan";
return target === "devshell" ? "devshell" : "quest";
}

/** A quest's page on the project's board: its path without `.md`, or its epic's directory. */
export function questHref(project: Enrollment, path: string) {
return `${projectURL(project.owner, project.name)}/board/${encodePath(path.replace(/(\/README)?\.md$/, ""))}`;
}

export type Entity = Pick<Item, "kind" | "title" | "href" | "activity" | "version" | "at">;

/** The open issues, open pull requests, and ready quests a maintainer can act on, by job target. */
export function entities(project: Enrollment, state: ProjectState | null) {
const found = new Map<string, Entity>();
if (!state) return found;
const web = `https://github.com/${project.owner}/${project.name}`;
for (const activity of state.activity) {
// A webhook's pointer has no data until a refresh reads it, but it is already news.
if (activity.missing || (activity.data && activity.data.state !== "OPEN")) continue;
const pull = activity.kind === "pull";
found.set(`${pull ? "pulls" : "issues"}/${activity.number}`, {
kind: activity.kind,
title: activity.data?.title ?? `${pull ? "Pull request" : "Issue"} #${activity.number}`,
href: `${web}/${pull ? "pull" : "issues"}/${activity.number}`,
activity,
version: String(activity.changedAt),
at: activity.changedAt,
});
}
for (const path of state.available) {
const content = state.project.documents.find((d) => d.path === path)?.content ?? "";
found.set(path, {
kind: "quest",
title: parse(path, content).title?.text ?? path,
href: questHref(project, path),
activity: null,
version: digest(content),
at: 0,
});
}
return found;
}

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];
const titles: Record<Kind, [string, string]> = {
issue: [`Issue #${number}`, `${web}/issues/${number}`],
pull: [`Pull request #${number}`, `${web}/pull/${number}`],
plan: ["New plan", web],
devshell: ["Add a flake devShell", web],
quest: [target, questHref(project, target)],
};
const [title, href] = titles[kind];
return { kind, title, href, activity: null, version: "", at: 0 };
}

/**
* Build one project's items from its observed `found` entities, its latest
* jobs, and the maintainer's read state.
*/
export function decisions(view: ProjectView, found: Map<string, Entity>, jobs: JobView[], reads: Read[]): Item[] {
const { project } = view;
const latest = new Map<string, JobView>();
// Jobs arrive newest first.
for (const job of jobs) if (!latest.has(job.target)) latest.set(job.target, job);
const items: Item[] = [];
for (const target of new Set([...found.keys(), ...latest.keys()])) {
const entity = found.get(target);
const job = latest.get(target) ?? null;
// GitHub showed a change after the job ended, such as a new push or comment.
const changed = !!entity && !!job && entity.at > job.updatedAt;
let need: Need | null = null;
let version = entity?.version ?? "";
let question: QuestionView | null = null;
let ended = false;
if (job && (job.state === "queued" || job.state === "running")) version = `job:${job.id}`;
else if (job?.state === "waiting") {
question = job.questions.find((q) => q.attempt === job.attempt && q.answer === null) ?? null;
if (question) need = question.kind === "account" ? "account" : "answer";
version = `question:${question?.id}`;
} else if (job?.state === "failed" && !changed) {
need = "retry";
version = `job:${job.id}`;
} else if (entity) {
// Starting new work on a paused project would be refused, so it is not offered.
if (project.state !== "enabled") continue;
need = "start";
ended = !!job && !changed;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Let edited quests resurface after terminal jobs

When a completed or cancelled quest is subsequently edited on the default branch while remaining ready, this always marks it as ended because quest entities have at: 0, so changed can never exceed the job's updatedAt. Although the quest digest produces a new version, the unconditional ended flag keeps the item in the collapsed “Skipped and finished” section instead of making the changed quest an unread decision again.

Useful? React with 👍 / 👎.

} else continue;
const read = reads.find((r) => r.repository === project.id && r.item === target);
let prompt: Prompt | null = null;
if (question?.kind === "agent")
try {
prompt = parsePrompt(question.prompt);
} catch {}
items.push({
...(entity ?? fallback(project, target)),
project,
target,
job,
question,
prompt,
need,
version,
unread: need !== null && read?.seen !== version,
skipped: need !== null && (ended || read?.skipped === version),
at: question?.askedAt ?? (need === "retry" ? job!.updatedAt : (entity?.at ?? job?.updatedAt ?? 0)),
});
}
return items;
}

// Agents blocked on an answer come first, then failures, then new PRs,
// issues, and ready quests: the loop of reviewing, planning, and spawning.
const order = (item: Item) =>
item.need === "answer" || item.need === "account"
? 0
: item.need === "retry"
? 1
: ({ pull: 2, issue: 3, quest: 4, plan: 5, devshell: 5 } as const)[item.kind];

/** Sort the items: decisions in priority order, then work in progress, then what was set aside. */
export function arrange(items: Item[]) {
const sorted = [...items].sort((a, b) => order(a) - order(b) || (order(a) === 0 ? a.at - b.at : b.at - a.at));
return {
decisions: sorted.filter((item) => item.need !== null && !item.skipped),
working: sorted.filter((item) => item.need === null),
skipped: sorted.filter((item) => item.skipped),
};
}

export type Runners = Awaited<ReturnType<typeof store.listRunners>>;

/**
* Load the inbox across the projects a maintainer enrolled. Every project
* rechecks current GitHub authority; one that fails is reported, not hidden.
*/
export async function load(env: Env, user: string) {
const github = new GitHubService(env);
const projects: ProjectView[] = await Promise.all(
(await github.projects(user)).map(async (project) => {
try {
return { project, state: await github.projectState(project, user), error: null };
} catch (error) {
if (!(error instanceof GitHubError)) throw error;
return { project, state: null, error: error.message };
}
}),
);
const usable = projects.filter((view) => !view.error);
const found = usable.map((view) => entities(view.project, view.state));
const [reads, jobs] = await Promise.all([
env.DB.prepare("SELECT repository,item,seen,skipped FROM inbox_items WHERE user=?").bind(user).all<Read>(),
Promise.all(usable.map((view, i) => store.listInboxJobs(env.DB, view.project.id, [...found[i]!.keys()]))),
]);
Comment on lines +216 to +219

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Load active jobs beyond the 50-job history limit

For repositories with more than 50 jobs, store.listJobs() returns only the 50 newest records (ORDER BY createdAt DESC ... LIMIT 50), but the inbox treats that result as the complete job set. An older queued, running, or waiting job on another target can therefore disappear entirely, including any unanswered question. Load all active jobs separately or paginate history so active decisions are never truncated.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Fixed in 57d059c: the inbox uses store.listInboxJobs, which returns every queued, running, and waiting job along with the 50 newest, so an older open decision is never cut off. The JSON jobs listing keeps its 50-job page. Regression test: "keeps an older job's question beyond the newest fifty jobs".

(Written by Claude Opus 5.5)

const items = usable.flatMap((view, i) => decisions(view, found[i]!, jobs[i]!, reads.results));
// 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>(
await Promise.all(
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. */
export function markSeen(db: D1Database, user: string, item: Item) {
return db
.prepare(
"INSERT INTO inbox_items(user,repository,item,seen) VALUES(?,?,?,?) ON CONFLICT(user,repository,item) DO UPDATE SET seen=excluded.seen",
)
.bind(user, item.project.id, item.target, item.version)
.run();
}

/** Set an item aside until it changes. */
export function skip(db: D1Database, user: string, repository: number, item: string, version: string) {
return db
.prepare(
"INSERT INTO inbox_items(user,repository,item,skipped) VALUES(?,?,?,?) ON CONFLICT(user,repository,item) DO UPDATE SET skipped=excluded.skipped",
)
.bind(user, repository, item, version)
.run();
}
Loading
Loading