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
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,45 @@ arguments, 128 plus the signal number when interrupted, and 1 otherwise. `stop`
signals the agent and waits until its `start` or `resume` has reported.
`quest agent` does not run on native Windows yet; use WSL.

## Run kix jobs on your machines

`quest runner` turns a Linux or macOS machine with Nix into a
[kix.quest](https://kix.quest) runner, driving Claude Code with that machine's
own logins:

```sh
quest runner login --name laptop # prints a link to approve while signed in to kix
quest runner account add # Claude Code's own sign-in, in an isolated home
quest runner account add --console --label work
quest runner account list
quest runner --slots 2 # runs jobs in the foreground until interrupted
```

`login` makes a token that never leaves the machine; the link carries only its
hash, and kix connects the runner once you approve it. Each account signs in
through `claude auth login` in its own `CLAUDE_CONFIG_DIR`, so kix stores only
an opaque account id, its kind, and a label. kix.quest/runners shows each
runner's presence and sets the order jobs use your accounts in.

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
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
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,
agents run with `--permission-mode bypassPermissions`, and a job resumes with
another maintainer's answer only once you approve it in the inbox.

The flake's `runner` package is a `quest` whose Claude Code is pinned by this
repository's lock (`nix run github:kixelated/quest#runner -- runner`);
otherwise `claude` comes from `QUEST_RUNNER_CLAUDE` or `PATH`. State lives in `$QUEST_RUNNER_HOME`, by default
`$XDG_DATA_HOME/quest/runner`. To keep a runner up, run `quest runner` as a user
service, such as a systemd user unit with `Restart=on-failure`.

## Prunable local checkouts

The experimental `quest worktree` commands share one disk budget across this
Expand Down
24 changes: 13 additions & 11 deletions cloudflare/GITHUB.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,17 +92,19 @@ and moves one branch. Before writing anything it checks, in order:
identity, visibility, installation, and enrollment are checked live, as for
every other operation.
- The branch is where the job left it: absent before the first publication,
then the last commit kix published. A `head` that differs gets a reply naming
the commit to continue from. A branch changed outside the job, or one that
earlier work left on the fork, becomes a decision; publication never
overwrites it. On a service fork branch, the decision also offers to start
over: at the maintainer's request (`restart: true` in place of an `answer`,
after a live authority check), kix replaces the job with a new one at the
job's revision, with a new agent session and no recorded pull request, and
then force-resets the branch there. Replacing the job first means a
concurrent answer or cancel refuses the restart before any commit is
discarded. If the reset fails, the new job waits on the same Start over
decision. An iterated pull request's head is never reset.
then the last commit kix published. A `head` that differs gets a
`409 { error, head }` naming the commit to continue from, so a runner whose
previous reply was lost continues from what landed instead of failing. A
branch changed outside the job, or one that earlier work left on the fork,
becomes a decision; publication never overwrites it. On a service fork
branch, the decision also offers to start over: at the maintainer's request
(`restart: true` in place of an `answer`, after a live authority check), kix
replaces the job with a new one at the job's revision, with a new agent
session and no recorded pull request, and then force-resets the branch there.
Replacing the job first means a concurrent answer or cancel refuses the
restart before any commit is discarded. If the reset fails, the new job waits
on the same Start over decision. An iterated pull request's head is never
reset.

`plan`, `start`, and `bootstrap` jobs write their branch on the service fork
`GITHUB_FORK_OWNER/<repository>`, whose immediate parent must be the upstream's
Expand Down
28 changes: 23 additions & 5 deletions cloudflare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,16 @@ claimed; resuming it dispatches the jobs that waited. Disconnecting a project
deletes its jobs and their output and tells their runners to stop. Duplicate
requests return the active job for the same target.

Runners are a user's own machines. Each one registers at `POST /api/runners`
and gets a bearer token, which kix stores only as a hash. With that token a
runner opens a WebSocket at `/api/runner/connect`. The connection goes to its
Runners are a user's own machines. `quest runner login` makes a bearer token
on the machine and prints a link to `/runners?name=…&platform=…&key=…`, where
`key` is the token's SHA-256; the signed-in user approves it there
(`POST /runners/connect`), and the runner polls `GET /api/runner` with its token
until it is accepted. A script can instead register at `POST /api/runners` and
get a token back. Either way kix stores only the hash. With that token a
runner opens a WebSocket at `/api/runner/connect`. `/runners`, linked from the
decision inbox, lists the user's runners with their presence and accounts, and
moves an account earlier or later in the preference order
(`POST /runners/accounts`). The connection goes to its
owner's `RunnerHub` (`src/jobs/hub.ts`), the one place that owner's jobs are
routed, and the open connection is the runner's presence.

Expand All @@ -120,7 +127,13 @@ A status that omits an acknowledged job gives it up at once.

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.
runner that holds it. Until the job sandbox ships, that holds only for the
requester's own answers: the agent acts on an answer with the requester's
machine and credentials, so another maintainer's answer waits, with an
`approval` notification, until the requester approves it
(`POST …/jobs/<job>/approve`) or answers the question themselves. The
assignment carries each answer's `approvedAt`, and the runner refuses to
resume with one that has none.

A `question` carries one round of independent questions
(`src/jobs/prompt.ts`): `{ kind, questions: [{ header?, question, options:
Expand All @@ -139,7 +152,10 @@ one branch, which a reassigned attempt or a retried failed job continues from,
and opens or updates the job's pull request. Each job records its branch's
`head` and its `pull`.
Changes a workflow may not publish pause the job with a `publication`
decision. [GITHUB.md](GITHUB.md#publication) describes the request, its
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
validation, and how interrupted publications are reconciled.

### Raw output
Expand Down Expand Up @@ -193,6 +209,8 @@ preselect the recommendation. 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.
Another maintainer's answer shows the job's requester the answer with an
approve button above the usual form, and shows everyone else the job waiting.
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
Expand Down
6 changes: 6 additions & 0 deletions cloudflare/migrations/0017_answer_approval.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- Until the job sandbox ships, a job resumes on its requester's runner with
-- another maintainer's answer only once the requester approves it. The
-- requester's own answers are approved as they are given.
ALTER TABLE job_questions ADD COLUMN approvedAt INTEGER;
-- Answers given before the gate already resumed their jobs.
UPDATE job_questions SET approvedAt=answeredAt WHERE answer IS NOT NULL;
116 changes: 116 additions & 0 deletions cloudflare/src/github/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -860,6 +860,122 @@ export class GitHubService {
),
}));
}
/**
* What a job's agent reads about its issue or pull request, which its
* checkout does not hold: an issue's text and comments, or a pull request's
* text, comments, reviews, unresolved review threads, and checks at its head.
* Everything in it is untrusted GitHub content.
*/
readTarget(repo: GitHubRepository, userId: string, target: string) {
const [, kind, value] = target.match(/^(issues|pulls)\/(\d{1,15})$/) ?? [];
if (!kind) throw new GitHubError("Only issue and pull request jobs have a target to read");
const permissions: Permissions =
kind === "issues" ? { issues: "read" } : { pull_requests: "read", checks: "read", statuses: "read" };
const comments = "comments(last: 100) { nodes { author { login } body createdAt } }";
const query =
kind === "issues"
? `issue(number: $number) { number title state body author { login } ${comments} }`
: `pullRequest(number: $number) { number title state body isDraft author { login } baseRefName headRefName headRefOid ${comments}
reviews(last: 50) { nodes { author { login } state body submittedAt } }
reviewThreads(first: 100) { nodes { isResolved isOutdated path line comments(first: 50) { nodes { author { login } body createdAt } } } }

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 Paginate unresolved review threads in runner context

For a pull request with more than 100 review threads, this fetches only the first page and filters unresolved threads afterward, so a newer unresolved thread beyond that page is silently omitted. Because context is the iteration agent's sole source of review feedback, it can deliver without addressing those comments; follow pageInfo cursors until all threads have been examined.

Useful? React with 👍 / 👎.

commits(last: 1) { nodes { commit { oid statusCheckRollup { state contexts(first: 100) { nodes { __typename ... on CheckRun { name status conclusion detailsUrl } ... on StatusContext { context state description targetUrl } } } } } } } }`;
type Comment = { author: { login: string } | null; body: string; createdAt?: string; submittedAt?: string };
type Thread = {
isResolved: boolean;
isOutdated: boolean;
path: string;
line: number | null;
comments: { nodes: Comment[] };
};
type Check = {
__typename: string;
name?: string;
status?: string;
conclusion?: string | null;
detailsUrl?: string | null;
context?: string;
state?: string;
description?: string | null;
targetUrl?: string | null;
};
type Item = {
number: number;
title: string;
state: string;
body: string;
isDraft?: boolean;
author: { login: string } | null;
baseRefName?: string;
headRefName?: string;
headRefOid?: string;
comments: { nodes: Comment[] };
reviews?: { nodes: (Comment & { state: string })[] };
reviewThreads?: { nodes: Thread[] };
commits?: {
nodes: {
commit: { oid: string; statusCheckRollup: { state: string; contexts: { nodes: Check[] } } | null };
}[];
};
};
return this.#authorized(repo, userId, permissions, async (token) => {
const result = await this.#api<{
data?: { repository: { issue?: Item | null; pullRequest?: Item | null } | null };
errors?: unknown[];
}>(token, "/graphql", "POST", {
query: `query($owner: String!, $name: String!, $number: Int!) { repository(owner: $owner, name: $name) { ${query} } }`,
variables: { owner: repo.owner, name: repo.name, number: number(Number(value)) },
});
const item = result.data?.repository?.[kind === "issues" ? "issue" : "pullRequest"];
if (result.errors?.length || !item) throw new GitHubError("This issue or pull request is unavailable");
const comment = (c: Comment) => ({
author: c.author?.login ?? null,
body: c.body,
at: c.createdAt ?? c.submittedAt ?? null,
});
const common = {
number: item.number,
title: item.title,
state: item.state,
author: item.author?.login ?? null,
body: item.body,
comments: item.comments.nodes.map(comment),
};
if (kind === "issues") return { kind: "issue" as const, ...common };
const head = item.commits?.nodes.at(-1)?.commit;
return {
kind: "pull" as const,
...common,
draft: item.isDraft ?? false,
base: item.baseRefName ?? null,
head: { ref: item.headRefName ?? null, oid: item.headRefOid ?? null },
reviews: (item.reviews?.nodes ?? []).map((r) => ({ ...comment(r), state: r.state })),
// Resolved threads need nothing more; outdated ones may still need an answer.
threads: (item.reviewThreads?.nodes ?? [])
.filter((t) => !t.isResolved)
.map((t) => ({
path: t.path,
line: t.line,
outdated: t.isOutdated,
comments: t.comments.nodes.map(comment),
})),
checks: {
commit: head?.oid ?? null,
state: head?.statusCheckRollup?.state ?? null,
runs: (head?.statusCheckRollup?.contexts.nodes ?? []).map((c) =>
c.__typename === "CheckRun"
? { name: c.name!, state: c.conclusion ?? c.status ?? null, url: c.detailsUrl ?? null }
: {
name: c.context!,
state: c.state ?? null,
url: c.targetUrl ?? null,
description: c.description ?? null,
},
),
},
};
});
}

comment(repo: GitHubRepository, userId: string, issue: number, approvedBody: string) {
if (!approvedBody.trim()) throw new GitHubError("Approved comment required");
return this.#authorized(repo, userId, { issues: "write" }, (token) =>
Expand Down
33 changes: 21 additions & 12 deletions cloudflare/src/inbox/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,11 @@ export type Read = { repository: number; item: string; seen: string | null; skip

/**
* 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.
* a limited job, approve another maintainer's answer to their own 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 Need = "answer" | "account" | "approve" | "retry" | "start";
export type Kind = "issue" | "pull" | "quest" | "plan" | "devshell";

export type Item = {
Expand All @@ -35,7 +36,7 @@ export type Item = {
activity: Activity | null;
/** The target's latest job. */
job: JobView | null;
/** The job's open question, and its prompt when an agent asked it. */
/** The job's open question, or its answer awaiting approval, and its prompt when an agent asked it. */
question: QuestionView | null;
prompt: Prompt | null;
need: Need | null;
Expand Down Expand Up @@ -127,10 +128,16 @@ function fallback(project: Enrollment, target: string): Entity {
}

/**
* Build one project's items from its observed `found` entities, its latest
* jobs, and the maintainer's read state.
* Build one project's items for `user` 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[] {
export function decisions(
view: ProjectView,
found: Map<string, Entity>,
jobs: JobView[],
reads: Read[],
user: string,
): Item[] {
const { project } = view;
const latest = new Map<string, JobView>();
// Jobs arrive newest first.
Expand All @@ -147,9 +154,11 @@ export function decisions(view: ProjectView, found: Map<string, Entity>, jobs: J
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}`;
question = job.questions.find((q) => q.attempt === job.attempt && q.approvedAt === null) ?? null;
// Another maintainer's answer waits on the requester alone; others see it as in progress.
if (question?.answer != null) need = job.actor === user ? "approve" : null;
else if (question) need = question.kind === "account" ? "account" : "answer";
version = `question:${question?.id}${question?.answer != null ? ":answered" : ""}`;
} else if (job?.state === "failed" && !changed) {
need = "retry";
version = `job:${job.id}`;
Expand Down Expand Up @@ -188,7 +197,7 @@ export function decisions(view: ProjectView, found: Map<string, Entity>, jobs: J
// 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"
item.need === "answer" || item.need === "account" || item.need === "approve"
? 0
: item.need === "retry"
? 1
Expand All @@ -213,7 +222,7 @@ async function collect(db: D1Database, user: string, views: ProjectView[]) {
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()]))),
]);
return views.flatMap((view, i) => decisions(view, found[i]!, jobs[i]!, reads.results));
return views.flatMap((view, i) => decisions(view, found[i]!, jobs[i]!, reads.results, user));
}

/**
Expand Down
Loading
Loading