Skip to content

feat: in-app config editor (CodeMirror 6 workbench with live impact preview)#33

Merged
StephenSook merged 21 commits into
mainfrom
feat/config-editor
Jun 4, 2026
Merged

feat: in-app config editor (CodeMirror 6 workbench with live impact preview)#33
StephenSook merged 21 commits into
mainfrom
feat/config-editor

Conversation

@StephenSook

Copy link
Copy Markdown
Owner

Summary

Adds an in-app config editor (the ContextMod Observatory workbench) so moderators load, edit, validate, and save their rule config without leaving the dashboard. Replaces the prior "click out to the raw wiki" workflow that FoxxMD flagged as the top friction point in his 2026-05-27 feedback.

What this gives a moderator

Click Edit config in the dashboard. The webview expands. A CodeMirror 6 editor loads the current wiki config with line numbers, YAML/JSON syntax coloring, schema-driven autocomplete + hover docs, and inline AJV validation. Right pane:

  • Impact tab runs the FULL config against a cached sample of the sub's real recent posts in dry-run; updates as you type. "Would fire on N/M recent items". Verified 9/9 live during playtest.
  • Explain tab calls OpenAI for a plain-English summary of the current config.
  • Diff tab shows added/removed lines vs the loaded config.

Save writes back to the wiki via reddit.updateWikiPage, validation-gated (an invalid config can never reach the wiki) and optimistic-locked (refuses with a 409 on a concurrent edit), with the revision reason "Edited via ContextMod Observatory by u/...".

Architecture

  • Server (Hono): new src/routes/configEditor.ts exposes GET /api/config/raw, POST /api/config/validate, POST /api/config/simulate-live, POST /api/config/explain, POST /api/config/save. All mod-auth gated, rate-limited where cost-bearing, structured 503 on transient failures.
  • Dry-run engine: new simulateFullConfig in src/core/simulateRule.ts runs the whole config per sample with bypassIdempotency: true and forced dryRun: true. Zero real actions can fire (proven structurally and locked by a regression test).
  • Client: lazy-loaded ConfigWorkbench (CodeMirror 6 + codemirror-json-schema) so the dashboard main entry stays at 118K with zero CodeMirror in it; the workbench chunk loads only when the editor opens. requestExpandedMode provides the surface.
  • Schema: about 120 description fields added to app.schema.json so the editor's hover docs are real, sourced from the JSDoc on the TypeScript rule/action/filter interfaces.

Verification chain

  • Unit + component tests: 896 passing across 89 files. Type-check and lint clean throughout.
  • Per-task two-stage review (spec compliance, then code quality) on every task. Two opus adversarial safety passes:
    • The privileged wiki write: traced the validation gate and the optimistic lock as airtight.
    • The simulate dry-run: confirmed structurally that zero real Reddit actions can fire (early return in runAction before the action switch, when bypassIdempotency: true). Locked with a regression test that spies on all 10 Reddit mutation methods (remove, approve, submitComment, lock, report, banUser, setUserFlair, distinguish) and asserts none are called while firedCount >= 1.
  • Final whole-feature coherence review (opus): GO. Caught one important real gap (the headline Impact tab erroring on full configs because /simulate-live reused the single-rule simulateRule); fixed by routing the full config through the dry-run engine and locked with the safety test above.
  • Playwright E2E in real chromium: 4/4 pass (opens workbench, CodeMirror visible, three preview tabs present, Impact fire-rate renders, Close hides).
  • Live Devvit playtest on r/cm_devvit_test:
    • CodeMirror renders under the real webview CSP. Zero Content-Security-Policy, unsafe-eval, or style-src violations from cm-devvit in the console.
    • Save writes to the wiki successfully: rev incremented from 5 to 7 to 8 to 9 across saves.
    • Live Impact preview reported "Would fire on 8/9" then "9/9" against the sub's real recent posts.
    • requestExpandedMode opens the editor in expanded mode.

Follow-ups (out of scope for this PR)

  1. Download is disallowed ... frame ... sandboxed in the playtest console: the Devvit webview iframe lacks the allow-downloads sandbox flag, so the pre-existing Export CSV button's programmatic download is blocked. Either request allow-downloads from Devvit, or replace Export CSV with a copy-to-clipboard fallback. Not introduced by this PR.
  2. OpenAI quota. The Explain tab will surface "OpenAI HTTP 429: quota exceeded" until billing is topped up on the configured key. The endpoint correctly surfaces this as an inline error; not a code bug.

Commits

19 atomic commits on this branch (git log --oneline 9700f4d..HEAD), grouped by plan phase:

  • Phase 1 (server): resolveOpenaiKey extract, getRecentSample cached extract, raw, validate, simulate-live, explain, save, error-branch hardening.
  • Phase 2 (client): CodeMirror deps, API helpers, ConfigEditor (CM6 + schema), ConfigWorkbench + lazy-load + expanded-mode wiring.
  • Phase 3 (schema): description fields for editor hover docs.
  • Phase 4 (preview): PreviewPane (Impact / Explain / Diff), workbench split layout, ARIA + comment polish.
  • Phase 5 (verification + fixes): demo-mode mock-server handlers, Playwright E2E, the simulate-live full-config fix, the dry-run safety regression test.

Design spec: docs/superpowers/specs/2026-05-27-config-editor-design.md.
Implementation plan: docs/superpowers/plans/2026-05-27-config-editor.md.

🤖 Generated with Claude Code

StephenSook and others added 21 commits May 27, 2026 17:27
…save-back)

Design spec for the in-app config editor (Observatory workbench), answering FoxxMD's feedback that raw-wiki editing is too much friction for mods.

v1: CodeMirror 6 editor in Devvit expanded mode, YAML-first (JSON5 too), schema autocomplete + hover + inline AJV validation, live rule-impact simulation, AI explainer, config diff, and save-back to the wiki via updateWikiPage. Monaco ruled out (Devvit webview CSP blocks runtime code evaluation and external client fetch).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… TDD)

Bite-sized TDD plan with real code for all 17 tasks across 5 phases: server endpoints (raw/validate/simulate-live/explain/save with validation gate + optimistic lock), CodeMirror 6 editor + schema hints, live Impact + AI Explain + Diff preview, and Devvit build-phase verifications. Reuses simulateRule, explainRule, parseConfig+AJV, configStore, ConfigDiffViewer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Moves the private resolveOpenaiKey helper from forms.ts into
src/lib/resolveOpenaiKey.ts so the upcoming config-editor explain
endpoint can share the same Redis-first, settings-fallback key
resolution logic without duplication.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Moves the fetchRecentPostsSafe + normalize loop out of the
/simulate-rule-submit handler into src/core/recentSample.ts.
Caches the result in Redis for 60 s so the future live-impact
editor endpoint can reuse it without re-fetching Reddit on
every debounced keystroke. Per-post normalize errors are logged
and skipped (non-fatal). Cache read/write failures are also
non-fatal so a Redis blip never breaks the simulation path.

4 unit tests cover the cache-hit, cache-miss, empty-listing,
and cache-write-fail branches. All 96 route tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds config-editor Hono sub-app mounted at /api/config. The single
/raw endpoint is mod-auth gated; it returns the wiki page content +
revisionId on success, or the DEFAULT_CONFIG_YAML starter template
(isDefaultTemplate:true) when the wiki page does not exist yet.
DEFAULT_CONFIG_YAML added to default-config.ts as a hand-written YAML
block semantically equivalent to DEFAULT_CONFIG_JSON5 and verifiably
parseable via parseConfig. 3 new tests; 99/99 suite green; tsc + lint
clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ample)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ndpoints

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements Task 7. Validation gate (parseConfig) runs before any wiki
write so an invalid config can never reach the live moderation wiki.
Optimistic lock re-reads the current wiki revisionId before writing and
returns 409 if the page moved since the editor loaded, preventing silent
concurrent-edit clobbers. On success: updateWikiPage, publish to Redis,
best-effort stamp cfgLastWikiRev, logModActivity(edit-config). Added
'edit-config' to ModActivityKind union and the isValidModActivity allow-
list. Test mock extended: updateWikiPage now a named vi.fn(); new vi.mock
blocks for configStore and modActivity. 3 new tests (400/409/200) green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…n/save)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements the ConfigEditor React component wired to app.schema.json for
YAML/JSON autocompletion, lint markers, and hover docs via codemirror-json-schema.
Adds server.deps.inline for codemirror-json-schema in vitest.config.ts to handle
the package's extensionless ESM sub-imports that Node resolution cannot resolve.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ded mode)

Task 11 integration capstone. Creates the ConfigWorkbench full-screen
overlay (full-width CodeMirror, no PreviewPane yet), wires the Edit
config button into ActionBar with best-effort requestExpandedMode, lazy-
loads the workbench in App.tsx so CodeMirror stays out of the main
entry chunk, and adds Escape key support. baseRev is refreshed from the
wiki after every successful save to prevent false-conflict 409 on
subsequent saves in the same session. requestExpandedMode is called via
a dynamic import with an unknown cast because @devvit/client exports map
has no types condition, making a static import a tsc error.

Build chunks: ConfigWorkbench.js 810K (CM6 isolated), client.js 115K
(main entry, pre-CM6 size preserved). 882 tests green, tsc clean, lint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds PreviewPane component with three tabs:
- Impact: debounced (700ms) simulateLiveSafe showing fire-rate vs recent items
- Explain: on-demand explainConfigSafe with AI explanation
- Diff: reuses exported simpleDiff from ConfigDiffViewer to show line-level
  changes between the loaded baseline and current edited text

simpleDiff was already exported from ConfigDiffViewer.tsx (line 50). No
duplication of LCS logic needed. "No changes." shows when no add/del
entries exist (same-only diff = no unsaved edits).

Tests (preview-pane.test.tsx): 7 tests covering tab rendering, aria-selected
state, Explain button flow, loading placeholder, Diff changed-line detection,
Diff no-changes state, Impact error path. All 198 client tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…k 14)

- Adds loadedText state: set in load() on initial fetch and after
  save+reload, NOT on user edits. This gives PreviewPane's Diff tab a
  stable baseline (currentText) that resets to '' post-save, so the Diff
  is empty when the editor reflects the just-saved content.
- Editor now fills flex-1 on the left; PreviewPane takes flex-[0_0_40%]
  on the right, with text={text} currentText={loadedText} props.
- config-workbench.test.tsx mock extended with simulateLiveSafe and
  explainConfigSafe stubs so the test suite continues to pass with the
  PreviewPane rendered inside the workbench.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…IA tabs

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Part A: add five demo-handler stubs to scripts/dev/mock-server.cjs for
all config editor API paths (raw GET + validate/simulate-live/explain/save
POSTs). Enables the editor workbench to open in ?demo=1 without a live
Devvit install, unlocking E2E and screenshotting in CI.

Part B: add tests/e2e/config-editor.spec.ts: light happy-path Playwright
E2E covering Edit-config button, .cm-editor visible, three preview tabs
present, and Impact tab fire-rate text rendered from the mock server.

Part C: conflict-reload test was already present in
tests/client/config-workbench.test.tsx (line 72-79, added in Task 11).
No changes required there; 6/6 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…the Impact preview

Previously /simulate-live delegated to simulateRule which wrapped the editor
text as a bare rule (rules: [${text}]), causing a parse error when the editor
contained a full runs: [...] config. Adds simulateFullConfig to simulateRule.ts
that mirrors dryRunActivity but accepts a caller-supplied AppConfig instead of
reading from configStore, using bypassIdempotency=true and dryRun=true so zero
real Reddit side-effects or idempotency Redis writes occur. The route now parses
the config text first and returns {ok:false, error:'config invalid...'} (200) for
mid-edit invalid text, then calls simulateFullConfig against the cached sample.
firedCount = samples where at least one run triggered at least one action. Tests
updated to mock simulateFullConfig instead of simulateRule and assert the invalid-
config short-circuit path does not invoke simulateFullConfig.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ire)

Adds a 4-case regression test that proves: when simulateFullConfig triggers
a rule (firedCount >= 1), all 10 Reddit mutation spies (remove, approve,
submitComment, report, banUser, setUserFlair, post.lock, post.distinguish,
comment.lock, comment.distinguish) remain uncalled. The config under test
has dryRun absent to verify the forcing inside simulateFullConfig is
responsible, not a config-level flag. Guards against future refactors that
accidentally remove the bypassIdempotency early-return in runAction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@StephenSook StephenSook merged commit 3531c65 into main Jun 4, 2026
12 checks passed
@StephenSook StephenSook deleted the feat/config-editor branch June 9, 2026 20:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant