Skip to content

Store host profile model + --check-settings verb + --validate-config registry fix (#4214) - #4271

Merged
erikdarlingdata merged 10 commits into
devfrom
feature/4214-store-host-check
Sep 25, 2026
Merged

erikdarlingdata merged 10 commits into
devfrom
feature/4214-store-host-check

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

Part of #4214.

Why

The managed store already sizes PostgreSQL from the host. None of that was visible from the product. Answering "is this store sized right" needed a remote shell, pg_settings.sourceline, and hand-counting conf blocks. This lane builds part 1: the host/store profile model, per-setting source attribution and verdicts, and the --check-settings CLI verb. It leaves the get_store_host MCP read, the web Store/Host panel, and cloud identity via IMDS to a later lane, per the brief.

What changes

New file Darling/PerformanceMonitor.Darling.Service/DarlingStoreHostProfile.cs:

  • Cross-platform host facts: OS platform, containerization, CPU count (Environment.ProcessorCount), RAM, and the data volume (size, free space, filesystem, all via DriveInfo).
  • RAM on Windows reuses the exact GlobalMemoryStatusEx read DarlingManagedPostgres sizes itself from. It is extracted to TryReadWindowsPhysicalMemoryBytes, so there is no second P/Invoke of the same API. RAM on Linux reads /proc/meminfo MemTotal, plus cgroup v2 memory.max with a fallback to v1 memory.limit_in_bytes. Both reads go through pure functions over already-read file text, so a Windows test covers every branch: meminfo missing, meminfo unparseable, v2's max, and v1's near-long.MaxValue sentinel.
  • Store facts: PostgreSQL and TimescaleDB versions, live store size, lifetime buffer hit ratio, lifetime temp bytes, and the Design: size raw chunk interval, compress_after and the hourly CAGG refresh window from ingest rate; a large store keeps 1–2 days of raw uncompressed and it outgrows RAM #4211 metric. That metric is the total bytes and chunk count of every hypertable chunk timescaledb_information.chunks reports as not yet compressed, summed with pg_total_relation_size.
  • Eight settings checked: shared_buffers, work_mem, effective_cache_size, maintenance_work_mem, timescaledb.max_background_workers, max_worker_processes, max_connections, max_wal_size. Each reuses the product's own DeriveMemorySettings, DeriveWorkerSettings, or DeriveWalSettings for "the value this host would derive right now," so no formula is duplicated. Mid-lane I found that max_wal_size is not the fixed 4GB literal the v4 block suggests: the v12 block supersedes it with a value derived from free disk space. The check follows DeriveWalSettings, not a constant.
  • Source attribution never escalates privileges. For a managed store it reads postgresql.conf's managed blocks and postgresql.auto.conf directly from disk, reusing DarlingManagedPostgres.ReadConfAssignments, which already follows PostgreSQL's own include and precedence order. It does not read pg_settings.sourcefile, because that column needs superuser or pg_read_all_settings, a right the least-privilege mcp role does not have (see DarlingStoreMetricsReader.JobExecutionLoggingSql's remarks, which this lane's code comments point back to). A generic line-vs-managed-block scan walks every marker in a new DarlingManagedPostgres.AllManagedConfMarkers list, so a future version block needs no update here. A bring-your-own store gets not-managed for every setting, plus pg_settings.source.
  • Four verdicts, exactly as ruled: matches, stale-after-hardware-change, operator-override, not-managed.

--check-settings CLI verb (DarlingCliCommands.CheckSettingsAsync, wired in Program.cs). It prints the profile and the per-setting table; --json selects the automation form. It returns three distinct exit codes, kept separate per the ruling so a caller can tell a sizing problem from a connectivity problem: 1 for a config error, 2 for an unreachable store, 3 for one or more stale-after-hardware-change verdicts.

--validate-config fix, from the issue's comment. It now probes the store's registry (config_monitored_servers, read via the newly-internal StoreConfigProvider.ReadMonitoredServersAsync) when the store is reachable. A darling.json server absent from the registry now gets a warning instead of failing the run. When the store cannot be reached, it falls back to darling.json's own list and says so on stdout. This is a behavior change: before this PR, --validate-config and --test-connection always probed the file's list and exited 1 on a stale entry. That is the exact false failure the issue's comment reported on a 42-server production store.

Small in-lane fixes, both mechanical and in files this PR already touches:

  • Extracted DarlingManagedPostgres.TargetMaxConnections = 200, so the v4 block and the new check share one source instead of the literal 200 living in two places.
  • Made FindHardwareSizingBlockEnd and ReadConfAssignments reachable (internal), so the new file can reuse them instead of re-implementing them.
  • Added DarlingStoreHostProfile.ComputeEffectiveMemoryLimitBytes to TsqlConventionGuardTests.KnownTruncatedRanges (the member-range census correctly flagged it as a new expression-bodied one-liner, the same shape as the other entries already in that list).

This lane (H1b) added, on top of H1's core:

  • Darling/Darling.Tests/DarlingStoreHostProfileLiveTests.cs: one live test that builds the profile against a real managed data directory and a real running server, proving all four verdicts resolve correctly end to end — not just the pure classification. Matches comes from a real v8-marker block plus pg_reload_conf() for three sighup/user-context settings; stale-after-hardware-change needs no live change at all, because PostgreSQL's untouched max_connections default (100) already disagrees with today's derivation (200) once a managed block claims it; operator-override comes from a real ALTER SYSTEM; not-managed comes from a bring-your-own config against the same connection. Restores the conf files (and reloads) in finally via LiveStoreCleanup.
  • Darling/Darling.Tests/DarlingCliCommandsHostCheckTests.cs: an exit-code test per --check-settings outcome (config parse error, unreachable store, a bring-your-own store where nothing is ever stale in both text and --json, a managed store with one stale setting) and per --validate-config registry path (a seed-only server warns and exits 0, a registry server that fails still fails, an unreachable store falls back to the file's list and says so). Proved live that the stale-settings exit-code test fails if its branch is broken (flipped the Any(... StaleAfterHardwareChange) check to Matches, watched it fail, reverted) — no diff remains from that step.
  • README: a new "Check Host/Store Sizing Settings" section next to "Validate the Config", and a paragraph on --validate-config's registry-vs-file behavior.
  • Fixed LivePostgresCollectionHygieneTests flagging the new CLI test class for reaching the shared store without [Collection("live-postgres")].

EXPLAIN (ANALYZE, BUFFERS) on the uncompressed-chunks read (UncompressedChunkSizeSql), seeded on the rig's probe database: 3 hypertables, 10 daily chunks each (30 chunks total), 13 compressed and 17 left uncompressed.

Aggregate (actual time=19.675..19.682 rows=1 loops=1)
  Buffers: shared hit=508
  -> Subquery Scan on finalq (actual rows=17 loops=1)   [the catalog read itself: 28 buffers]
Planning: Buffers: shared hit=1016, Planning Time: 14.437 ms
Execution Time: 19.853 ms

All 508 buffer hits were cache hits (no disk reads). The catalog view itself (timescaledb_information.chunks) costs 28 of those; the rest comes from pg_total_relation_size() resolving each of the 17 uncompressed chunks' size — relation metadata (extent/fork sizes), never a scan of chunk row data. 20ms total for 30 chunks matches the code's own justification for paying this live rather than reading the hourly-sweep figure: a one-shot CLI/once-per-start cost, not a hot path. No bound was added; the read does not scale into chunk contents, only chunk count.

CHANGELOG entry

SECTION: Added
ENTRY:

Test plan

  • dotnet build Darling/PerformanceMonitor.Darling.Service/PerformanceMonitor.Darling.Service.csproj: 0 Warning(s), 0 Error(s).
  • dotnet build Darling/Darling.Tests/Darling.Tests.csproj: 0 Warning(s), 0 Error(s).
  • DarlingStoreHostProfileTests, 43 tests, all passing (pure classification, every RAM/cgroup parse branch, verdict classification, pg_settings unit normalization, CLI verb recognition).
  • DarlingStoreHostProfileLiveTests (new, gated on DARLING_TEST_PG + DARLING_TEST_PGRUNTIME): 1 test, all four verdicts proved end to end against the rig. Passing.
  • DarlingCliCommandsHostCheckTests (new): 7 tests, all --check-settings and --validate-config exit-code paths. Passing. Proved live that the stale-settings test fails against a broken branch, then reverted.
  • EXPLAIN (ANALYZE, BUFFERS) run on seeded hypertables/chunks on probe — numbers above; no bound needed.
  • README updated (--check-settings section, --validate-config registry behavior); plain-English checker run once, the one genuinely long new sentence split.
  • git merge origin/dev (clean, no conflicts), darlingtest dropped/recreated, full Darling suite run once: 13944 total, 0 errors, 7 failed, 29 skipped. Two failures were this branch's: a hygiene-collection miss and a census list needing the new pure function added (both fixed, files changed 0 net diff besides the additions; re-ran the affected classes clean, 76/76 passing). The other 5 (SerialLoopStoreSizeSourceTests, EventWindowedReadsAreBoundedLivePostgresTests, CaptureDownChunkOrderTests, ServerListAndSummaryPlanShapeTests, LiveCleanupBatchTests) were called plan-shape/chunk-count assertions in files this PR never touches. That call was wrong for the first of the five; see below.
  • SerialLoopStoreSizeSourceTests is this branch's own defect, not pre-existing. Re-ran it alone on a freshly created darlingtest. PgDatabaseSizeRunsOnlyWhereItsCostHasABudget failed because DarlingStoreHostProfile.StoreSizeSql (pg_database_size) is a real fourth call site the pin's census did not know about yet, not a false positive. It is a genuine fourth regime: --check-settings's own one-shot, user-invoked read under CliStoreReadSeconds (10s), never the serial loop. It was documented and added to the pin's allow-list with that reasoning, mirroring the existing three entries' style, not weakened. 20/20 passing after the fix.
  • Startup logging is now built (ruling 9). DarlingStoreHostProfile.GatherStartupProfileAsync gathers only the cheap parts: host facts, the live pg_settings read, the managed conf files. It never calls GatherStoreFactsAsync, so the store-size/chunk-total reads that scale with the store stay out of the startup path entirely. DarlingWorker.LogStoreHostProfileAsync calls it once, ahead of the collection loop's while (pinned by source order). It runs under its own new deadline, ServiceCommandDeadlines.StartupHostProfileSeconds (CliStoreReadSeconds + 5 = 15s, documented as its own regime rather than reusing BootstrapSeconds), logs the profile at info, warns once per stale-after-hardware-change setting, and never throws (pinned: no throw anywhere in the method's source). New file StartupHostProfileLogTests.cs has 4 tests: the log-line shape against a fixture whose Store is filled with sentinel values that must never print, the store-facts exclusion, the never-throws failure path, and the call-site ordering. Plus 1 new live test in DarlingStoreHostProfileLiveTests.cs proving the exclusion holds against a real running server. All passing.
  • Ran every class in every file this lane (H1c) touched, plus every pin named in this lane's brief, together: StartupCommandTimeoutTests, StorageCommandTimeoutTests, LivePostgresCollectionHygieneTests, TsqlConventionGuardTests, DocCommentHygiene, DarlingStoreHostProfileTests, DarlingCliCommandsHostCheckTests. 185 total, 0 failed. SerialLoopStoreSizeSourceTests (20), StartupHostProfileLogTests (4) and DarlingStoreHostProfileLiveTests (2) ran separately above, all passing.
  • git merge origin/dev again for this lane's own work (clean, no conflicts; dev had since touched EventWindowedReadsAreBoundedLivePostgresTests.cs and PlanChunkScans.cs), darlingtest dropped/recreated. Then ran H1b's 4 other remaining-failure classes alone, skipping EventWindowedReadsAreBoundedLivePostgresTests (another lane owns it): SerialLoopStoreSizeSourceTests, CaptureDownChunkOrderTests, ServerListAndSummaryPlanShapeTests, LiveCleanupBatchTests. 29 total, 0 failed. None of H1b's other 4 reproduced after this merge, so there was nothing left to check against dev's CI for them.
  • Full suite not re-run by this lane. H1b ran it after merging dev (13944 total, see above), the targeted classes above cover every file this lane touched plus every named pin, and CI runs the full suite again on push.

What the coordinator should double-check

  1. Startup logging is built and pinned; see the test plan above. Nothing is outstanding from the original brief.
  2. SerialLoopStoreSizeSourceTests's new fourth allow-list entry (DarlingStoreHostProfile.cs, in the test file). Please confirm the reasoning holds: --check-settings is genuinely this call site's only caller, and startup deliberately never reaches it.
  3. The rig (C:\GitHub\worktrees\rig-th, port 55983) is stopped. It now also carries a darling database (managed-mode connections always target that fixed name) and a probe database with 3 seeded/partly-compressed hypertables, left in place for reuse. Both are throwaway rig state, not customer data.
  4. One judgment call worth checking: for a bring-your-own store, "data volume" reports the volume under the service's own AppContext.BaseDirectory, not a store data directory. A BYO store's data directory is not something this process can know, and the store may be remote. Documented in DarlingStoreHostProfile.ResolveVolumeAnchor's comment as the smallest reasonable call, flagged for revisiting once part 2 builds the web panel.
  5. ServiceCommandDeadlines.StartupHostProfileSeconds is a new constant (15s = CliStoreReadSeconds + 5). Please confirm the derivation in its doc comment reads right: it backstops GatherSettingProfilesAsync's own CliStoreReadSeconds-bound query rather than setting an independent number, the same relationship CliBudgetBackstopSeconds already has to CliStoreReadSeconds.

No monitored or production server was touched. Installer.Tests was not run.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3

erikdarlingdata and others added 10 commits September 25, 2026 05:29
…gistry fix (#4214)

Part 1 of #4214: the cross-platform host/store profile, per-setting source
attribution and verdicts (matches / stale-after-hardware-change /
operator-override / not-managed), the --check-settings CLI verb, and the
--validate-config fix from the issue comment (probe the store's registry
when reachable, not darling.json's seed list; a file-only server is now a
warning, not exit 1).

Reuses DeriveMemorySettings/DeriveWorkerSettings/DeriveWalSettings and the
Windows GlobalMemoryStatusEx read rather than duplicating them. Source
attribution reads the managed data directory's postgresql.conf/.auto.conf
from disk instead of pg_settings.sourcefile, which needs escalated
privileges the mcp role does not have.

Checkpoint commit: tests, startup logging, README and the full suite run
still need to land before this is ready for review.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
#4214)

RAM/cgroup parsing (every branch: meminfo present/missing/unparseable, v2
max vs a real limit, v1's near-long.MaxValue sentinel), containerization,
managed-block line detection, the four-way verdict classification, and
pg_settings unit normalization -- all pure, all Windows-runnable per
ruling 10. Plus CLI verb recognition for --check-settings.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
…dicts

Add DarlingStoreHostProfileLiveTests: builds the profile against a real
managed data directory and a real running server, proving Matches,
StaleAfterHardwareChange, OperatorOverride and NotManaged all resolve
correctly from real conf files and real pg_settings, not just the pure
classification the existing pin tests cover.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
Add DarlingCliCommandsHostCheckTests: an exit-code test per
--check-settings outcome (config parse error, unreachable store, a
bring-your-own store with nothing stale, a managed store with a stale
setting, text and --json), plus --validate-config's three registry
paths (seed-only server warns and exits 0, a registry server that
fails still fails, an unreachable store falls back to darling.json's
own list and says so). Proved live that the stale-settings test fails
if its exit-code branch is broken, then restored it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
…avior

Add a README section for --check-settings (verdicts, exit codes) next
to --validate-config, and document that --validate-config probes the
store's own registry once reachable, falling back to darling.json's
seed list only when the store cannot be reached.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
DarlingCliCommandsHostCheckTests reaches the shared DARLING_TEST_PG
store directly and through ScratchPostgres but was not serialized
against the other live-postgres classes; add the collection attribute.

TsqlConventionGuardTests' member-range census now sees
DarlingStoreHostProfile.ComputeEffectiveMemoryLimitBytes stop short
(an expression-bodied one-liner, the same shape as the other entries
in the list) - add it to KnownTruncatedRanges per the test's own
instructions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
…profile log

SerialLoopStoreSizeSourceTests failed on this branch because
DarlingStoreHostProfile.StoreSizeSql adds a fourth pg_database_size call site the
pin's census did not know about. It is a real fourth regime, not a false positive:
--check-settings's own CliStoreReadSeconds budget, one-shot, never on the serial
loop. Documented and added to the pin's allow-list rather than weakened.

Also builds the startup logging item (ruling 9): DarlingStoreHostProfile.GatherStartupProfileAsync
gathers only the cheap parts (host facts, pg_settings, conf files) and never reaches
GatherStoreFactsAsync, so the store-size/chunk-total reads stay out of the collection
loop's startup path entirely. DarlingWorker.LogStoreHostProfileAsync calls it once,
ahead of the collection loop's while, under its own short deadline
(ServiceCommandDeadlines.StartupHostProfileSeconds), logs the host/settings profile,
warns once per stale-after-hardware-change setting, and never throws.

Pinned with StartupHostProfileLogTests.cs (log-line shape via a fixture whose Store
is deliberately filled with sentinel values that must never print; the failure path
and the store-facts exclusion via source scans of DarlingWorker.cs and
DarlingStoreHostProfile.cs; the call site's position ahead of the collection loop)
and a new live test proving GatherStartupProfileAsync never gathers store facts even
against a real running server.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
LiveCleanupConversionRatchetTests: 6 finally blocks in
DarlingCliCommandsHostCheckTests used root.Delete(recursive: true)
(a DirectoryInfo instance call), which contains neither File. nor
Directory. and was flagged as store-teardown. Switch all 7 instances
to Directory.Delete(root.FullName, recursive: true) so the static
call carries the Directory. exclusion token the ratchet requires.

DarlingMissingCredentialMessageTests: PR #4271 added a new verb
that calls MissingStoreCredentialMessage, raising the count from 6
to 7. Update the Assert.Equal count to match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVjn4PBJN71NQXdFo6ZxNQ
@erikdarlingdata

Copy link
Copy Markdown
Owner Author

Fix report

Two CI failures fixed in commit 9f6f98e, pushed to feature/4214-store-host-check.

Failure 1: LiveCleanupConversionRatchetTests (6 offenders)

Diagnosis confirmed. DarlingCliCommandsHostCheckTests.cs used root.Delete(recursive: true) (a DirectoryInfo instance method) in 7 finally blocks. The ratchet excludes only blocks containing File., Directory., or .Kill(. The instance call contains none of these tokens, so all 6 blocks that lacked any other exclusion were flagged.

Fix: changed all 7 instances to Directory.Delete(root.FullName, recursive: true). The static call contains Directory., which satisfies the ratchet's file-teardown exclusion. The 7th instance (in the CheckSettingsAsync_ManagedStoreWithAStaleBlock finally) already had a File.WriteAllText call, so it was not an offender — changing it to the static form is consistent but cosmetic for that block.

Result: LiveCleanupConversionRatchetTests — Total: 15, Failed: 0.

Failure 2: DarlingMissingCredentialMessageTests — Assert.Equal(6) → 7

Diagnosis confirmed. The test NoVerbStillCarriesItsOwnFirstRunAdvice counts calls to DarlingStoreBootstrapEvidence.MissingStoreCredentialMessage( in the CLI source. The new verb added by #4214 added a seventh call site, raising the actual count to 7.

Fix: updated the expected value from 6 to 7 at DarlingCliCommandsTests.cs:1667.

Result: DarlingMissingCredentialMessageTests — Total: 16, Failed: 0.

Build

dotnet build Darling/Darling.Tests/Darling.Tests.csproj -c Debug — 0 Error(s), 0 Warning(s).

@erikdarlingdata
erikdarlingdata marked this pull request as ready for review September 25, 2026 11:27
@erikdarlingdata
erikdarlingdata merged commit 88e69c8 into dev Sep 25, 2026
15 of 16 checks passed
@erikdarlingdata
erikdarlingdata deleted the feature/4214-store-host-check branch September 25, 2026 11:27
erikdarlingdata added a commit that referenced this pull request Sep 25, 2026
* Add get_store_host MCP read for #4214 part 2 (checkpoint)

get_store_host reuses part 1's DarlingStoreHostProfile.GatherAsync (#4271) to
serve the host/store/settings profile over MCP and /api/read, with its own
enclosing deadline (store-size reads scale with the store, #3199) and a fixed
#4271 CLI test that assumed a bootstrapped rig's own postgresql.conf and
"darling" database rather than building its own.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3

* #4214: bump README/llms.txt Darling tool census 159 -> 160

get_store_host is the 160th Darling MCP tool; DarlingMcpInstructions.cs
already carried this bump but README.md and llms.txt did not, failing
CrossAppMcpToolInventoryPinTests.RootReadmeDarlingToolCensus_MatchesTheScannedInventory
and LlmsTxtToolCensus_SpansTheTwoCurrentEditions in CI.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3

* #4214: live test proves get_store_host under the mcp/viewer least-privilege roles

DarlingMcpStoreHostToolsLiveTests calls DarlingMcpStoreHostTools.GetStoreHost
through the SAME least-privilege roles it runs under in production (mcp for
the MCP host, viewer for the web /api/read row), not the rig superuser every
other #4214 part 2 test uses. Proves all four verdicts (matches,
stale_after_hardware_change, operator_override, not_managed) are reachable
under each role:

- work_mem: a per-role ALTER ROLE ... SET default applied before the role's
  first connection, matched against this host's own DeriveMemorySettings
  figure -> matches (shared_buffers/max_connections are PGC_POSTMASTER and
  the live rig would need a restart to move them, so work_mem, PGC_USERSET,
  is the one setting a live value can be set for without touching the rig's
  real conf or going through postgresql.auto.conf, which would misattribute
  to operator_override).
- max_connections: a managed-block assignment of 100 against the
  TargetMaxConnections=200 constant -> stale_after_hardware_change.
- maintenance_work_mem: left unassigned in the fake managed conf ->
  operator_override (ClassifyVerdict's default arm).
- A second gather with Managed=false -> not_managed for every setting.

No permission gap found: pg_extension/pg_stat_database/pg_settings carry no
Darling-authored grant anywhere in DarlingManagedRoles, and this proves
PostgreSQL's own PUBLIC defaults are what let mcp/viewer read them; the one
grant that matters (SELECT on collect, for timescaledb_information.chunks
visibility) already exists in production provisioning and is mirrored here.

Ran twice against the rig-th rig (port 55983): 1 total, 0 failed both times;
confirmed host_mcp_test/host_viewer_test roles are dropped after each run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3

* #4214: get_store_host tools/list + response-size budget pins

McpToolsListBudget/DarlingMcpStoreHostTools.txt pins the new tool's
served block (510 chars) and McpToolsListBudgetTests.TotalCeilingBytes
moves 173,157 -> 173,773 (+616, the tool's own JSON scaffolding
included) to match: get_store_host has no parameters, so this is the
tool's whole entry.

DarlingMcpStoreHostBudgetLiveTests proves the response-size half:
get_store_host's payload is a fixed shape (host/store facts plus one
row per sizing-relevant setting, 8 today), not row-scaling like the
other #4198 tools, so one live call in the not_managed shape (the
longer of the two source strings) is the whole proof. Passed at well
under McpResponseBudget.DefaultBytes (32 KB) against the rig-th rig.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3

* #4214: web Store host panel on the Sweeps page

Adds a "Store host" section to the Fleet Sweeps page, below Watch
items: reads get_store_host through /api/read (readTool, the page's
existing read pattern - no raw fetch, no second copy of the verdict
math #4214 part 1 already owns) and renders host facts (platform,
CPUs, RAM, data volume, managed flag, PostgreSQL/TimescaleDB facts)
plus a per-setting table (current/derived/source/verdict), with every
stale-* verdict highlighted using the page's existing sev-Warning/
sev-Healthy vocabulary. Read-only, like the rest of this page (no
apiSend call) - the CLI --check-settings verb and the companion
sizing issue own any write.

Uses the page's own local table()/cellText() helpers (no new table
implementation) and four new util.js imports (fmtMb, fmtPct, fmtBool,
fmtText) already used elsewhere in the app.

FleetSweepWebFeedTests gains a source pin (no JS runner in this
pipeline) proving the section title, the get_store_host read call,
and the startsWith("stale") highlight predicate. Verified sev-Warning/
sev-Healthy are real table.data td rules in app.css, and node -c
confirms the edited file parses. FleetSweepWebFeedTests (59) and
DarlingWebAssetsTests all pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3

* #4214: fix two full-suite regressions from the get_store_host change

Full suite (13,992 tests) surfaced two defects caused by this branch:

- DarlingMcpStoreHostBudgetLiveTests reached the shared DARLING_TEST_PG
  store without [Collection("live-postgres")], failing
  LivePostgresCollectionHygieneTests. Added the attribute, matching
  its sibling DarlingMcpStoreHostToolsLiveTests.
- DarlingMcpFleetSweepToolsTests.TheMirrorsLoggerSeat_... pinned the
  old BuildReadDispatch(logger) call-site text; BuildReadDispatch now
  also threads postgresConfig (get_store_host's config seat) per this
  PR's earlier commit, so the literal moved to
  BuildReadDispatch(logger, postgresConfig) - the same kind of pin
  update already made for FleetSweepWebFeedTests/SharedBaselineCacheTests,
  missed for this third site.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3

* #4214 round-1 fix item 3: trim PostgresConfig at the MCP/web DI seat

Round-1 review Low 3: the full PostgresConfig (owner connection string
included) was injectable via DI at both the MCP and web host wiring, even
though get_store_host's GatherAsync only ever reads Managed/DataDirectory
off it. Register a trimmed copy at both call sites instead, so a future
[McpServerTool] that takes a PostgresConfig parameter cannot receive the
owner secret through this seat.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3

* #4214 round-1 fix item 7: get_store_host description tail fixes

Round-1 review: three fixes to the description tail (strictly after
<<GUIDE>>, so the served tools/list head is unchanged - confirmed by
McpToolsListBudgetTests, 5/5 green with no fixture edit needed).

- data_volume_note -> data_volume.note (the payload key is nested under
  data_volume, not a flat data_volume_note).
- Add the missing "ready" field to the data_volume parenthetical list.
- Document gathered_at (UTC) and the 5-minute shared cache ahead of item
  2 landing the field/cache itself, per the brief's item ordering.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3

* #4214 round-1 fix item 1: redact conf file paths in get_store_host's settings.source

Round-1 review Medium 1: get_store_host's settings[].source printed a full
local filesystem path for a managed block or operator override, fine on
the CLI's own shell but not fine handed to a remote MCP/web caller.

- HostSettingProfile gains SourceFile/SourceLine (default null/0), set
  only by the managed-attribution branch of GatherSettingProfilesAsync.
  SourceDescription itself is untouched, so --check-settings and the
  startup log line still print the full path as before.
- New DarlingStoreHostProfile.FormatSourceForMcp: passes SourceDescription
  through verbatim when there is no SourceFile (not-managed/unreadable -
  already a kind word, never a path), otherwise renders "<kind> (<path>:
  <line>)" with the path redacted to a data-directory-relative path when
  inside the managed data directory, or the bare file name only when
  outside it (never a leading ../ that would still leak the parent tree).
- get_store_host resolves the managed data directory once and reuses it
  for every settings row via the new formatter, in place of the raw
  SourceDescription.

Tests: 3 new pure-function tests on FormatSourceForMcp (inside data
directory, outside it, no SourceFile) plus the existing DarlingStoreHost-
ProfileTests/StartupHostProfileLogTests/McpPayloadContractCensusTests/
McpToolsListBudgetTests classes, 124/124 passing. Revert-proved: gutting
FormatSourceForMcp to return SourceDescription unconditionally failed the
two new sanitization tests; restored and re-verified green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3

* #4214 round-1 fix item 4: DI-seat census + UNC refusal for the managed data directory

Round-1 review Low 4, two independent halves.

Census: a new McpServiceParameterDiSeatCensusTests pins that every
DI-service-typed [McpServerTool] parameter (NpgsqlDataSource, PostgresConfig,
DarlingAnalysisService, ILogger today) has an AddSingleton<T>/AddSingleton(new T
seat in DarlingMcpHostService.cs's own source text. Without one, the SDK would
serve that parameter as a client argument instead of resolving it from DI - for
PostgresConfig, a remote caller could then set managed:true with a UNC
dataDirectory. Coupled to item 3 by design: before item 3 trimmed the
registration, AddSingleton(config.Postgres) did not name PostgresConfig in
source text, so this test only started passing once item 3 landed (confirmed
by reverting item 3's line, below).

UNC refusal: new DarlingStoreHostProfile.TryResolveProfileDataDirectory wraps
DarlingManagedPostgres.ResolveDataDirectory and refuses (returns null) a
UNC-resolved path, mirroring DarlingManagedPostgres.TryResolveConfPath's own
UNC refusal for an include directive - without it, a UNC-configured managed
store would make the service open a conf file, or stat a volume, on a remote
share as its own account (an NTLM-relay vector). Does not change
ResolveDataDirectory itself, shared by the writer/provisioning path. Wired at
both call sites: GatherSettingProfilesAsync's dataDirectory local (falls
through to the same not-managed path a bring-your-own store already takes -
never opens a file), and ResolveVolumeAnchor's managed arm (falls back to
AppContext.BaseDirectory, same as its existing BYO branch).

Tests: McpServiceParameterDiSeatCensusTests (new) plus 3 new pure-function
tests on TryResolveProfileDataDirectory/ResolveVolumeAnchor. 135/135 passing
(4 skipped, live-gated, no rig this pass). Revert-proved twice: reverting
item 3's AddSingleton line failed the new census test; separately, gutting
TryResolveProfileDataDirectory's UNC check failed both new UNC tests. Both
restored and re-verified green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3

* #4214 round-1 fix item 6: random per-run password for the live test roles

Round-1 review Low 6: DarlingMcpStoreHostToolsLiveTests.cs's two LOGIN test
roles (each granted SELECT on every table in collect) used a hardcoded
password that is public in this repository. Cleanup runs in finally, but a
cleanup failure after a failed body is swallowed, and a killed run skips
cleanup outright, so a role that survives on a reachable rig would be a
known, reusable credential. Generate the password fresh per run instead
(Convert.ToHexString(RandomNumberGenerator.GetBytes(16)) - hex output needs
no escaping in the DDL string). Scope is this file only;
DarlingSecuritySplitLiveTests uses the same pattern but is out of scope here.

Build only (this is a live-gated test class, no rig this pass): 0 warnings,
0 errors; the class's own live Fact still skips cleanly without
DARLING_TEST_PG set.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3

* #4214 round-1 fix item 1 follow-up: mcpDataDirectory agrees with the UNC refusal

GetStoreHost's mcpDataDirectory local called the raw DarlingManagedPostgres.ResolveDataDirectory,
while GatherSettingProfilesAsync (Low 4) now refuses a UNC-resolved data directory via
TryResolveProfileDataDirectory. The doc comment on mcpDataDirectory claimed it was "the SAME data
directory GatherSettingProfilesAsync resolved" - no longer true under a UNC-configured managed
store. Harmless today (every setting's SourceFile is null in that case, so FormatSourceForMcp never
reaches the sanitizer), but switches mcpDataDirectory to the same TryResolveProfileDataDirectory
call so the comment is accurate again and there is no divergent path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3

* #4214 round-1 fix item 2: get_store_host's 5-minute shared cache

New StoreHostProfileCache (Darling/PerformanceMonitor.Darling.Service/StoreHostProfileCache.cs):
a single immutable CacheEntry behind one volatile reference field (not a bare tuple field, which
can tear under concurrent reads), a SemaphoreSlim gate for single-flight gather, and an injected
clock. A cache hit never calls the gather delegate at all, so it never opens a connection. A failed
gather is never cached: the finally always releases the gate, and _entry is only assigned after
gather returns successfully.

Wiring: GetStoreHost takes a third StoreHostProfileCache parameter; postgres.OpenConnectionAsync
moved inside the gather delegate; the response gains gathered_at (naive-UTC, matching every other
"_at" timestamp on an MCP payload in this codebase per DarlingFleetReader/DarlingAgReader/
DarlingMcpConfigHistoryTools/DarlingMcpTrendTools - not the DateTimeKind.Utc the original plan
named). DarlingMcpHostService.cs registers the process-wide StoreHostProfileCache.Shared singleton
via the typed-generic AddSingleton<T> overload McpServiceParameterDiSeatCensusTests requires.
DarlingWebEndpoints.cs's direct-call dispatch passes the same Shared instance.

StoreHostProfileCache is public, not internal as the handoff's draft had it: GetStoreHost is a
public [McpServerTool] method, and a parameter type may never be less accessible than the method
(CS0051) - matches the existing PostgresConfig/DarlingAnalysisService precedent. GetOrGatherAsync
itself stays internal, since its signature carries the internal HostProfile type.

Test call sites (DarlingMcpStoreHostBudgetLiveTests.cs, DarlingMcpStoreHostToolsLiveTests.cs) each
get a fresh StoreHostProfileCache instance, never the production Shared singleton. The two calls in
DarlingMcpStoreHostToolsLiveTests.cs's role loop (managed config then BYO config against the same
data source) each need their OWN fresh cache: the cache holds one un-keyed entry, so sharing one
across those two calls would serve the first call's cached profile back for the second regardless
of which PostgresConfig was passed.

New Darling/Darling.Tests/StoreHostProfileCacheTests.cs, 4 tests: two concurrent callers gather
once (proven deterministically via a two-TCS choreography, no Task.Delay), a call inside 5 minutes
gathers nothing, a call after 5 minutes gathers again, a throwing gather is not cached. Revert-proved:
gutted TryGetFresh to always return null, confirmed both TwoConcurrentCalls_GatherOnce and
ACallInsideFiveMinutes_GathersNothing fail, restored, rebuilt 0/0, reverified all green.

McpServiceParameterDiSeatCensusTests' pinned service-parameter-type list grows from four names to
five (StoreHostProfileCache added), with the reason in the test's own comment.

Build: 0 Warning(s), 0 Error(s). Targeted run (StoreHostProfileCacheTests,
McpServiceParameterDiSeatCensusTests, McpPayloadContractCensusTests, McpToolsListBudgetTests,
DarlingStoreHostProfileTests, StartupHostProfileLogTests, DarlingCliCommandsHostCheckTests,
DarlingMcpStoreHostToolsLiveTests, DarlingMcpStoreHostBudgetLiveTests, FleetSweepWebFeedTests,
DarlingWebAssetsTests, SerialLoopStoreSizeSourceTests): 220 total, 0 failed, 6 skipped (live-gated,
no rig this pass).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3

* #4214 round-1 fix item 2 follow-up: StoreHostProfileCache implements IDisposable (CA1001)

A non-incremental rebuild surfaced CA1001: the class owns a disposable SemaphoreSlim field
(_gate) but was not itself IDisposable. Mirrors DarlingWebOidcClient's own gate disposal.
Safe for the Shared singleton despite two hosts (DarlingMcpHostService, DarlingWebEndpoints.cs's
direct-call dispatch) both holding a reference to it: both register it with the DI INSTANCE
overload (AddSingleton<StoreHostProfileCache>(Shared)), and the built-in container never disposes
an instance it did not itself construct, so no container shutdown disposes Shared out from under
the other host.

Build: 0 Warning(s), 0 Error(s) on a --no-incremental rebuild.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3

* #4214 round-1 fix item 3: sweeps.js's store host card skips re-fetching on a poll tick

app.js's route() now forwards opts (the 60s poll's { poll: true } marker) to renderSweeps, which
forwards it to renderStoreHost. renderStoreHost is split into itself (fetch/cache) and a new
renderStoreHostPayload(box, p) (pure render), with a module-level lastStoreHostPayload. A poll
tick with an already-fetched payload replays it through renderStoreHostPayload instead of calling
readTool("get_store_host", {}) again; every non-poll call (first paint, hashchange, the span
control) always fetches fresh. The profile this card reports (host RAM/CPUs/disk, PostgreSQL/
TimescaleDB versions, sizing verdicts) changes on a hardware or version change, never per-tick, so
a 60-second poll paying a fresh network round trip for it was pure waste - the tool's own 5-minute
server-side cache (item 2) already made repeat calls cheap for the STORE, but not for the browser's
own network cost.

FleetSweepWebFeedTests.TheSweepPage_IsWiredIntoTheShellAndTheRouter pinned the literal
"renderSweeps(main)" call text; updated to "renderSweeps(main, opts)" to match.

Build: 0 Warning(s), 0 Error(s) (--no-incremental). Targeted run (StoreHostProfileCacheTests,
McpServiceParameterDiSeatCensusTests, McpPayloadContractCensusTests, McpToolsListBudgetTests,
FleetSweepWebFeedTests, DarlingWebAssetsTests, SerialLoopStoreSizeSourceTests,
DarlingCliCommandsHostCheckTests): 164 total, 0 failed, 4 skipped (live-gated, no rig this pass).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3

* #4214 round-1 fix item 5: store-fact assertions in the least-privilege live test

GetStoreHost_AsMcpAndViewerRoles_SurfacesAllFourVerdicts_Live gains three assertions per role,
against the managed-config response: store.size_bytes and store.timescale_version are not null
(both come off reads PUBLIC can run with no Darling-authored GRANT, so a regression there is a
silent "unavailable" null rather than a thrown exception), and store.uncompressed_chunk_count
matches a fresh owner-side read of the exact same query (DarlingStoreHostProfile.
UncompressedChunkSizeSql, reused rather than duplicated) taken immediately after the tool call.

Written per the handoff; not run live in this pass (no rig). Build: 0 Warning(s), 0 Error(s).
The class still skips cleanly without DARLING_TEST_PG.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
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