Skip to content

fix(config): degrade gracefully on a broken config file in PawWork runtime (#1485) - #1500

Merged
Astro-Han merged 4 commits into
devfrom
fix/1485-config-load-resilience
Jul 9, 2026
Merged

fix(config): degrade gracefully on a broken config file in PawWork runtime (#1485)#1500
Astro-Han merged 4 commits into
devfrom
fix/1485-config-load-resilience

Conversation

@Astro-Han

@Astro-Han Astro-Han commented Jul 9, 2026

Copy link
Copy Markdown
Owner

Summary

In the PawWork runtime, a config file that fails to load (invalid JSONC or a schema violation) is now skipped per file — its error is recorded and every other valid config source still loads — instead of dying the whole config load. The recorded errors are exposed via a new read-only GET /config/errors route and surfaced once as a readable toast on bootstrap. The plain opencode CLI keeps its fail-fast contract.

Why

Reported in #1485: a non-technical user hand-edited pawwork.json to add an MCP server, got the schema wrong, and the desktop app spammed the same config error on every operation until it was unusable.

Root cause: ConfigParse raises parse errors with a plain throw, which inside an Effect generator becomes a defect, not a typed failure. That defect dies the instance config load, and because Config.get() runs on nearly every operation (and is polled), the same failure resurfaced on every call. A probe confirmed the existing global orElseSucceed never caught these (it only recovers typed failures), so the "graceful" global path was silently ineffective too.

Related Issue

Fixes the error-spam half of #1485. The in-app MCP management GUI (the other half) is a follow-up PR.

Human Review Status

Pending

Review Focus

  • The Runtime.isPawWork() gate in keepValidConfig (config.ts): desktop degrades, the opencode CLI still fails fast — the existing engine tests encode fail-fast as a contract, so degrading universally would break them and change CLI behavior. This diverges from an earlier suggestion to degrade globally; the divergence is deliberate because the global-path premise was disproven.
  • catchDefect (not catchAll) is required because the parse errors are defects; non-config defects are re-raised unchanged.
  • The bootstrap dedup key (directory + file + message) so a still-broken file does not re-toast on every reload.

Risk Notes

  • Generated files: packages/sdk/openapi.json and packages/sdk/js/src/v2/gen/* are regenerated from the new route via bun dev generate + the SDK build. Expected, in scope.
  • Visible UI (copy): adds one error toast + two i18n strings (en/zh). Verified by unit tests (formatter + dedup) and typecheck, not by a live dev:desktop screenshot walk — the toast reuses the existing toast component and copy is unit-covered. Flagging so a reviewer can request a screenshot if desired.
  • Platform: config-file loading is platform-agnostic; no packaging/updater/signing/path-resolution surface changed. Behavior is identical on macOS and Windows.

How To Verify

opencode config tests (test/config/): 232 passed — incl. PawWork keeps valid sibling config and records the error for both invalid JSON and an invalid mcp entry; CLI still fails fast
config route tests (test/server/config-routes.test.ts): 17 passed — incl. GET /config/errors returns recorded errors; /config/errors declared in the HttpApi spec
openapi source test (test/server/openapi-generation-source.test.ts): 7 passed
app server-errors (src/utils/server-errors.test.ts): 10 passed — incl. ConfigJsonError renders by file, dropping the noisy dump
app bootstrap (src/context/global-sync/bootstrap.test.ts): 23 passed — incl. surfaceConfigErrors emits once per distinct error and dedupes across bootstraps
i18n parity (src/i18n/parity.test.ts): 2 passed
typecheck: opencode 0 errors, app 0 errors
eslint (changed source files): clean

Screenshots or Recordings

Not captured — see Risk Notes (toast copy is unit-covered; no live screenshot walk done yet).

Checklist

  • Type label — this PR carries exactly one of bug, enhancement, task, documentation. Type labels are author-added; the labeler bot does NOT assign them. Add the label in the GitHub UI, then tick this.
  • Routing labels — this PR carries at least one of app, ui, platform, harness, ci. The labeler bot assigns these on PR open based on changed paths. Confirm the bot's choice (or override if wrong), then tick this.
  • Priority label — this PR carries exactly one of P0, P1, P2, P3. The priority-triage bot suggests one on PR open. Confirm or override, then tick this.
  • Human Review Status above is set to Pending, Approved by @<reviewer>, or Not required: <reason> (default is Pending; "not required" is restricted to bot-authored low-risk PRs).
  • I linked the related issue, or stated in Summary why there is no issue.
  • I described the review focus and any meaningful risks.
  • I replaced the example block in How To Verify with the real verification steps and the key result for each.
  • I did not introduce unrelated refactors, dependencies, generated files, or file changes beyond the stated scope.
  • (conditional) I manually checked visible UI or copy changes when needed, with screenshots or recordings. Leave unticked only if no visible UI or copy changed.
  • (conditional) I considered macOS and Windows impact for platform, packaging, updater, signing, paths, shell, or permissions changes. Leave unticked only if no platform/packaging surface was touched.
  • (conditional) I called out docs, release notes, dependencies, permissions, credentials, deletion behavior, generated content, or local file changes when relevant. Leave unticked only if none of those surfaces was touched.
  • I reviewed the final diff for unrelated changes and suspicious dependency changes.
  • I am targeting dev, and my PR title and commit messages use Conventional Commits in English.

Summary by CodeRabbit

  • New Features

    • Added a new config errors endpoint so apps can view loading issues without blocking normal config usage.
    • Config loading now records and exposes per-file errors instead of stopping at the first failure.
  • Bug Fixes

    • Invalid config files now surface clearer, localized messages.
    • Repeated bootstraps avoid duplicate error notifications for the same unresolved issue, while still re-notifying when the failure changes.
  • Tests

    • Expanded coverage for config error reporting, error deduplication, and the new API response.

…ntime (#1485)

A malformed config file (bad JSONC or a schema violation) is raised as a defect
by ConfigParse, which dies the whole instance config load. Because Config.get()
runs on nearly every operation and the app polls it, the same failure resurfaced
on every call — the reported error storm that also left the desktop app unusable
after a non-technical user hand-edited pawwork.json to add an MCP server.

Engine: in the PawWork runtime, catch config-parse defects per file, record each
one, and fall back to an empty config for just that file so every other valid
config source still loads. The plain opencode CLI keeps its fail-fast contract
(a broken config still throws). Verified via probe that these throws are defects,
not typed failures, so the existing global orElseSucceed never actually caught
them — loadGlobal now degrades the same way. Recorded errors are exposed through
a new read-only GET /config/errors route (SDK regenerated).

Frontend: on bootstrap, after config loads, fetch the recorded errors and surface
one readable toast per unseen problem (deduped by directory + file + message so a
still-broken file does not re-toast on every reload), reusing the existing
ConfigInvalidError formatter and adding a ConfigJsonError branch.

Tests: PawWork keeps valid sibling config and records the error for both invalid
JSON and an invalid mcp entry; the CLI still fails fast; the /config/errors route
returns recorded errors; formatServerError renders ConfigJsonError by file; and
surfaceConfigErrors emits once per distinct error and dedupes across bootstraps.
@Astro-Han Astro-Han added bug Something isn't working P2 Medium priority labels Jul 9, 2026
@github-actions github-actions Bot added app Application behavior and product flows harness Model harness, prompts, tool descriptions, and session mechanics labels Jul 9, 2026
@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@Astro-Han, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 40 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: aab3f3cf-6a6b-4189-ae2b-fa83884a4f50

📥 Commits

Reviewing files that changed from the base of the PR and between 4033548 and ca766a6.

📒 Files selected for processing (4)
  • packages/app/src/context/global-sync/bootstrap.test.ts
  • packages/app/src/context/global-sync/bootstrap.ts
  • packages/opencode/src/config/config.ts
  • packages/opencode/test/config/pawwork-global-config.test.ts
📝 Walkthrough

Walkthrough

This PR adds resilient config loading in the opencode config service: invalid JSON or schema-invalid config files are skipped per-file with recorded ConfigLoadError entries instead of failing bootstrap. A new /config/errors API endpoint exposes these errors, and the app surfaces deduplicated toast notifications with localized messages for JSON parse failures.

Changes

Config load resilience with error surfacing

Layer / File(s) Summary
Config service error tracking
packages/opencode/src/config/config.ts
Adds ConfigLoadError schema, keepValidConfig helper to catch parse defects per-file, error accumulation in global/instance loading, and a new getErrors() accessor on Config.Interface/Service.
HTTP API and OpenAPI spec
packages/opencode/src/server/routes/instance/httpapi/groups/config.ts, .../handlers/config.ts, packages/sdk/openapi.json, packages/opencode/test/server/config-routes.test.ts
Adds GET /config/errors endpoint and handler returning config.getErrors(), updated OpenAPI schema, and route tests validating the new endpoint.
Config resilience tests
packages/opencode/test/config/pawwork-global-config.test.ts
Adds loadErrors() helper and tests confirming invalid JSON/schema-invalid pawwork.json files are skipped while valid config still loads and errors are recorded.
App-side ConfigJsonError formatting
packages/app/src/utils/server-errors.ts, packages/app/src/utils/server-errors.test.ts
Adds ConfigJsonError type, isConfigJsonErrorLike guard, and parseReadableConfigJsonError formatter producing a readable message instead of a raw parser dump.
Bootstrap toast surfacing
packages/app/src/context/global-sync/bootstrap.ts, .../bootstrap.test.ts, packages/app/src/i18n/en.ts, packages/app/src/i18n/zh.ts
Adds surfaceConfigErrors to deduplicate and emit at most one toast per unseen config error, wired into directory bootstrap via sdk.config.errors(), with new toast.config.invalid translations and tests.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant App as App Bootstrap
  participant SDK as sdk.config
  participant Server as ConfigHttpApi
  participant ConfigService as Config.Service
  participant Toast as showToast

  App->>SDK: errors()
  SDK->>Server: GET /config/errors
  Server->>ConfigService: getErrors()
  ConfigService-->>Server: ConfigLoadError[]
  Server-->>SDK: 200 JSON error list
  SDK-->>App: ConfigLoadError[]
  App->>App: surfaceConfigErrors(directory, errors)
  alt unseen error
    App->>Toast: emit error toast
  else already surfaced
    App->>App: skip
  end
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 11.11% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed Clearly states the main change: graceful config loading for broken PawWork config files.
Description check ✅ Passed Includes all required sections and enough verification/risk detail; only the routing-label checkbox remains unchecked.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/1485-config-load-resilience

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested priority: P2 (includes user-path files (packages/app/src/context/global-sync/bootstrap.test.ts, packages/app/src/context/global-sync/bootstrap.ts, packages/app/src/i18n/en.ts, packages/app/src/i18n/zh.ts, packages/app/src/utils/server-errors.test.ts, packages/app/src/utils/server-errors.ts)).

P1/P0 are reserved for maintainer confirmation. Please relabel manually if this is a release blocker, security issue, data-loss risk, or updater/runtime failure.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/app/src/context/global-sync/bootstrap.ts (1)

63-93: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Unbounded module-level dedup cache.

seenConfigErrors is a module-level Set that entries are only ever added to, never evicted — it lives for the entire app process lifetime. Every distinct (directory, path, message, issues) combination a user ever triggers (e.g. repeatedly editing/fixing/re-breaking a config file, each attempt producing a different parser message/line number) permanently occupies memory, even long after the underlying file is fixed and the directory is closed/removed. It's also awkward for future test isolation since the state is shared across all callers/tests importing this module.

Consider scoping this by directory (e.g. clear/replace the per-directory entries when bootstrapDirectory runs and the directory's config successfully loads with no errors, or evict entries once a directory is closed) rather than a flat, ever-growing global Set.

♻️ Possible direction
-const seenConfigErrors = new Set<string>()
+// Keyed by directory so entries can be cleared/replaced per bootstrap pass instead of growing forever.
+const seenConfigErrorsByDirectory = new Map<string, Set<string>>()
 
 export function surfaceConfigErrors(
   directory: string,
   errors: ConfigLoadError[],
   translate: (key: string, vars?: Record<string, string | number>) => string,
   emit: (toast: { variant: "error"; title: string; description: string }) => void = showToast,
 ) {
+  const seen = seenConfigErrorsByDirectory.get(directory) ?? new Set<string>()
+  seenConfigErrorsByDirectory.set(directory, seen)
   for (const error of errors) {
-    const key = [directory, error.data?.path ?? "", error.data?.message ?? "", JSON.stringify(error.data?.issues ?? "")].join(
+    const key = [error.data?.path ?? "", error.data?.message ?? "", JSON.stringify(error.data?.issues ?? "")].join(
       " | ",
     )
-    if (seenConfigErrors.has(key)) continue
-    seenConfigErrors.add(key)
+    if (seen.has(key)) continue
+    seen.add(key)
     emit({ ... })
   }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/app/src/context/global-sync/bootstrap.ts` around lines 63 - 93, The
module-level dedup cache in surfaceConfigErrors currently grows forever because
seenConfigErrors only adds entries and never evicts them. Change the dedup
strategy to be directory-scoped, or otherwise clear/remove entries when
bootstrapDirectory succeeds for that directory or when the directory is closed,
so stale config error keys do not accumulate across the app lifetime. Also make
the cache easy to reset for tests by avoiding a single shared global Set that
all callers import and mutate.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/opencode/src/config/config.ts`:
- Around line 385-388: The `toConfigLoadError` helper is returning
`InvalidError.toObject()` by cast, which can leak extra `issues` fields beyond
the declared `{ message, path }` `ConfigLoadError` shape. Update
`toConfigLoadError` in `config.ts` to normalize `InvalidError` by mapping its
`issues` into only the allowed fields before returning, while keeping the
`JsonError.isInstance` and `InvalidError.isInstance` branches intact. Make sure
the `/config/errors` response matches the SDK contract and does not expose
upstream Zod issue details.

---

Nitpick comments:
In `@packages/app/src/context/global-sync/bootstrap.ts`:
- Around line 63-93: The module-level dedup cache in surfaceConfigErrors
currently grows forever because seenConfigErrors only adds entries and never
evicts them. Change the dedup strategy to be directory-scoped, or otherwise
clear/remove entries when bootstrapDirectory succeeds for that directory or when
the directory is closed, so stale config error keys do not accumulate across the
app lifetime. Also make the cache easy to reset for tests by avoiding a single
shared global Set that all callers import and mutate.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 5472924e-0c88-41b2-a898-01c52e0b5d1f

📥 Commits

Reviewing files that changed from the base of the PR and between ca3bf6b and 4033548.

⛔ Files ignored due to path filters (2)
  • packages/sdk/js/src/v2/gen/sdk.gen.ts is excluded by !**/gen/**
  • packages/sdk/js/src/v2/gen/types.gen.ts is excluded by !**/gen/**
📒 Files selected for processing (12)
  • packages/app/src/context/global-sync/bootstrap.test.ts
  • packages/app/src/context/global-sync/bootstrap.ts
  • packages/app/src/i18n/en.ts
  • packages/app/src/i18n/zh.ts
  • packages/app/src/utils/server-errors.test.ts
  • packages/app/src/utils/server-errors.ts
  • packages/opencode/src/config/config.ts
  • packages/opencode/src/server/routes/instance/httpapi/groups/config.ts
  • packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts
  • packages/opencode/test/config/pawwork-global-config.test.ts
  • packages/opencode/test/server/config-routes.test.ts
  • packages/sdk/openapi.json

Comment thread packages/opencode/src/config/config.ts Outdated
Astro-Han added 3 commits July 9, 2026 23:54
…anaged config

Address two findings from PR review:

- toConfigLoadError kept the JSONC parser's raw debug message, which echoes
  the offending config text (potentially API keys/PATs from a half-edited
  file) both as a full dump and per-error context lines. That message is
  surfaced to the frontend via /config/errors and stored in the dedup key.
  Keep only the '<code> at line N, column M' summaries; drop echoed source.

- The managed-config-dir and macOS managed-preferences branches loaded config
  bare, so a broken managed file still died the whole load and never reached
  /config/errors. Wrap both in keepValidConfig so managed deployments degrade
  like user config (non-PawWork CLI still fail-fasts via the isPawWork gate).
The sanitizer kept lines whose suffix matched ' at line N, column M', but the
parser echoes the offending source as 'Line N: <source>'. A config line that
itself ends with such a suffix (e.g. 'sk-secret at line 2, column 3') would be
mistaken for a summary and leak through /config/errors.

Anchor the filter to the whole line ('^<Code> at line N, column M$') and drop
'Line N:' echo lines explicitly, so no raw config line can masquerade as a
summary. Add a regression test for a source line ending in a fake position.
…ache

Address two CodeRabbit findings:

- toConfigLoadError cast InvalidError.toObject() straight to ConfigLoadError,
  so zod issues carried fields beyond the declared { message, path } shape
  (issue code, expected/received or raw input values, nested union errors).
  Map issues to only { message, path } so no upstream zod detail — including
  echoed config values — leaks via /config/errors, state, or the dedup key.

- surfaceConfigErrors used a module-level Set that only ever grew, holding a
  key for every distinct config error for the app's lifetime. Scope it per
  directory and replace each pass with the errors still present, so a fixed
  config drops its entry and a regressed error is surfaced again.
@Astro-Han
Astro-Han merged commit 6f467de into dev Jul 9, 2026
43 checks passed
@Astro-Han
Astro-Han deleted the fix/1485-config-load-resilience branch August 21, 2026 00:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

app Application behavior and product flows bug Something isn't working harness Model harness, prompts, tool descriptions, and session mechanics P2 Medium priority

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant