Repository navigation
feat(worker): make the decision inbox the signed-in home - #144
Conversation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add /inbox: one active decision thread across a maintainer's enrolled projects, coalesced per job target, with agent questions, account-limit choices, failed jobs, new pull requests, new issues, and ready quests. Define the agent prompt contract (choices, recommendation, free text, permission vs decision) and validate prompts and answers. Job routes accept inbox form posts, bind decisions to the revision on screen, and refuse stale or repeated answers. Per-maintainer read state marks changed items unread and keeps skips until an item changes. Sign-in now lands on the inbox. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Outcome of the quest-start run: the decision inbox is implemented and (Written by Claude Opus 5.5) |
The PR meta line reused .entry-meta, whose grid-column: 2 split the thread into two columns once there was room. Also show GitHub's check and review enums as plain words. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Drop the deleted prompts quest from the follow-up quests #146 added, and remove their now-empty Required sections. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
#147 merged into main and adds (Written by Claude Opus 5.5) |
Brings in raw output retention (#142) and the harness and answer-flow plans (#147). The inbox migration becomes 0009_inbox.sql after 0008_output.sql. The shared maintainerOf() fixture adopts main's stricter GitHub mock, where only the signed-in user holds the maintain role. harness.md and answer-flow.md drop this deleted quest from Required, and activity.md drops both deleted quests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in bot publication (#145). The inbox migration becomes 0010_inbox.sql after 0009_publication.sql. merge.md and planning.md drop the deleted publication and inbox quests from Required, and merge.md's emptied section goes away. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
#150 merged (Written by Claude Opus 5.5) |
#150 added paper-trail.md requiring /quest/m0/kix/prompts.md, which this PR deletes. That was its only blocker, so the Required section goes away. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@codex review The earlier code review failed and posted nothing. Re-triggering at the maintainer's request. (Written by Claude Opus 5.5) |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f778d7d319
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| {item.need === "answer" && | ||
| (item.prompt ? ( | ||
| <AgentForm item={item} prompt={item.prompt} /> | ||
| ) : ( | ||
| <p role="alert"> | ||
| The agent's question could not be read. Stop the job or wait for the runner to ask again. | ||
| </p> |
There was a problem hiding this comment.
Render publication questions before treating them as answers
When publication pauses a job because files are refused or the branch moved, the open question has kind === "publication". This path classifies it as need === "answer", but only agent questions populate item.prompt, so the page falls through to “could not be read” and offers no way to submit the publication decision. The job therefore remains stuck unless the maintainer manually uses the JSON API; render a publication-specific form or avoid presenting it as an answerable inbox item.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Agreed. Fixed in 57d059c: a publication decision now renders the refused paths (escaped) or the blocked reason with a text reply. The answer route accepts that text from the form and resumes the session. Regression test: "asks how to continue when publishing needs a decision".
(Written by Claude Opus 5.5)
| const reason = item.need === "start" || item.need === "retry" ? blocked(item, view) : null; | ||
| const job = item.need === "start" || item.need === "retry" ? work(item, view!) : null; |
There was a problem hiding this comment.
Defer constructing retry actions until the item is actionable
When a failed pull-request job remains after its PR is closed or otherwise disappears from observed activity, blocked() correctly returns a refresh reason, but work(item, view!) is still evaluated first. For a pull fallback, work() dereferences item.activity!.data, which is null, so loading /inbox throws instead of showing the refresh action. Only call work() when reason is null.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Agreed. Fixed in 57d059c: the retry/start action is built only when blocked() returns no reason, so a failed PR job whose pull request is gone shows the refresh instead of crashing. Regression test: "offers a refresh for a failed job whose pull request is no longer observed".
(Written by Claude Opus 5.5)
| 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) => store.listJobs(env.DB, view.project.id))), | ||
| ]); |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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)
🛡️ Codex Security Review · Automatically triggeredSecurity review completed. No security issues were found in this pull request. Reviewed commit: Only the user who started this review can view the report in Codex. ℹ️ About Codex security reviews in GitHubThis is an experimental Codex feature. Security reviews are triggered when:
Once complete, Codex will leave suggestions, or a comment if no findings are found. |
…inbox Codex review findings on #144: - A publication decision from bot publication (#145) fell through to "could not be read" with no way to answer. The inbox now lists the refused paths or why the branch is blocked and takes a text reply, which the answer route accepts from the form. - A failed PR job whose pull request is no longer observed crashed the inbox: the retry action dereferenced missing activity before the refresh reason was checked. The action is built only when unblocked. - The inbox read only the 50 newest jobs, so an older waiting job vanished. It now also reads every queued, running, and waiting job. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 57d059cc74
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| }; | ||
| if (item.kind === "issue") | ||
| return { workflow: "plan", target: item.target, revision: commit, label: "Plan this issue" }; | ||
| if (item.kind === "plan") return { workflow: "plan", revision: commit, label: "Start a new plan" }; |
There was a problem hiding this comment.
Preserve the target when retrying planning interviews
When a targetless planning interview (plans/<uuid>) fails, this retry description omits target; resolve() therefore generates a new UUID and starts an unrelated planning job. The original failed item remains in the inbox, so clicking “Try again” duplicates the interview instead of retrying or clearing the failure.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Agreed. Fixed in 0d464ab: a failed interview's retry posts its own plans/<id> target, and the jobs route accepts a UUID-shaped plan target, so the new job replaces the failure instead of starting a second interview. Regression test: "retries a failed planning interview on its own target".
(Written by Claude Opus 5.5)
| if (item.kind !== "quest" && item.kind !== "plan" && item.kind !== "devshell" && !item.activity?.data) | ||
| return "Refresh to read this from GitHub."; |
There was a problem hiding this comment.
Do not offer retries for unavailable quests
When a failed quest job's target is no longer in state.available—for example, its branch was observed as claimed or the quest was removed—there is no entity for the target, but this check exempts every quest item and enables “Try again.” The submitted start request is then rejected by resolve() because the quest is not ready, so the inbox advertises an action that cannot succeed.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Agreed. Fixed in 0d464ab: blocked() now refuses a quest that is not in state.available and says why, so a failed quest job no longer offers a retry the route would reject. It can still be skipped. Regression test: "offers no retry for a failed quest that is no longer ready".
(Written by Claude Opus 5.5)
| export async function listInboxJobs(db: D1Database, repository: number) { | ||
| const rows = await db | ||
| .prepare( | ||
| "SELECT id FROM jobs WHERE repository=?1 AND (state IN ('queued','running','waiting') OR id IN (SELECT id FROM jobs WHERE repository=?1 ORDER BY createdAt DESC,id LIMIT 50)) ORDER BY createdAt DESC,id", |
There was a problem hiding this comment.
Include failed decisions beyond the history limit
The uncapped portion of this query includes active states but not failed, even though failed jobs are actionable inbox decisions. Once a repository has 50 newer jobs, an older unskipped failure is omitted; if its target still exists it is incorrectly presented as fresh work, and if not it disappears entirely, so the maintainer can no longer retry or skip that failure.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Agreed. Fixed in 0d464ab: listInboxJobs also includes any failed job that is still its target's latest. Superseded failures stay out, so they can't hide a newer job. The 50-job test now also covers an older failed issue job, which stays "Failed" with "Try again".
(Written by Claude Opus 5.5)
Codex review findings on #144, round 2: - Retrying a failed planning interview started an unrelated one under a new plans/<id>. The retry now keeps the target, which the jobs route accepts, so the new job replaces the failure. - A failed quest job offered "Try again" after the quest stopped being ready, which the route then refused. The inbox explains instead. - An older failure beyond the 50-job history showed as fresh work or vanished. The inbox also reads each target's latest failure. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0d464abf6c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| `SELECT id FROM jobs j WHERE repository=?1 AND ( | ||
| state IN ('queued','running','waiting') | ||
| OR (state='failed' AND NOT EXISTS(SELECT 1 FROM jobs n WHERE n.repository=?1 AND n.target=j.target AND (n.createdAt>j.createdAt OR (n.createdAt=j.createdAt AND n.id<j.id)))) | ||
| OR id IN (SELECT id FROM jobs WHERE repository=?1 ORDER BY createdAt DESC,id LIMIT 50) |
There was a problem hiding this comment.
Retain latest terminal jobs beyond the history window
When a target's latest job is completed or cancelled and 50 newer jobs exist on other targets, this query omits that terminal job. decisions() then sees only the still-open issue, PR, or ready quest and presents it as fresh work instead of setting it aside, allowing a maintainer to start duplicate work. Include every target's latest job regardless of state, while still limiting older history.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Agreed. Fixed in 07c269c: listInboxJobs now returns each target's latest job however old, when it awaits a decision or its target is one of the observed issues, PRs, or ready quests. A new index on (repository, target, createdAt) backs the lookup, and the 50-job window is gone. The regression test now also covers a finished PR job that sits behind 50 newer jobs and stays set aside.
(Written by Claude Opus 5.5)
|
|
||
| /** 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 `/projects/github/${project.owner}/${project.name}/board/${path.replace(/(\/README)?\.md$/, "")}`; |
There was a problem hiding this comment.
Encode quest paths in inbox links
For valid quest filenames containing URL-reserved characters such as # or ?, this raw interpolation turns part of the filename into a fragment or query, so clicking the inbox item's title requests the wrong board route. The existing board URL builder encodes each path segment; apply the same encoding here before constructing the link.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Agreed. Fixed in 07c269c: the inbox's questHref now uses the board's encodePath (now exported) and projectURL, so # and ? in a quest filename are encoded. Test: "encodes quest paths in board links".
(Written by Claude Opus 5.5)
Codex review findings on #144, round 3: - A finished job beyond the 50-job window let its still-open issue, PR, or quest show as fresh work. The inbox now reads each target's latest job however old: every one awaiting a decision, plus any other whose target is still observed. An index on (repository, target, createdAt) backs the lookup, replacing the 50-job window. - Quest links interpolated raw paths, so a `#` or `?` in a filename broke the board link. They now reuse the board's path encoding. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 07c269c7a2
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| question.id, | ||
| user, | ||
| // An agent's answer must fit its prompt; the store checks an account choice against the owner's accounts. | ||
| question.kind === "agent" ? parseAnswer(parsePrompt(question.prompt), answer) : data(answer, 1024), |
There was a problem hiding this comment.
Enforce the publication limit on raw text
For publication questions, answer is a string from a textarea that permits 1,000 characters, but data(answer, 1024) limits the length of JSON.stringify(answer). Escaped characters count twice, so a valid reply containing, for example, 600 backslashes—or 1,000 characters with 23 newlines—is rejected as “Payload too large” even though it satisfies the form limit. Validate the raw publication text against the advertised limit or allow enough serialized overhead.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Agreed. The route should check the raw text against the form's 1,000-character limit instead of the serialized length. This would be a fourth fix round, past the cap of three, so I'm leaving it for the maintainer to decide: one more small fix, or merge as is. For now such a reply gets a clear "Payload too large" refusal, and nothing is recorded wrongly.
(Written by Claude Opus 5.5)
There was a problem hiding this comment.
Fixed in 44f4c87, with the maintainer's approval for one more round. A publication reply is now checked as text against PUBLICATION_REPLY (1,000 characters). The form's maxlength uses the same constant. CRLF line breaks from forms are normalized first, so a line break counts as one character, as it does in the textarea. The regression test sends exactly 1,000 characters of quotes, backslashes, and CRLF line breaks, which pass, and 1,001, which are refused.
(Written by Claude Opus 5.5)
The inbox's reply box allows 1,000 characters, but the answer route checked the JSON-serialized length against 1024, so quotes, backslashes, and line breaks could get a valid reply refused as too large. The route now checks the text itself against the same 1,000-character limit the form uses, after normalizing the CRLF line breaks forms submit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 075f166126
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Merge summary from the /quest-merge run:
(Written by Claude Opus 5.5) |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6fe526c824
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // 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; |
There was a problem hiding this comment.
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 👍 / 👎.
| 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" }; | ||
| if (item.kind === "devshell") return { workflow: "bootstrap", revision: commit, label: "Add a devShell" }; |
There was a problem hiding this comment.
Hide bootstrap retries once a flake exists
If a bootstrap job fails and flake.nix is later added to the default branch, this still offers “Try again” because the devshell path has no availability check. Submitting that form is deterministically rejected by routes.ts at the if (flake) throw new JobError("The default branch already has a flake") check, and refreshing cannot clear the stale failed item; treat the bootstrap as resolved or suppress its retry when a flake is now present.
Useful? React with 👍 / 👎.
Implements
quest/m0/kix/prompts.md(Make interactive decisions the main UI) and deletes the quest.Problem
Signing in to kix.quest led to project setup screens. There was nowhere to answer an agent's question, notice a new issue or PR, or start ready work. The runner protocol also took an opaque
promptthat the UI could not render.Behavior
/inboxis the signed-in home. Sign-in now lands there by default, and the nav shows "Decisions" when you're signed in. The page shows one active decision thread first, then what's up next, what's in progress, your projects (refresh, start a planning interview, board, activity), and a collapsed list of skipped and finished items. It's laid out for a phone and uses plain forms with no JavaScript.issues/1,pulls/2, a quest path). An issue, its planning job, and that job's questions stay one item. Webhook updates, duplicate deliveries, and reconnects never add a second prompt.src/jobs/prompt.ts):{ kind: "decision" | "permission", questions: [{ header?, question, options: [{ label, description?, recommended? }], multiple? }] }.{ answers: [{ choices, text? }] }, one per question, checked against the prompt.revisionthe maintainer saw, such as a PR head, and is refused once observations have moved past it.bootstrap: true.inbox_items, migration0010): each maintainer's read state is keyed by a version of the item (a question id, a GitHubchangedAt, a hash of the quest text). A newer version shows as unread again. A skip lasts until the item changes, and a finished or stopped job sets its item aside the same way.Validation
just checkandjust testpass insidenix developafter merging main: 227 CLI tests and 249 Worker tests.test/inbox.test.tsruns end to end through the Worker. It covers:test/project-fixtures.ts, which also holds the signed webhook helper./inbox. There is no horizontal scroll at 375px, and long unbroken titles wrap..entry-meta(grid-column: 2), which split the decision thread into two columns on wide screens. GitHub's check and review enums now read as plain words ("changes requested").Review fixes
Four rounds of Codex code review findings (the fourth approved by the maintainer), each with a regression test in
test/inbox.test.ts:publicationdecisions from bot publication (feat(worker): publish agent work through the GitHub bot #145) render the refused paths or the blocked reason, and take a text reply from the form.jobs(repository, target, createdAt)in0010_inbox.sqlbacks the lookup.plans/<id>target.Settled decisions
The maintainer accepted every recommendation:
enrolledBy). Co-maintainer membership is a separate follow-up.quest readyorder.planandstartstay bound to the default-branch revision; a movedmainrefuses the stale action.{ answers: [{ choices, text? }] }with therecommendedoption flag. The runner adapter maps Claude Code'sAskUserQuestiononto it.Planning
Batched inbox reads is added to m1 in this PR.
Retry from the branch is added to m0 in this PR, for the last Codex finding.
Limitations and follow-ups
Follow-up quests are planned separately, not in this PR. #146 added webhook reconcile, inbox merge, and co-maintainer inbox. Merging main here drops this deleted quest from their
Requiredlists.0010_inbox.sql, following0008_output.sqland0009_publication.sql.🤖 Generated with Claude Code
(Written by Claude Opus 5.5)