diff --git a/README.md b/README.md index 1eb4c1e8..215010b5 100644 --- a/README.md +++ b/README.md @@ -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, diff --git a/cloudflare/README.md b/cloudflare/README.md index ffd1f705..2adafd60 100644 --- a/cloudflare/README.md +++ b/cloudflare/README.md @@ -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/` +target; a retry keeps both. A completed job's result may list suggested +`followups`, and planning them targets `plans/`: 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 @@ -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 @@ -196,7 +203,9 @@ 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/` 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 @@ -204,8 +213,13 @@ 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. diff --git a/cloudflare/dev/seed.ts b/cloudflare/dev/seed.ts index 26f9e7a6..3b437bb0 100644 --- a/cloudflare/dev/seed.ts +++ b/cloudflare/dev/seed.ts @@ -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[] = [ @@ -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", @@ -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, @@ -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, ), diff --git a/cloudflare/migrations/0018_plan_request.sql b/cloudflare/migrations/0018_plan_request.sql new file mode 100644 index 00000000..e0704b11 --- /dev/null +++ b/cloudflare/migrations/0018_plan_request.sql @@ -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; diff --git a/cloudflare/src/inbox/model.ts b/cloudflare/src/inbox/model.ts index fb499c76..bbef5de4 100644 --- a/cloudflare/src/inbox/model.ts +++ b/cloudflare/src/inbox/model.ts @@ -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. */ @@ -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; +export type Entity = Pick; + +/** 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/`, 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) { @@ -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, @@ -219,6 +255,10 @@ export type Runners = Awaited>; /** 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(), Promise.all(views.map((view, i) => store.listInboxJobs(db, view.project.id, [...found[i]!.keys()]))), @@ -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; } @@ -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), ]; diff --git a/cloudflare/src/inbox/page.tsx b/cloudflare/src/inbox/page.tsx index 053517cc..bfc455c4 100644 --- a/cloudflare/src/inbox/page.tsx +++ b/cloudflare/src/inbox/page.tsx @@ -1,6 +1,6 @@ import { type Pull, continued, startable } from "../github/state"; import { type Prompt, parsePrompt } from "../jobs/prompt"; -import { type Done, PUBLICATION_REPLY, inboxURL } from "../jobs/routes"; +import { type Done, PLAN_LIMITS, PUBLICATION_REPLY, inboxURL } from "../jobs/routes"; import { Marker, Notifications } from "../layout"; import { projectURL } from "../projects"; import { type Card, PHASES, ago } from "./activity"; @@ -48,8 +48,10 @@ function needLabel(item: Item) { if (item.need === "account") return "Account limited"; if (item.need === "approve") return "Answer to approve"; if (item.need === "retry") return "Failed"; - if (item.need === "start") - return item.skipped && item.job ? `${kinds[item.kind]} · job ${item.job.state}` : kinds[item.kind]; + if (item.need === "start") { + if (item.skipped && item.job) return `${kinds[item.kind]} · job ${item.job.state}`; + return item.followups ? "Follow-ups to plan" : kinds[item.kind]; + } if (!item.job) return "Claimed draft"; if (item.job.state === "waiting") return "Awaiting approval"; return item.job.state === "queued" ? "Queued" : "Working"; @@ -71,7 +73,8 @@ function progress(item: Item, runners: Runners, user: string) { const runner = runners.find((r) => r.id === job.runner); if (job.lease !== null || !runner?.online) return "Runner offline. The job moves to another runner if it stays away for two minutes."; - return `Working on ${runner.name}.`; + const account = runner.accounts.find((a) => a.id === job.account); + return `Working on ${runner.name}${account ? ` with ${account.label}` : ""}.`; } if (item.need === "account") return "The job's account hit its limit. Choose an account to continue; kix never switches accounts on its own."; @@ -194,21 +197,24 @@ function AgentForm(props: { item: Item; prompt: Prompt }) { {ask.header && {ask.header}} {ask.question} - {ask.options.map((option, j) => ( - - ))} + {/* The recommendation comes first, but is never chosen for the maintainer. */} + {[...ask.options.filter((o) => o.recommended), ...ask.options.filter((o) => !o.recommended)].map( + (option, j) => ( + + ), + )} @@ -346,8 +352,14 @@ function work(item: Item, view: ProjectView) { }; if (item.kind === "issue") return { workflow: "plan", target: item.target, revision: commit, label: "Plan this issue" }; - // Only a failed interview is an item, so this retries it on the same target. - if (item.kind === "plan") return { workflow: "plan", target: item.target, revision: commit, label: "Try again" }; + // A plan item is a failed interview, retried on the same target, or a finished job's follow-ups. + if (item.kind === "plan") + return { + workflow: "plan", + target: item.target, + revision: commit, + label: item.followups ? "Plan these follow-ups" : "Try again", + }; if (item.kind === "devshell") return { workflow: "bootstrap", revision: commit, label: "Add a devShell" }; return { workflow: "start", target: item.target, revision: commit, label: "Start this quest" }; } @@ -447,6 +459,40 @@ function Thread(props: { item: Item; view: ProjectView | undefined; runners: Run

{item.job.summary}

)} + {item.job?.idea && ( +
+ What this plan starts from +

{item.job.idea}

+ {item.job.context && ( + <> +

Pasted context, which the agent reads as untrusted:

+

{item.job.context}

+ + )} +
+ )} + {item.job?.pull && ( +

+ {item.kind === "plan" ? "Its draft proposal: " : "Its pull request: "} + + #{item.job.pull} + +

+ )} + {item.followups && ( +
+

Suggested follow-ups

+
    + {item.followups.map((followup) => ( +
  • {followup}
  • + ))} +
+

+ An agent suggested these when its job finished. Planning them starts an interview, which reads + them as untrusted context. +

+
+ )} {item.need === "approve" && } {(item.need === "answer" || item.need === "account" || item.need === "approve") && @@ -499,7 +545,66 @@ function List(props: { items: Item[]; active: Item | null }) { ); } -function Project(props: { view: ProjectView }) { +/** + * A compact choice of where a new job runs: automatic by default, following + * the user's account preference order, or one of their runners' accounts. + */ +function Pin(props: { runners: Runners; id: string }) { + const choices = props.runners.flatMap((runner) => runner.accounts.map((account) => ({ runner, account }))); + if (!choices.length) return null; + return ( +
+ Runner and account: automatic unless you choose + + +
+ ); +} + +/** + * The new plan box: the maintainer's idea, plus an optional pasted block, + * such as an email or call notes, that the agent reads as untrusted context. + */ +function NewPlan(props: { project: ProjectView["project"]; commit: string; runners: Runners }) { + const { project } = props; + const base = projectURL(project.owner, project.name); + const id = `plan-${project.id}`; + return ( +