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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -516,9 +516,10 @@ Every job runs in its own checkout inside the project's `nix develop` shell,
with the flake's `nixConfig` honored; a project without a flake gets a bootstrap
job instead. The agent reports through kix tools: `checkpoint` summaries publish
work in progress to the job's branch, `ask` pauses the job on a decision for you,
and `deliver` publishes the result and its pull request. A job on an issue or
and `deliver` publishes the result and its pull request, listing any suggested
follow-ups, which the inbox offers you to plan. A job on an issue or
pull request reads it through `context`, since the runner holds no GitHub
token. kix publishes as its GitHub App, so the runner never pushes. A job that hits an account's limit
token; a new plan reads your idea and pasted context there. kix publishes as its GitHub App, so the runner never pushes. A job that hits an account's limit
pauses for you to pick another account; the runner never switches on its own.
On SIGINT or SIGTERM, running work is published and its jobs move to another
runner. Until the job sandbox ships, runners take only trusted revisions,
Expand Down
24 changes: 19 additions & 5 deletions cloudflare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,13 @@ every request rechecks current GitHub authority. `start` takes only quests that
observations show ready and unclaimed, except that retrying a failed job does
not count that job's own open pull request as a claim: the one it recorded, or
else the only open one observed on its branch at a commit it published. A
retry records the failed job it `continues`. A job on an issue or pull request
retry records the failed job it `continues`. A new `plan` takes the
maintainer's `idea`, up to 4,000 characters, and optional pasted `context`
(an email, call notes, findings), up to 64,000, on its own `plans/<id>`
target; a retry keeps both. A completed job's result may list suggested
`followups`, and planning them targets `plans/<that job>`: kix writes the
idea and takes the follow-ups and summary as context from the job's own
result, never from the request, and offers them once. A job on an issue or pull request
rereads it from GitHub, refusing one that closed or whose head moved since
observations read it. `iterate` jobs are untrusted and route
only to Linux runners that declare VM builds. Without a `flake.nix`, the route
Expand Down Expand Up @@ -155,7 +161,8 @@ Changes a workflow may not publish pause the job with a `publication`
decision. The current attempt of a job on an issue or pull request reads it at
`GET /api/runner/context?job=…&attempt=…`, as the App with the requester's
current authority: an issue's text and comments, or a pull request's text,
comments, reviews, unresolved review threads, and checks at its head. [GITHUB.md](GITHUB.md#publication) describes the request, its
comments, reviews, unresolved review threads, and checks at its head. A new
plan's attempt reads its `idea` and `context` there instead. [GITHUB.md](GITHUB.md#publication) describes the request, its
validation, and how interrupted publications are reconciled.

### Raw output
Expand Down Expand Up @@ -196,16 +203,23 @@ 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. Each webhook rereads its project in the
requests, new issues, ready quests, and a finished job's suggested follow-ups,
offered as "plan these?" on the `plans/<job>` target their interview takes, so
the offer, the interview, and its questions stay one item. Each webhook rereads its project in the
background, so a new issue or PR arrives with its title, ready to act on. An
item offers only actions that would work: none on a paused project, and only a
refresh while availability is pending or a background read failed, with the
failure's reason. Every action is a plain form post to the job routes, which
recheck authority and move on to the next active decision (or the empty
state) with a short notice such as "Answer recorded"; the item acted on stays
reachable from its list with its earlier answers. A refused action says why
and links back to its item. Prompts render as escaped text and never
preselect the recommendation. A `publication` decision lists the refused
and links back to its item. Prompts render as escaped text, list the
recommended option first, always take an answer in the maintainer's own words,
and never preselect the recommendation. Each project's "Plan something new"
box takes an idea and optional pasted context, which the agent reads as
untrusted, and folds the runner and account behind an automatic default. A
plan's thread keeps what it started from and links its draft proposal once
delivered; a running job names its runner and account. A `publication` decision lists the refused
paths or why the branch is blocked, and takes the maintainer's reply as text.
A service fork branch that changed outside its job also offers Start over,
which resets the branch to the job's revision and queues a new job there.
Expand Down
56 changes: 54 additions & 2 deletions cloudflare/dev/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,12 +320,17 @@ async function jobs(db: D1Database, user: string, repo: Repo) {
target: string;
revision: string;
branch: string;
state: "queued" | "running" | "waiting" | "failed";
state: "queued" | "running" | "waiting" | "failed" | "completed";
/** The commit the job last published to its branch. */
head?: string;
runner?: string;
account?: string;
summary?: string;
/** What a completed job reported, such as its suggested follow-ups. */
result?: unknown;
/** A new plan's request: the maintainer's idea and what they pasted with it. */
idea?: string;
context?: string;
question?: { kind: "agent" | "account" | "publication"; prompt: unknown };
};
const list: Job[] = [
Expand Down Expand Up @@ -464,6 +469,50 @@ async function jobs(db: D1Database, user: string, repo: Repo) {
account: "claude-max",
summary: "Reproducing the lost settings in a background tab.",
},
{
id: "00000000-5eed-4000-8000-000000000001",
workflow: "plan",
target: "plans/00000000-5eed-4000-8000-000000000001",
revision: commit,
branch: "plan/00000000",
state: "waiting",
runner: "workstation",
account: "claude-max",
idea: "Let people share a read-only link to a project",
context: "From the call with the design team:\n- links should expire\n- no sign-in to view",
summary: "Read the call notes and the project pages. One choice decides the quests.",
question: {
kind: "agent",
prompt: {
kind: "decision",
questions: [
{
header: "Expiry",
question: "How long should a shared link last?",
options: [
{ label: "Until revoked" },
{ label: "30 days", description: "Matches the call notes.", recommended: true },
],
},
],
},
},
},
{
id: "00000000-5eed-4000-8000-000000000002",
workflow: "start",
target: "quest/m0/cli.md",
revision: commit,
branch: "quest/m0/cli",
state: "completed",
runner: "workstation",
account: "claude-max",
summary: "Added the command-line entry point and its tests.",
result: {
head: null,
followups: ["Shell completions for bash, zsh, and fish", "A --json flag on every listing command"],
},
},
{
id: "seed-start-offline",
workflow: "start",
Expand Down Expand Up @@ -495,7 +544,7 @@ async function jobs(db: D1Database, user: string, repo: Repo) {
return [
db
.prepare(
"INSERT INTO jobs(id,repository,workflow,target,revision,branch,head,actor,trusted,state,attempt,runner,account,delivered,summary,createdAt,updatedAt) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
"INSERT INTO jobs(id,repository,workflow,target,revision,branch,head,actor,trusted,state,attempt,runner,account,delivered,summary,result,idea,context,createdAt,updatedAt) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
)
.bind(
job.id,
Expand All @@ -513,6 +562,9 @@ async function jobs(db: D1Database, user: string, repo: Repo) {
job.account ?? null,
attempt,
job.summary ?? null,
job.result === undefined ? null : JSON.stringify(job.result),
job.idea ?? null,
job.context ?? null,
at,
now,
),
Expand Down
6 changes: 6 additions & 0 deletions cloudflare/migrations/0018_plan_request.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- What a new planning interview starts from: the maintainer's idea in their
-- own words, and an optional pasted block (an email, call notes, findings, or
-- a finished job's suggested follow-ups), which agents read as untrusted
-- context like issue text. A retry keeps both.
ALTER TABLE jobs ADD COLUMN idea TEXT;
ALTER TABLE jobs ADD COLUMN context TEXT;
53 changes: 51 additions & 2 deletions cloudflare/src/inbox/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export type Item = {
title: string;
href: string;
activity: Activity | null;
/** A "plan these?" decision's suggested follow-ups, untrusted agent text. */
followups?: string[];
/** The target's latest job. */
job: JobView | null;
/** The job's open question, or its answer awaiting approval, and its prompt when an agent asked it. */
Expand Down Expand Up @@ -70,7 +72,39 @@ export function questHref(project: Enrollment, path: string) {
return `${projectURL(project.owner, project.name)}/board/${encodePath(questBranch(path))}`;
}

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

/** What a target names in a sentence, such as `issue #4`. */
function describe(target: string) {
const [, kind, number] = target.match(/^(issues|pulls)\/(\d+)$/) ?? [];
if (kind) return `${kind === "issues" ? "issue" : "pull request"} #${number}`;
return target.startsWith("plans/") ? "a plan" : target;
}

/**
* A finished job's suggested follow-ups as a "plan these?" decision. Its
* target is the interview that plans them, `plans/<job>`, so the decision,
* that interview, and its questions stay one item.
*/
export function followupEntity(project: Enrollment, job: store.Followups): Entity {
const web = `https://github.com/${project.owner}/${project.name}`;
return {
kind: "plan",
title: `Follow-ups from ${describe(job.target)}`,
href: job.pull ? `${web}/pull/${job.pull}` : web,
activity: null,
version: `followups:${job.id}`,
at: job.updatedAt,
followups: job.followups,
};
}

/** A plan's title: the first line of the maintainer's idea. */
function planTitle(idea: string | null) {
const line = idea?.trim().split("\n")[0]?.trim();
if (!line) return "New plan";
return line.length <= 80 ? line : `${line.slice(0, 79)}…`;
}

/** 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) {
Expand Down Expand Up @@ -178,8 +212,10 @@ export function decisions(
try {
prompt = parsePrompt(question.prompt);
} catch {}
const shown = entity ?? fallback(project, target);
items.push({
...(entity ?? fallback(project, target)),
...shown,
...(shown.kind === "plan" && !entity && { title: planTitle(job?.idea ?? null) }),
project,
target,
job,
Expand Down Expand Up @@ -219,6 +255,10 @@ export type Runners = Awaited<ReturnType<typeof store.listRunners>>;
/** Every project's items, from its observations, its jobs, and the maintainer's read state. */
async function collect(db: D1Database, user: string, views: ProjectView[]) {
const found = views.map((view) => entities(view.project, view.state));
// Finished jobs' suggested follow-ups wait as "plan these?" decisions until planned.
const followups = await Promise.all(views.map((view) => store.listFollowups(db, view.project.id)));
for (const [i, list] of followups.entries())
for (const job of list) found[i]!.set(`plans/${job.id}`, followupEntity(views[i]!.project, job));
const [reads, jobs] = await Promise.all([
db.prepare("SELECT repository,item,seen,skipped FROM inbox_items WHERE user=?").bind(user).all<Read>(),
Promise.all(views.map((view, i) => store.listInboxJobs(db, view.project.id, [...found[i]!.keys()]))),
Expand All @@ -242,6 +282,13 @@ export async function unread(env: Env, user: string) {

/** What a notification calls an item: its issue, pull request, or quest title, as the inbox shows it. */
export async function title(db: D1Database, project: Enrollment, target: string) {
if (target.startsWith("plans/")) {
const job = await db
.prepare("SELECT idea FROM jobs WHERE repository=? AND target=? ORDER BY createdAt DESC,id LIMIT 1")
.bind(project.id, target)
.first<{ idea: string | null }>();
return planTitle(job?.idea ?? null);
}
const state = await readState(db, project);
return (entities(project, state).get(target) ?? fallback(project, target)).title;
}
Expand Down Expand Up @@ -270,7 +317,9 @@ export async function load(env: Env, user: string) {
collect(env.DB, user, readable),
Promise.all(readable.map((view) => store.listActivityJobs(env.DB, view.project.id, openPulls(view)))),
]);
// The user's own runners are where the jobs they request run.
const actors = [
user,
...items.flatMap((item) => (item.job ? [item.job.actor] : [])),
...activity.flat().map((j) => j.actor),
];
Expand Down
Loading
Loading