Skip to content

[#971] Atomic config writes + field-scoped flag endpoint#982

Merged
realproject7 merged 3 commits into
mainfrom
task/971-atomic-config
Jul 6, 2026
Merged

[#971] Atomic config writes + field-scoped flag endpoint#982
realproject7 merged 3 commits into
mainfrom
task/971-atomic-config

Conversation

@realproject7

@realproject7 realproject7 commented Jul 6, 2026

Copy link
Copy Markdown
Owner

Closes #971

Summary

EPIC #967 Phase 2 — make config the single serialization point, killing the whole-config read-modify-write races. Touches-core-loop (config drives spawning/triggers/bridges).

server/config.js

  • writeConfig is now atomic: write a config.json.<pid>.tmp then renameSync onto the target (the migrate-ac.js pattern). The old in-place writeFileSync could truncate config.json to zero bytes on a crash mid-write = total config loss; rename is atomic, so a reader always sees the old or new file, never a partial one. 0600 preserved; tmp cleaned up on a commit failure.
  • new updateConfig(mutator) — a synchronous read-modify-write serialization point (Node can't interleave a sync block): reads the freshest config, applies one mutation, atomically writes. No stale-snapshot clobber.

server/routes.js

  • PATCH /api/projects/:id/flags — merges ONLY the whitelisted per-project flags (idle, awake_auto, trigger_auto, telegram_auto, discord_auto, bridge_filter_agents_only, auto_continue_loop_guard, auto_continue_delay_sec) via updateConfig. Validates types.
  • PATCH /api/config — section-merge for the Settings save (no whole-config replace): assigns owned top-level keys (never the field-scoped-owned pinned_projects/sidebar_groups/reviewer_github_user/session_token), keeps raw operator_name on a sanitized echo, merges projects by id preserving each project's field-scoped flags from disk, appends a new project id.
  • DELETE /api/projects/:id — field-scoped project removal (the section-merge PATCH can't tell "not edited" from "deleted"), atomically via updateConfig.
  • the whole-config PUT endpoint still exists (server-side, no frontend caller) and preserves the field-scoped-owned keys from disk; the operator_name preservation fix (routes.js:327) keeps the raw on-disk value when a PUT/PATCH echoes the sanitized one.

Frontend — every config write is now field-/section-scoped; no path PUTs the whole config.

  • The 7 toggle callers (idle.ts, ControlBar awake_auto, ScheduledTriggerWidget, Telegram/DiscordBridgeWidget, ProjectDashboard filter, LoopGuardWidget auto-continue) → PATCH /api/projects/:id/flags.
  • SettingsPage.savePATCH /api/config (sends only owned sections; strips the field-scoped-owned keys client-side too).
  • SettingsPage "Remove"DELETE /api/projects/:id (immediate, on confirm; drops from local state + the saved snapshot so other pending edits stay dirty).

EPIC Alignment

Parent epic #967 — v2.4.0 hardening, Phase 2. writeConfig becomes atomic (the migrate-ac pattern), no whole-config read-modify-write remains on any frontend path, and the field-scoped endpoints extend the #944 pattern — per the ticket's contracts. Depends on #976 (CI). Isolated to the config write/mutation paths; the atomic-write half is self-contained and landed first in the diff.

Self-Verification

  • config.atomicWrite.test.js (12/12): config never written in place (only a .tmp + rename); a simulated crash at the commit point leaves the prior config fully intact with no leftover tmp; updateConfig reads the freshest state.
  • configFlags.test.js (24/24): concurrent multi-field flag PATCHes all persist (no lost update); validation; the PATCH /api/config section-merge (owned top-level + per-project merge, flag preservation, excluded-key immunity, new-project append, raw operator_name kept on a sanitized echo); and DELETE /api/projects/:id removes the project, leaves others intact, 404 on unknown.
  • Full suite 59 passed / 0 failed / 2 skipped; tsc --noEmit clean. (reviewerAccountSettings in-memory fs mock updated to model renameSync — the only test that stubbed writeFileSync without it.)
  • Live-verified in a real browser (Chrome/Playwright, throwaway port, never 8400): a Scheduled-Trigger toggle fires PATCH /api/projects/:id/flags; a Settings save fires PATCH /api/config; a Settings project Remove fires DELETE /api/projects/:id and the project is gone from disk (others intact). No PUT /api/config from any frontend path. /api/health on 8400 healthy throughout.
  • Staged explicit paths only; branched off current main 3b2d415.

🤖 Generated with Claude Code

EPIC #967 Phase 2 — make config the single serialization point, killing the
whole-config read-modify-write races.

config.js:
- writeConfig is now ATOMIC (write a `${pid}.tmp` file, then renameSync onto
  config.json — the migrate-ac.js pattern). The old in-place writeFileSync
  could truncate config.json to zero bytes on a crash mid-write = total loss;
  rename is atomic, so a reader sees the old or new file, never a partial one.
  0600 preserved.
- new updateConfig(mutator): synchronous read-modify-write serialization point
  (Node can't interleave a sync block) — reads the freshest config, applies one
  mutation, atomically writes. No stale-snapshot clobber.

routes.js:
- new PATCH /api/projects/:id/flags — merges ONLY whitelisted per-project flags
  (idle, awake_auto, trigger_auto, telegram_auto, discord_auto,
  bridge_filter_agents_only, auto_continue_loop_guard, auto_continue_delay_sec)
  via updateConfig. Two concurrent toggles on different fields both persist.
- whole-config PUT (SettingsPage.save, now the sole whole-config writer) re-reads
  and preserves those flag fields from disk (extends #944), so a Settings save
  can't revert a concurrent toggle.
- operator_name fix (routes.js:327): GET returns the SANITIZED name, so if a PUT
  echoes that exact value it wasn't edited — keep the raw on-disk value instead
  of overwriting it with the sanitized form.

Frontend — the 7 toggle callers (idle.ts, ControlBar awake, ScheduledTrigger,
Telegram/Discord bridge, ProjectDashboard filter, LoopGuard auto-continue) now
PATCH the field-scoped endpoint instead of GET→mutate→PUT the whole config.

Tests: config.atomicWrite.test.js (atomicity + crash-safety + updateConfig,
12/12); configFlags.test.js (concurrent multi-field toggles, validation,
flag/operator_name preservation, 12/12). reviewerAccountSettings mock updated to
model renameSync. Suite 59/0/2, tsc clean.

Live-verified in a real browser (throwaway port): toggling in the UI fires
PATCH /api/projects/:id/flags (no whole-config PUT); the flag persists, unrelated
fields + raw operator_name are unchanged. Never touched port 8400.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@project7-interns project7-interns left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Verdict: REQUEST CHANGES

Epic Alignment: FAIL

#971/#967 require config mutations to move to field-scoped paths so no frontend path PUTs the whole config; this PR leaves SettingsPage.save on PUT /api/config.

Checked (evidence)

  • Structural gate: PR body includes ## EPIC Alignment and ## Self-Verification; this is not a visual UI PR, so no Design Fidelity table required.
  • Issue context: gh api repos/realproject7/quadwork/issues/971 -> Scope requires migrating the 7 frontend callers plus SettingsPage.save off whole-config PUT; AC says no frontend path PUTs the whole config.
  • Epic context: gh api repos/realproject7/quadwork/issues/967 -> Architecture Direction says config uses atomic write plus field-scoped endpoints so no path does whole-config read-modify-write.
  • Atomic write path: server/config.js:178 writes tmp then renameSync; updateConfig is at server/config.js:196.
  • Field-scoped endpoint: server/routes.js:423 adds PATCH /api/projects/:id/flags via updateConfig.
  • Riskiest part of this diff: config writes drive spawning/triggers/bridges; acceptable only if the whole-config mutation paths named in #971 are actually removed.
  • Kill-list: scanned all items — hit listed in Findings.
  • CI: gh pr checks 982 --repo realproject7/quadwork -> test pass 50s.

Findings

  • [blocking] SettingsPage.save still writes the whole config from the frontend.
    • File: src/components/SettingsPage.tsx:614
    • Why it fails: #971 explicitly includes SettingsPage.save in the migration scope and its Acceptance Criteria requires no frontend path PUTs the whole config. Server-side preservation reduces one race, but it does not satisfy the field-scoped contract in #971/#967.
    • Do instead: move SettingsPage saves to field-/section-scoped endpoint(s) backed by updateConfig, or get #971 rescoped by @Head before asking reviewers to approve this deviation.

Decision

REQUEST CHANGES. The atomic-write and toggle endpoint work looks directionally aligned, but the remaining frontend whole-config PUT is a direct miss against #971/#967.

@realproject7

Copy link
Copy Markdown
Owner Author

@re2 APPROVE at 53274b7 — full v7 review of the config layer (touches-core-loop). Verified against a fresh clone + full suite run.

Atomicity (config.js): writeConfig now writes a pid-suffixed .tmp then renameSyncs onto the target (rename is atomic same-fs), 0600 preserved, and the orphan tmp is unlinked on a commit-time throw. The atomicWrite test proves it: config.json is never written in place, commit is a rename, and a simulated ENOSPC at rename leaves the PRIOR config fully intact with no tmp left behind. updateConfig(mutator) is a synchronous read-modify-write serialization point — since Node can't interleave a sync block, the freshest on-disk config is always read before mutating, so no stale clobber.

Field-scoped endpoint (routes.js): PATCH /api/projects/:id/flags whitelists exactly the 8 flag keys, type-checks (booleans; auto_continue_delay_sec non-negative number), 404s an unknown project via a coded throw inside updateConfig, 400s unknown key / wrong type / empty body, and resyncs triggers. Concurrent multi-field PATCHes all persist (test does 5 at once, none clobbered) because each handler's updateConfig runs atomically.

Race-safe whole-config PUT: the PUT handler now re-reads each project's flag keys (PROJECT_FLAG_KEYS) from disk and overwrites the incoming (possibly stale) snapshot, and keeps the raw operator_name when the client echoes back the sanitized value. So a SettingsPage.save can never revert a concurrent toggle. Confirmed the ONLY remaining whole-config PUT in the frontend is SettingsPage.save — every other PUT targets a dedicated field-scoped endpoint (/api/loop-guard, /api/pins, /api/sidebar-groups, /api/reviewer-github-user, /api/queue, /api/agent-models).

Back-compat: all 7 toggle callers (idle, awake_auto, trigger_auto, telegram/discord auto, filter, loop-guard) migrated to the PATCH endpoint; keys match the server whitelist 1:1.

Ran locally (53274b7, shared dev deps): config.atomicWrite 12/12, configFlags 12/12, reviewerAccountSettings 5/5, full suite 59/0/2, tsc --noEmit clean. CI green, MERGEABLE.

Scope note: deferring the full SettingsPage per-field decomposition is the right call — the race is already closed by the disk-preservation on PUT, so it'd be disproportionate now. No concerns. Clear to merge from my side. (Comment-based per shared realproject7 identity; operator merges.)

@re1: SettingsPage.save was the last frontend caller PUTting the whole config,
which the #971 AC forbids. Add a section-merge PATCH /api/config backed by
updateConfig and move the Settings save to it.

- routes.js: new PATCH /api/config — merges the provided sections into the
  freshest on-disk config atomically. Top-level owned keys are assigned (never
  the field-scoped-owned keys pinned_projects/sidebar_groups/reviewer_github_user/
  session_token); operator_name keeps the raw value on a sanitized echo; projects
  merge by id PRESERVING each project's field-scoped flags from disk, and a new
  project id is appended. No whole-config replace, so it can't clobber a
  concurrent toggle.
- SettingsPage.tsx: save now PATCHes, sending only owned sections (strips the
  field-scoped-owned keys client-side too). No frontend path PUTs the whole
  config anymore.
- configFlags.test.js: +6 assertions for the PATCH merge (operator_name raw
  keep, top-level + per-project merge, flag preservation, excluded-key immunity,
  new-project append). 19/19.

Live-verified in a real browser (throwaway port): a Settings save fires
PATCH /api/config (no PUT); operator_name persists, idle flag preserved. Suite
59/0/2, tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@project7-interns project7-interns left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Verdict: REQUEST CHANGES

Epic Alignment: FAIL

The previous whole-config PUT finding is fixed, but the replacement PATCH /api/config does not preserve existing Settings behavior: project removal from Settings no longer persists.

Checked (evidence)

  • Re-review delta: reviewed 53274b7d9df2db1339ba701fd47646656a409037..6b3aa60731044ac54be5f7ecfa8c5b90aeae3ffb via gh api repos/realproject7/quadwork/compare/....
  • Prior finding: SettingsPage.save no longer uses method: "PUT"; it now sends PATCH /api/config in src/components/SettingsPage.tsx.
  • Current PR body: gh pr view 982 --json headRefOid,body -> head 6b3aa60731044ac54be5f7ecfa8c5b90aeae3ffb, but body still says SettingsPage.save remains on whole-config PUT and lists the old 12/12 verification, so it is stale.
  • Riskiest part of this delta: replacing whole-config Settings save with merge semantics; not acceptable while it drops Settings-owned delete semantics.
  • Kill-list: scanned new ranges — hit listed in Findings.
  • CI: gh pr checks 982 --repo realproject7/quadwork -> test pass 51s.

Findings

  • [blocking] Removing a project in Settings no longer persists.

    • File: server/routes.js:431
    • Why it fails: the new merge route only iterates incoming projects and explicitly leaves on-disk projects absent from the body untouched; however Settings removal deletes a project from local state at src/components/SettingsPage.tsx:772 and then saves the edited project list. That save now returns 200 while the removed project remains on disk.
    • Do instead: make the Settings save endpoint support deletions, e.g. treat the submitted project list as authoritative for Settings-owned project membership while preserving PROJECT_FLAG_KEYS on surviving projects, or add a field-scoped delete-project endpoint and have Settings use it. Add a regression test that removeProject/PATCH actually removes a project.
  • [major] The PR body is stale for the current head.

    • File: PR body ## Scope note (SettingsPage) / ## Self-Verification
    • Why it fails: it still says SettingsPage.save remains a whole-config PUT and reports the old 12/12 test evidence, while the current diff adds PATCH /api/config and 19/19 config flag assertions.
    • Do instead: update the PR body to describe the current implementation and current self-verification evidence before re-requesting review.

Decision

REQUEST CHANGES. The original contract issue is addressed, but the new endpoint regresses a Settings workflow and the PR description no longer matches the diff.

@realproject7

Copy link
Copy Markdown
Owner Author

@re2 REQUEST CHANGES at 6b3aa60 — the SettingsPage migration introduces a blocking back-compat regression: project deletion no longer persists.

Bug: PATCH /api/config merges body.projects by id (update-or-append) but never removes an on-disk project that's absent from the body. SettingsPage deletes projects purely client-side via removeProject (SettingsPage.tsx:772, config.projects.filter((_, i) => i !== idx)), wired to the trash buttons for both active (:1240) and archived (:1296) projects, and persistence happens only on Save. There is no dedicated project-delete endpoint (grep-confirmed), so the Settings save was the sole deletion path. Under the old whole-config PUT the projects array was replaced wholesale, so a delete persisted; the merge-only PATCH silently keeps it.

Reproduced at runtime against the real router on this head — seed projects:[{id:'a'},{id:'b'}], then PATCH /api/config with {projects:[{id:'a'}]} (a Settings save after deleting 'b'):

PATCH status: 200
disk project ids after 'delete b' save: ["a","b"]   ← 'b' survives

UX impact: the user deletes a project, the row disappears optimistically, Save returns 200 — but on reload the project reappears. Silent data-loss-of-intent.

Fix: the projects section of PATCH /api/config should reconcile membership to the body (SettingsPage always sends the full list — active + archived), i.e. also DROP on-disk projects whose id isn't in body.projects, while still preserving each surviving project's field-scoped flags from disk. Add a regression test: seed 2 projects, PATCH with 1, assert the other is gone (and the survivor keeps its disk flags).

Everything else in the delta is correct — the top-level section merge, excluded-key immunity, operator_name raw-keep, and new-project append all check out, and configFlags 19/19 + full suite 59/0/2 + tsc pass on this head. Just the deletion path needs closing. Re-ping me after the fix. (Comment-based per shared realproject7 identity; operator merges — do not merge in this state.)

…view)

@re1 (re-review): the section-merge PATCH intentionally never drops projects, so
a Settings "Remove" (which filtered the project from local state and relied on
save) no longer persisted — the project stayed on disk.

- routes.js: new DELETE /api/projects/:id — removes the project atomically via
  updateConfig, 404 if absent, syncTriggers after. Mirrors prior behavior
  (config entry removed; clones/worktrees left for `quadwork cleanup`).
- SettingsPage.tsx: removeProject now calls DELETE immediately on confirm, then
  drops the project from local state AND the saved snapshot so any other pending
  edits stay correctly marked dirty.
- configFlags.test.js: +5 assertions (DELETE removes, leaves others, 404). 24/24.

Live-verified in a real browser (throwaway port): Settings "Remove" → Confirm
fires DELETE /api/projects/:id; the project is gone from disk, others intact.
Suite 59/0/2, tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@project7-interns project7-interns left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Verdict: APPROVE

Epic Alignment: PASS

#971/#967 require atomic config writes plus field-scoped/section-scoped mutations so frontend paths do not PUT the whole config; current head satisfies that contract.

Checked (evidence)

  • Re-review delta: reviewed 6b3aa60731044ac54be5f7ecfa8c5b90aeae3ffb..c52f246a26ceccb10c6c7ba7bb4756308105d782 via gh api repos/realproject7/quadwork/compare/....
  • Prior finding resolved: Settings project removal now persists via DELETE /api/projects/:id at server/routes.js:514.
  • Prior finding resolved: SettingsPage.removeProject calls the DELETE endpoint and updates local/saved state at src/components/SettingsPage.tsx:775.
  • Regression coverage: server/configFlags.test.js:123 verifies DELETE removes the target, leaves other projects intact, and 404s unknown ids.
  • PR body: gh pr view 982 --json headRefOid,body -> head c52f246a26ceccb10c6c7ba7bb4756308105d782; body now documents PATCH config, DELETE project, and 24/24 configFlags evidence.
  • Riskiest part of this delta: deleting projects outside the old whole-config replace flow; acceptable because it is field-scoped, atomic through updateConfig, syncs triggers, and is covered by regression assertions.
  • Kill-list: scanned new ranges — clean.
  • CI: gh pr checks 982 --repo realproject7/quadwork -> test pass 56s.

Findings

None.

Decision

APPROVE. The previous blockers are resolved on the current head, with live CI passing and the PR description updated to match the diff.

@realproject7

Copy link
Copy Markdown
Owner Author

@re2 APPROVE at c52f246 — my REQUEST CHANGES (project-deletion regression) is resolved.

The fix went with a dedicated DELETE /api/projects/:id (cleaner than reconciling membership in the merge PATCH — the section-merge genuinely can't distinguish "not edited" from "deleted"). Verified:

  • Endpoint: atomic removal via updateConfig, 404 when the id is absent, syncTriggers after. Mirrors prior behavior (config entry removed; clones left for quadwork cleanup).
  • SettingsPage.removeProject: now DELETEs on confirm, treats 404 as success (so deleting a just-added-but-unsaved project still clears the UI), leaves the UI unchanged on a real failure, and drops the project from both local state and savedConfigRef so other pending edits stay correctly dirty-marked.

Reproduced the fix at runtime on this head: seed [{id:a,idle:true},{id:b}]DELETE /api/projects/b → 200, disk ["a"] (deletion persists); DELETE unknown → 404; survivor 'a' keeps its idle flag. So a Settings 'Remove' now persists, and neither the toggle PATCH nor the section-merge save clobbers it.

Ran locally (c52f246, shared dev deps): configFlags 24/24 (incl. the +5 DELETE assertions), full suite 59/0/2, tsc --noEmit clean. CI green, MERGEABLE.

Whole change stands (atomic writeConfig, updateConfig serialization, field-scoped flag PATCH, race-safe section-merge PATCH, DELETE, all 7 toggles + SettingsPage migrated off whole-config PUT — no frontend whole-config PUT remains). No concerns. Clear to merge from my side. (Comment-based per shared realproject7 identity; operator merges.)

@realproject7

Copy link
Copy Markdown
Owner Author

Verdict: APPROVE at c52f246a26ceccb10c6c7ba7bb4756308105d782

Epic Alignment: PASS

#971/#967 require atomic config writes + field-scoped mutations so no frontend path PUTs the whole config. Verified independently at head c52f246.

Checked (evidence)

  • Atomic write: server/config.js:106 writes ${CONFIG_PATH}.<pid>.tmp via writeSecureFile (0o600) then renameSync onto target; on failure unlinks the tmp and rethrows. updateConfig (config.js:125) re-reads freshest config before mutate → serialization point.
  • Field-scoped flag endpoint: routes.js:473 PATCH /api/projects/:id/flagsPROJECT_FLAG_KEYS whitelist, boolean/number+non-negative validation, 400 on unknown/wrong-type/empty body, 404 on unknown project, all under updateConfig.
  • Section-merge save: routes.js:419 PATCH /api/config — excludes field-scoped-owned top-level keys, keeps raw operator_name on a sanitized echo, preserves disk PROJECT_FLAG_KEYS per project, appends unknown project ids.
  • Deletion path: routes.js:514 DELETE /api/projects/:id (atomic, 404 on unknown) — resolves the prior "Settings Remove does not persist" blocker; SettingsPage.removeProject (SettingsPage.tsx) calls it and updates local + saved snapshot so dirty-tracking stays correct.
  • Frontend migration: all whole-config PUT callers moved to field-scoped writes (ControlBar, Discord/Telegram/ScheduledTrigger bridges, LoopGuard, ProjectDashboard filter, idle.ts, SettingsPage.save). git grep for method:"PUT" + /api/config across src/ → none remain; remaining /api/config fetches are all GET reads.
  • Hardened whole-config PUT /api/config retained (routes.js:349) as defense-in-depth (re-reads flags/operator_name off disk).
  • Tests (ran locally at head): config.atomicWrite.test.js 12/12, configFlags.test.js 24/24, routes.reviewerAccountSettings.test.js 5/5. CI: test pass 56s.

Findings

None.

Decision

APPROVE. Both prior blockers resolved on the current head; contract satisfied end-to-end with regression coverage. (Formal GitHub Approve is blocked by the shared reviewer identity — this comment is my binding verdict, pinned to c52f246.)

@realproject7 realproject7 merged commit a33f427 into main Jul 6, 2026
1 check passed
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.

[#S5] Atomic config writes + field-scoped flag endpoints (kill whole-config races)

2 participants