fix(web): gate public digest on maintainer approval; keep resolved community drafts resolved - #6748
Merged
Merged
Conversation
…d drafts resolved - runDigest stages the structured weekly record with approved:false and picks its fields explicitly; /digest renders only approved, well-formed records, so the "maintainer-approved" copy on the page and in llms.txt is accurate. Posting the digest from /admin approves it (unedited text only); discarding deletes the staged record. - Posting or discarding writes a draft-resolved marker (90 days). saveDraft and hasFreshDraft honor it and never overwrite a posted draft, so the triage / PR-review / stale / dupes / digest / content-watch runs no longer bring a resolved draft back as pending or spend a model call on it. - /api/admin/post rejects already-posted drafts (409), claims the draft before the GitHub call, returns ok with a warning when bookkeeping fails after GitHub accepted the post, reads the body with readBoundedBody, and answers malformed JSON with a JSON 400. - listDrafts follows the KV list cursor instead of stopping at 100 keys. Tests: web vitest 57 files / 515 passed; new lib/community-agent-review-state.test.ts 14 passed (13 fail on the unfixed source); tsc --noEmit exit 0; eslint on touched files exit 0. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ZwqatxgVFxHvovngywnks
Review of the first commit on this branch found gaps; this addresses them. - Posting claim: the claim now carries a random token that is read back before the GitHub call, so two concurrent posts of one draft that meet in the same KV location post once. KV has no compare-and-set and is eventually consistent across locations, so this is documented as best effort, not a lock. - GitHub 5xx, 408 and 429 answers keep the claim (the post may exist); only other 4xx answers release it for an immediate retry. - Resolution markers no longer suppress triage / PR-review / stale drafting for 90 days regardless of activity: an item updated more than 10 minutes after the maintainer's decision can be drafted again (our own post's updated_at bump stays covered). Digests, dupes and content-watch findings keep the 90-day suppression; their identity already encodes the content. - /digest publishes only the language the maintainer reviewed (approvedLang), revalidates the page after approval or discard, and a posted draft can no longer be discarded (409), which would have silently unpublished a digest. - The admin client shows the route's warning (state not saved, or digest not published because it was edited or its record is gone). - listDrafts reads at most 500 drafts in parallel batches of 50 and keeps what it read when a later list page fails. - runSemanticDrift and the triage / PR-review / stale runs count a draft only when saveDraft wrote it. Tests: web vitest 57 files / 526 passed; targeted community-agent-review-state, content-watch, community-agent-security, public-api-security 4 files / 45 passed; 14 of the new or changed tests fail on the previous source; tsc --noEmit exit 0; eslint on touched files exit 0; web npm run check ran through next build. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ZwqatxgVFxHvovngywnks
…equest claims Independent review of 1d83478 held two P1s. Approval binding: approveDigestRecord took only weekId/lang and approved whatever record sat at the key, so draft B + record A (B's record put failed after its draft put) posted B to GitHub and published A. The route also reloaded and posted current storage, so a draft regenerated after the admin page loaded was posted unseen. - The admin page sends reviewedSha256 (SHA-256 of the text shown in the selected language) with every post/discard; the route answers 409 before any claim or GitHub call when the stored draft no longer hashes to it. - approveDigestRecord(kv, draft, lang) publishes only a well-formed record of the same weekId and generatedAt that re-renders (renderDigestBody, now the single renderer shared with runDigest) to exactly the reviewed text, copying fields explicitly in one put. Otherwise it stays unpublished and the maintainer gets a mismatch/missing warning. Claim serialization: the token claim overwrote one shared marker key, so two handlers that both passed the absence check could each write and read back their own token and both post. Claims now live in per-request keys (draft-claim:<type>:<id>:<uuid>); a request proceeds only if a list shows its key alone and no decision marker exists, otherwise it deletes its key and answers 409. Overlapping claimants can both refuse, never both proceed. Discard takes the same claim, so a discard cannot overlap a post. The claim is released on a definite GitHub rejection or once the posted marker is written; on an unknown outcome it is kept until it expires. The code comment states the limit: this relies on a list seeing finished puts, which Workers KV guarantees within one location, not across locations. Tests: community-agent-review-state, content-watch, community-agent-security, public-api-security: 4 files, 51 passed. Against the previous source 6 of the new tests fail (draft B/record A publication, explicit-field publish, barrier race post/post, post/discard overlap, stale reviewed text, missing hash). tsc --noEmit exit 0; eslint on touched files exit 0. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ZwqatxgVFxHvovngywnks
Workers KV list results can lag writes by up to 60 seconds, even in the location that wrote them, so the list-based claim could 409 every post from a stale listing (and still let two stale listings both proceed). The claim is now one key holding the request's token: refuse if a claim is visible, write the token, proceed only if reading it back returns our token and no decision marker exists. A read-back that misses our own write answers 503 (retryable) instead of a lost race. Release deletes the key only while it still holds our token. The code comment no longer claims exclusivity: KV has no compare-and-set, so simultaneous or cross-location requests can still both proceed; a Durable Object would be needed for that. A repeated discard (marker already "discarded", seen by the route or by the claim) is now a 200 no-op instead of a 409. Tests: vitest community-agent-review-state 34 passed (4 new; all 4 fail against 989081c); community-agent + content-watch + public-api-security 5 files, 64 passed. tsc --noEmit exit 0. eslint on touched files exit 0. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ZwqatxgVFxHvovngywnks
Claims on a community-agent draft (post or discard) now go through the DraftClaimLock Durable Object when the DRAFT_CLAIM_LOCK binding is present: one SQLite-backed object per draft identity (idFromName), whose one-event- at-a-time input gate makes grant/refuse/release atomic. The lease expires after 15 minutes so a crashed or unknown-outcome post cannot wedge the draft, and after a recorded decision the object keeps refusing claims for 2 minutes while the KV decision marker propagates to other locations. The KV get/put claim stays as the fallback for next dev and tests. A second discard of the same draft (double click) now returns 200 whether the first one has finished or is still holding the draft. worker.ts exports the class (OpenNext custom-worker pattern); wrangler.jsonc adds the binding and a v1 new_sqlite_classes migration, applied on deploy. Tests: draft-claim-lock (6) + 7 new route tests with a serializing fake DO (concurrent posts -> one 200, stale-KV retry refused during the hold, lease kept after 502 and expired after 15 min, 422 frees at once, double discard -> 200/200, post/discard exclusion, unreachable lock fails closed). Against 7cf21ca, 6 of the 7 route tests fail (the 422 release test passes on both, a guard against over-locking). vitest community-agent*/content-watch/public-api-security/draft-claim-lock/ deploy-preflight: 7 files, 89 passed. tsc --noEmit exit 0. eslint on touched files exit 0. esbuild bundles worker.ts with the exported class. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ZwqatxgVFxHvovngywnks
The admin post route now requires the hash of the exact reviewed draft body, so this older test was rejected with 400 before reaching GitHub. It now sends the hash of the zh body it expects to be posted. Evidence: web vitest 58 files / 549 passed, 0 failed; tsc --noEmit and eslint clean. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ZwqatxgVFxHvovngywnks
The race helper let whichever request claimed first proceed and parked the second claim until a GitHub call. When the scheduler let the discard claim first, the post waited for a GitHub call that could never happen, and the test hung to its 5 s timeout (seen in CI on 9694514). A discard's claim now waits for the post to reach GitHub directly, so request order no longer matters. Negative control: with the two requests swapped, the old gate fails and the new one passes. Web vitest 58 files / 549 passed; eslint clean. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ZwqatxgVFxHvovngywnks
…raft "refuses a discard while a post holds the draft" fired both requests at once and expected the post to win. Under full-suite load the discard sometimes reached the lock first, which is a correct outcome but failed the assertion. The discard is now sent once the post has reached GitHub, matching the test's intent. Correction to 0eef7dd: its message reported 549/549, but one full-suite run had this test failing (1 failed / 548 passed); that was pushed before the failure was read. With this change the full web suite passed three consecutive runs: 58 files / 549 passed each; tsc and eslint clean. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ZwqatxgVFxHvovngywnks
pull Bot
pushed a commit
to TheTechOddBug/DeepSeek-TUI
that referenced
this pull request
Sep 29, 2026
The community agent and the curated-dispatch cron called deepseek-v4-flash. DeepSeek's pricing page (api-docs.deepseek.com/quick_start/pricing, read 2026-09-29) lists deepseek-flash as DeepSeek-V4.1-Flash, available on Chat Completions, and says the legacy deepseek-v4-flash name is still accepted but its model is retired and served by V4.1 Flash. Codewhale's own default (crates/config/src/provider_defaults.rs DEFAULT_DEEPSEEK_MODEL) is already deepseek-flash. Changes the wrangler DEEPSEEK_MODEL var, FALLBACK_MODEL in web/lib/community-agent.ts and web/lib/deepseek.ts, and the web README, AGENT.md, .env.example and content-watch comment. FALLBACK_BASE unchanged. The FAQ's Fin/OpenRouter mentions and generated facts describe the product, not the website agent, and are left alone. vitest community-agent*/content-watch/deepseek/deploy-preflight/ public-api-security: 5 files, 42 passed. tsc --noEmit exit 0. eslint on touched TS exit 0. check-docs PASS. No live DeepSeek call was made. Refs Hmbown#6748 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ZwqatxgVFxHvovngywnks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No-Issue: verified bug-hunt findings
Four community-agent findings, each re-checked on current
mainbefore fixing. The lane coversweb/lib/community-agent*.ts,web/app/api/admin/post/route.tsandweb/app/[locale]/digest/page.tsx.1. /digest showed unreviewed model output as "maintainer-approved"
Problem.
runDigestwrotedigest:weekly-<week>straight to KV, and/digestrendered every record under that prefix. The page copy ("Maintainer-approved summaries", "appears here only after the cron draft has been reviewed") and llms.txt ("reviewed by a maintainer") were therefore not true. The digest is built from public issue and PR titles.Resulting behavior. The cron now stages the record with
approved: false, and the model can't set it. The record fields are copied one by one, not spread from model output./digestrenders only records that are approved and well-formed (isPublishedDigest), which also hides legacy records that were never approved. When the maintainer posts the digest from /admin, the record is approved in the language they reviewed (approvedLang,published: truein the response); /digest shows only that language. If the maintainer edited the digest first, it still goes to GitHub, but the stored unedited model text is not published (published: false). Discard deletes the staged record. The page and llms.txt copy is now accurate, so I left it unchanged.2. Discarded or already-posted drafts came back as pending
Problem. Discard deleted the draft, so the next triage tick drafted it again and paid for another model call. After a post, GitHub bumps
updated_atpastgeneratedAt, so the draft was overwritten withposted: false.runDupeshad no existing-draft check.Resulting behavior. Post and discard write a
draft-resolved:<type>:<id>marker that lasts 90 days. For triage, PR-review and stale drafts, activity on the item more than 10 minutes after the decision reopens it (see follow-up below).saveDraftrefuses to write when that marker exists or the stored draft is already posted. This covers every generator, including the content-watch linkcheck and semantic-drift drafts.hasFreshDraftchecks the same state, so resolved items cost no model call.runDigestchecks the marker before its model call.3. The admin post action was not idempotent, and body parsing was unguarded
Problem. The route never checked
draft.posted, so a second tab could post the same comment again. A KV write failing after GitHub accepted the post returned a 500, and retrying posted a duplicate. A barereq.json()turned malformed JSON into an unhandled 500. The 64 KiB limit was enforced only throughContent-Length.Resulting behavior.
ok: truewith awarning.readBoundedBody, which counts real bytes. Invalid JSON gets a JSON 400.4. The admin queue stopped at the first 100 KV keys
Problem.
listDraftsmade a singlelist({ limit: 100 })call with no cursor, so newer drafts never appeared and the pending count was wrong.Resulting behavior. It now follows
cursor, reads at most 500 drafts in parallel batches of 50 (bounded KV operations per request), and keeps what it read if a later list page fails.Test evidence
web/lib/community-agent-review-state.test.ts: 14 passed. Against the unfixed source, 13 fail: 11 on behavior (for exampleexpected 200 to be 409,expected [ …(100) ] to have a length of 1500 but got 100,processed: 1vs0,SyntaxErroron malformed JSON) and 2 because the newisPublishedDigesthelper doesn't exist yet. The one that passes on both is the claim-release retry test, a guard against over-locking.web/lib/public-api-security.test.ts: I removed one source-text assertion that depended on the old inline 502 layout. The new file now covers that property (a digest post that GitHub rejects returns 502, with nookand no publication).tsc --noEmit -p .exit 0.eslinton the touched files exit 0.Review follow-up (commit 1d83478)
A review of the first commit raised nine points. Each was re-checked against the branch.
Fixed
Promise.allagainst a KV that yields on every call → statuses[200, 409], 1 GitHub post (2 before). KV has no compare-and-set and is eventually consistent across locations, so this is best effort, not a lock; the code comment says so. The earlier "safe to repeat" wording is withdrawn. (Superseded: see "Exclusive claims" below.)warning.admin-client.tsxnow shows the route's warning. The route also warns when an edited digest is posted but not shown on /digest, or when the weekly record is gone.resolutionCovers); our own comment'supdated_atbump stays covered. Test: post, then an update a day later →processed: 1, a new pending draft, which posts once.approvedLang;isPublishedDigestrequires it and /digest renders each digest only in that language. Tests for en and zh approval.revalidatePath("/[locale]/digest", "page"), as the feed retry route already does.runSemanticDriftand the triage / PR-review / stale runs now count a draft only whensaveDraftwrote it. Test: a discarded drift finding →drafted: 0(was 1).Kept as intended, with reasons
updated_at, and a changed drift or link finding already gets a new key. Discard means the maintainer chose "not this", so regenerating the same identity is the old behavior this PR removes.Follow-up test evidence
community-agent-review-state,content-watch,community-agent-security,public-api-security: 4 files, 45 passed. On the previous source, 14 of the new or changed tests fail: race, 5xx×4, lang×2, discard-after-post, edited-digest warning, reopen-on-activity, legacy approvedLang, listDrafts cap and partial, and drift count.tsc --noEmitexit 0.eslinton the touched files exit 0.npm --prefix web run checkran throughnext build.Second review follow-up (commit 989081c)
An independent review of 1d83478 held two P1s. Both fixed; this supersedes the "Concurrent posts" bullet above.
reviewedSha256(SHA-256 of the text it showed, in the selected language) with every post and discard. If the stored draft no longer hashes to it, the route answers 409 before any claim or GitHub call.approveDigestRecordnow takes the draft. It publishes only a record with the sameweekIdandgeneratedAtwhose re-render (renderDigestBody, now the one renderer shared withrunDigest) equals the reviewed text, and it copies fields explicitly in one put. The failure case the reviewer named (draft B saved, record B's put failed, record A still stored) now posts B to GitHub and leaves /digest unpublished, with a mismatch warning.draft-claim:<type>:<id>, holding the request's random token. A request refuses if a claim is already visible, writes its token, and proceeds only if reading the key back returns its token and no decision marker exists. It no longer uses KVlist, whose results can lag writes by up to 60 s. A read-back that misses the request's own write returns 503 (retryable) instead of a false 409. Release deletes the key only while it still holds the request's token. On KV alone this is best effort: KV has no compare-and-set, so two requests whose writes land at the same moment, or that run in different locations, can both proceed. (Superseded by the Durable Object in ccc0009; KV is now only the local/dev fallback.) What it does stop is the sequential cases this page produces: a second tab, a retry, or a discard during a post. A repeated discard is now a 200 no-op. Discard takes the same claim.Test evidence:
community-agent-review-state,content-watch,community-agent-security,public-api-security: 4 files, 51 passed. Six of the new tests fail against 1d83478: record mismatch, explicit-field publish, the barrier race (both absence checks pass, then the second claim lands while the first is at GitHub), post/discard overlap, stale reviewed text, and missing hash.tsc --noEmitexit 0.eslinton the touched files exit 0.Still open (nonblocking):
listDraftsstill stops at 500 reads and doesn't tell the admin page the list is partial.Exclusive claims (commit ccc0009)
Once deployed, claims are exclusive through a Durable Object. KV is now only the fallback for
next devand tests.DraftClaimLock(web/lib/draft-claim-lock.ts; the class lives inweb/worker.ts) is SQLite-backed, with one instance per draft identity viaidFromName("draft-claim:<type>:<id>"). A Durable Object handles one event at a time, and its input gate holds other events while a storage call is pending. That makes grant, refuse and release atomic, so exactly one request wins.worker.tsexportsDraftClaimLock, andwrangler.jsoncadds theDRAFT_CLAIM_LOCKbinding and av1migration withnew_sqlite_classes: ["DraftClaimLock"].CommunityAgentEnvhas the binding type.getAgentEnvdrops the binding undernext dev, because wrangler's platform proxy can't run a class defined in the Worker's own entry.Test evidence:
draft-claim-lock.test.tshas 6 tests: 8 concurrent claims give exactly 1 grant, a control shows the same logic without the gate double-grants, one lock per draft, lease expiry, release only by the owner, and the post-decision hold. The route suitecommunity-agent-review-statehas 7 new tests using a serializing fake DO:6 of the 7 route tests fail against 7cf21ca. The 422 test passes on both; it guards against locking too much.
viteston community-agent*, content-watch, public-api-security, draft-claim-lock and deploy-preflight: 7 files, 89 passed.tsc --noEmitexit 0.eslinton the touched files exit 0.esbuildbundlesworker.tswith the exported class. Not checked: a realwrangler devor deployed Durable Object. The fake models the input gate; it isn't workerd.Deploy steps (founder; nothing here was deployed)
web/, runnpm run deploy(opennextjs-cloudflare build && opennextjs-cloudflare deploy). The deploy applies migrationv1, which creates theDraftClaimLockSQLite namespace and binds it asDRAFT_CLAIM_LOCK. Nowranglerresource-create command or secret is needed; the account must allow SQLite-backed Durable Objects, which the Workers Free plan does.codewhale-web→ Settings → Bindings and confirmDRAFT_CLAIM_LOCK → DraftClaimLockis listed. Or post and discard a test draft from /admin and see the Durable Objects tab show one object per draft touched.DraftClaimLockwhile the binding exists; removing the class takes adeleted_classesmigration (which deletes its stored leases), not just deleting the export. I did not test awrangler rollbackacross this migration.🤖 Generated with Claude Code
https://claude.ai/code/session_014ZwqatxgVFxHvovngywnks
Evidence for 7cf21ca:
community-agent-review-state: 34 passed, including 4 new tests: nolistfor claims, own-write-not-visible → 503 then a successful retry, a visible foreign claim is refused and left alone, and a repeated discard is a no-op. All 4 fail against 989081c.community-agent*+content-watch+public-api-security: 5 files, 64 passed.tsc --noEmitexit 0.eslinton the touched files exit 0. The in-memory KV fake is strongly consistent, so these tests can't show cross-location behavior.