Skip to content

Commit 67def44

Browse files
authored
fix(gate,review,ops): add glob exclusions, tier the public-comment vocabulary, and make inert config queryable (#9554, #9555, #9433) (#9556)
* fix(gate,review,ops): add glob exclusions, tier the public-comment vocabulary, and make inert config queryable (#9554, #9555, #9433) * test: cover the exclusion matcher from both import identities so shard attribution cannot miss it * test(gate): cover the presence-mode staleness checkpoint and two long-untested reject arms Brings packages/loopover-engine/src/review/screenshot-table-gate.ts and src/signals/change-guardrail.ts (and both re-export shims) to 100% statement, branch and function coverage. Three branches had no test on any identity: - evaluateScreenshotTableGate's PRESENCE-mode freshness checkpoint. Matrix mode's identical correlation was pinned by #8866's tests, but presence mode runs it through a separate return site and never saw a headSha in a test. Presence mode is the DEFAULT shape, so the miss meant one table pasted on push #1 could hold the gate green for every later push -- the exact regression the checkpoint exists to stop. Six tests: first-push checkpoint, stale-on-new-head (asserting no checkpoint is re-issued, which would launder the staleness away after one extra push), re-affirmation with fresh URLs, custom message on the stale path, no-headSha degradation, and same-head replay. - guardrailPathMatches' empty-path skip, a deliberate divergence from matchesAny's fail-safe: the boolean form matches an empty path under an over-complex glob, the structured form must not, because its output is rendered verbatim into public review text and audit metadata. - extractTableRows' non-table reject arm, which is what stops ordinary PR prose (and a shell pipe inside backticks) from parsing as table rows. Mirrored across both import identities so a sharded, flag-merged coverage upload cannot report a branch as uncovered on one copy. * fix(ci): declare @loopover/contract#build on the three typecheck tasks that transitively need it validate-code failed on this PR with 'Cannot find module @loopover/contract/tools' plus five downstream implicit-any errors in src/mcp/server.ts -- none of which this PR touches. #9530 added @loopover/contract, and src/mcp/server.ts imports @loopover/contract/tools. Three turbo typecheck tasks pull that file into their program without any build edge to the package: - @loopover/ui#typecheck: apps/loopover-ui/tsconfig.json includes $TURBO_ROOT$/worker-configuration.d.ts, which imports './src/index' -- so the ENTIRE Worker is in the UI's typecheck program (src/index.ts -> src/api/routes.ts -> src/mcp/server.ts -> @loopover/contract/tools). Confirmed with tsc --explainFiles, not inferred. apps/loopover-ui has no package.json dependency on contract, so ^build never builds it. - //#typecheck: a root task, where a root package.json dependency creates no build edge. - @loopover/ui-miner#typecheck: reaches packages/loopover-miner/lib/**, which imports the package, and miner-ui has no dependency on it either. Each already carries an explicit @loopover/engine#build edge for precisely this reason -- turbo.json's own comment there describes the same scheduling race, observed intermittently in validate-code, that bit here. Cache-dependent, which is why it looked like flakiness: turbo caches these tasks, so the failure only appears on a cache MISS. Other open PRs are green on cache hits. Verified both directions from a clean contract build state (dist/ and .tsbuildinfo both removed, --force): - with the edges: 4 tasks successful, contract built first, typecheck passes - without them: the identical six errors CI reported
1 parent 968c731 commit 67def44

14 files changed

Lines changed: 739 additions & 19 deletions

apps/loopover-ui/src/lib/selfhost-env-reference.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,14 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
285285
name: "LOOPOVER_METRICS_REPO_LABELS",
286286
firstReference: "src/server.ts",
287287
},
288+
{
289+
name: "LOOPOVER_PUBLIC_SCORE_TERMS_ALLOWED_REPOS",
290+
firstReference: "src/selfhost/inert-config.ts",
291+
},
292+
{
293+
name: "LOOPOVER_PUBLIC_STATS_REPOS",
294+
firstReference: "src/selfhost/inert-config.ts",
295+
},
288296
{
289297
name: "LOOPOVER_REPO_CONFIG_DIR",
290298
firstReference: "src/server.ts",
@@ -297,6 +305,10 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
297305
name: "LOOPOVER_REVIEW_RAG",
298306
firstReference: "src/selfhost/ai.ts",
299307
},
308+
{
309+
name: "LOOPOVER_REVIEW_SAFETY",
310+
firstReference: "src/selfhost/inert-config.ts",
311+
},
300312
{
301313
name: "LOOPOVER_SHUTDOWN_LOCK_RELEASE_AFTER_MS",
302314
firstReference: "src/server.ts",
@@ -744,9 +756,12 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [
744756
"| `LOOPOVER_ENABLE_UNSAFE_CODEX_REVIEWER` | `src/selfhost/ai.ts` |",
745757
"| `LOOPOVER_MCP_TOKEN` | `src/selfhost/preflight.ts` |",
746758
"| `LOOPOVER_METRICS_REPO_LABELS` | `src/server.ts` |",
759+
"| `LOOPOVER_PUBLIC_SCORE_TERMS_ALLOWED_REPOS` | `src/selfhost/inert-config.ts` |",
760+
"| `LOOPOVER_PUBLIC_STATS_REPOS` | `src/selfhost/inert-config.ts` |",
747761
"| `LOOPOVER_REPO_CONFIG_DIR` | `src/server.ts` |",
748762
"| `LOOPOVER_REVIEW_CONTINUOUS` | `src/queue/processors.ts` |",
749763
"| `LOOPOVER_REVIEW_RAG` | `src/selfhost/ai.ts` |",
764+
"| `LOOPOVER_REVIEW_SAFETY` | `src/selfhost/inert-config.ts` |",
750765
"| `LOOPOVER_SHUTDOWN_LOCK_RELEASE_AFTER_MS` | `src/server.ts` |",
751766
"| `LOOPOVER_SINGLE_INSTANCE` | `src/selfhost/redis-cache.ts` |",
752767
"| `LOOPOVER_VERSION` | `src/selfhost/otel.ts` |",

packages/loopover-engine/src/review/screenshot-table-gate.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { matchesAny } from "../signals/change-guardrail.js";
1+
import { matchesAnyWithExclusions } from "../signals/change-guardrail.js";
22
import type { ScreenshotTableGateAction, ScreenshotTableGateConfig } from "../types/manifest-deps-types.js";
33

44
export type { ScreenshotTableGateAction, ScreenshotTableGateConfig } from "../types/manifest-deps-types.js";
@@ -174,7 +174,12 @@ export function hasCommittedImageFile(changedFiles: string[], scopedPaths: strin
174174
return changedFiles.some((file) => {
175175
const lower = file.toLowerCase();
176176
if (!IMAGE_EXTENSIONS.some((ext) => lower.endsWith(ext))) return false;
177-
return scopedPaths.length === 0 || matchesAny(file, scopedPaths);
177+
// #9434: exclusion-aware, so an operator can scope "this directory except these generated files"
178+
// (e.g. "apps/loopover-ui/public/**" minus "!apps/loopover-ui/public/openapi.json") instead of being
179+
// forced to enumerate every safe subpath. Must stay the SAME matcher isScreenshotTableGateInScope uses
180+
// below -- both read config.whenPaths, and disagreeing on which paths count as "scoped" would make a
181+
// path excluded from gate SCOPE still count as a stray committed image, or vice versa.
182+
return scopedPaths.length === 0 || matchesAnyWithExclusions(file, scopedPaths);
178183
});
179184
}
180185

@@ -312,7 +317,8 @@ export function isScreenshotTableGateInScope(config: ScreenshotTableGateConfig,
312317
if (config.whenLabels.length === 0 && config.whenPaths.length === 0) return true;
313318
const wantedLabels = new Set(config.whenLabels.map((label) => label.toLowerCase()));
314319
const labelMatch = config.whenLabels.length > 0 && prLabels.some((label) => wantedLabels.has(label.toLowerCase()));
315-
const pathMatch = config.whenPaths.length > 0 && changedFiles.some((file) => matchesAny(file, config.whenPaths));
320+
// #9434: exclusion-aware -- see hasCommittedImageFile's own comment on why the two must share one matcher.
321+
const pathMatch = config.whenPaths.length > 0 && changedFiles.some((file) => matchesAnyWithExclusions(file, config.whenPaths));
316322
return labelMatch || pathMatch;
317323
}
318324

packages/loopover-engine/src/signals/change-guardrail.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,47 @@ export function matchesAny(path: string, globs: string[]): boolean {
116116
return globs.some((g) => hasUnsafeWildcardCount(g) || globToRegExp(g).test(canonicalPath));
117117
}
118118

119+
/**
120+
* #9434: `matchesAny` is deliberately positive-only — there is no way to say "this directory, except these
121+
* generated files" without enumerating every safe subpath by hand. That gap is exactly what let
122+
* `apps/loopover-ui/public/**` (a directory that is overwhelmingly non-visual: a generated openapi.json,
123+
* robots.txt, sitemap.xml, favicons) sit in a screenshotTableGate `whenPaths` list and auto-close 5
124+
* contributor PRs one-shot for regenerating a JSON spec file, with zero recovery — a one-shot close has none.
125+
* Confirmed the identical glob shape independently present in two sibling repos' private configs.
126+
*
127+
* A glob prefixed with `!` here is an EXCLUSION: a path counts as matched only if it hits at least one
128+
* INCLUDE glob and hits NO exclude glob. This is deliberately a SEPARATE function, not a change to
129+
* `matchesAny` itself — `matchesAny` is also the hard-guardrail matcher, where an unrecognized/malformed
130+
* glob shape failing towards "still matches" is the safety-correct default (see its own doc comment); adding
131+
* `!`-parsing there would mean a maintainer's literal path starting with `!` (rare, but not impossible) is
132+
* silently reinterpreted as an exclusion instead of guarding it. This function is opt-in for callers that
133+
* explicitly want exclusion semantics (today: the screenshotTableGate `whenPaths` scope check) and leaves
134+
* every existing `matchesAny` caller — including hardGuardrailGlobs — byte-identical.
135+
*
136+
* The INCLUDE half reuses `matchesAny` unchanged, so an over-complex include glob keeps its existing
137+
* fail-toward-matching default. The EXCLUDE half deliberately does NOT reuse `matchesAny` for this: failing
138+
* an unsafe glob toward "matches" is safe for an include (worst case, more paths are considered in scope) but
139+
* WRONG for an exclude (an over-complex exclude glob resolving to "matches everything" would silently widen
140+
* what gets excluded, shrinking a safety gate's coverage — the opposite failure direction from what the gate
141+
* exists for). The exclude half therefore compiles each glob with `globToRegExp` directly, whose own
142+
* NEVER_MATCHES fallback for an unsafe glob is exactly right here: a malformed/pathological exclude excludes
143+
* NOTHING rather than excluding everything, so gate coverage can only ever be too WIDE from a bad exclude
144+
* glob, never too narrow.
145+
*/
146+
export function matchesAnyWithExclusions(path: string, globs: string[]): boolean {
147+
const includes: string[] = [];
148+
const excludes: string[] = [];
149+
for (const glob of globs) {
150+
if (glob.startsWith("!") && glob.length > 1) excludes.push(glob.slice(1));
151+
else includes.push(glob);
152+
}
153+
if (includes.length === 0) return false;
154+
if (!matchesAny(path, includes)) return false;
155+
if (excludes.length === 0) return true;
156+
const canonicalPath = canonicalize(path);
157+
return !excludes.some((exclude) => globToRegExp(exclude).test(canonicalPath));
158+
}
159+
119160
/**
120161
* The changed paths (if any) that trip a hard guardrail. A non-empty result means the PR touches a guarded
121162
* path and MUST fall through to a human — loopover may neither auto-merge nor auto-close it. Pure.

src/queue-intelligence.ts

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,34 @@ const HIGH_ISSUE_QUALITY_THRESHOLD = 0.7;
4040
const PENDING_STALE_THRESHOLD_DAYS = 2;
4141
const MILLISECONDS_PER_DAY = 1000 * 60 * 60 * 24;
4242

43-
export const FORBIDDEN_PUBLIC_COMMENT_WORDS = [
43+
/**
44+
* #9432: ordinary English that carries gittensor meaning ONLY in context. Each of these is a word a perfectly
45+
* safe review of this codebase's own gate/scoring code uses naturally -- "updates the ranking comparator",
46+
* "this improves reviewability", "the reward path". They are still matched by default (unchanged), but a repo
47+
* that has POSITIVELY confirmed via {@link isPublicScoreTermSafeForRepo}'s allowlist that this vocabulary is
48+
* safe public language for it can exempt them, exactly as bare "score" already could.
49+
*
50+
* WHY THIS IS SAFE, and why the split is where it is: the risk this filter exists to stop is a leaked private
51+
* VALUE, and a value never appears as a bare noun -- it appears qualified ("reward estimate 12 TAO", "trust
52+
* score 0.82", "score preview"). Every one of those QUALIFIED forms lives in
53+
* {@link ALWAYS_FORBIDDEN_PUBLIC_COMMENT_WORDS} below and stays enforced for every repo, allowlisted or not.
54+
* A bare "reward" or "ranking" with no number attached leaks nothing on its own. This generalizes the exact
55+
* judgment `allowBareScoreTerm` already made for "score" to its siblings, rather than inventing a new one.
56+
*/
57+
export const AMBIGUOUS_PUBLIC_COMMENT_WORDS = [
58+
"rewards",
59+
"reward",
60+
"farming",
61+
"rankings",
62+
"ranking",
63+
"cohort",
64+
"reviewability",
65+
] as const;
66+
67+
/** Terms that name a private concept outright and are NEVER exemptible, for any repo. A leaked value is
68+
* necessarily qualified (see AMBIGUOUS_PUBLIC_COMMENT_WORDS' rationale), so every qualified form belongs
69+
* here -- this list is what actually holds the public/private boundary. */
70+
export const ALWAYS_FORBIDDEN_PUBLIC_COMMENT_WORDS = [
4471
"wallet",
4572
"hotkey",
4673
"raw trust score",
@@ -53,12 +80,8 @@ export const FORBIDDEN_PUBLIC_COMMENT_WORDS = [
5380
"reward estimate",
5481
"estimated rewards",
5582
"estimated reward",
56-
"rewards",
57-
"reward",
58-
"farming",
5983
"private reviewability",
6084
"reviewability internals",
61-
"reviewability",
6285
"private scoreability",
6386
"scoreability",
6487
"score preview",
@@ -67,15 +90,19 @@ export const FORBIDDEN_PUBLIC_COMMENT_WORDS = [
6790
"score estimate",
6891
"private rankings",
6992
"private ranking",
70-
"rankings",
71-
"ranking",
72-
"cohort",
7393
"miner-originated",
7494
"miner originated",
7595
"human-originated",
7696
"human originated",
7797
] as const;
7898

99+
/** Every forbidden term, both tiers -- the default (non-exempt) matching set. Preserved as a single exported
100+
* list so existing consumers and the redaction-parity test keep one canonical vocabulary to compare against. */
101+
export const FORBIDDEN_PUBLIC_COMMENT_WORDS = [
102+
...ALWAYS_FORBIDDEN_PUBLIC_COMMENT_WORDS,
103+
...AMBIGUOUS_PUBLIC_COMMENT_WORDS,
104+
] as const;
105+
79106
// A bare "score" is checked separately from the substring list above (not folded in as another entry):
80107
// FORBIDDEN_PUBLIC_COMMENT_WORDS is matched with a plain case-insensitive `.includes()`, and an unqualified
81108
// "score" substring also matches ordinary English words that carry no gittensor meaning at all ("underscore",
@@ -218,7 +245,13 @@ export function shouldWarnPublicScoreTermsAllowlistUnset(env: Record<string, str
218245
* positively confirmed (via a repo allowlist, never a blanket default) that "score" is safe public
219246
* vocabulary for that repo. */
220247
export function sanitizePublicComment(comment: string, options?: { allowBareScoreTerm?: boolean }): string {
221-
for (const forbiddenWord of FORBIDDEN_PUBLIC_COMMENT_WORDS) {
248+
// #9432: an allowlisted repo exempts the AMBIGUOUS tier (ordinary English -- "reward", "ranking",
249+
// "reviewability", ...) along with bare "score"; the ALWAYS_FORBIDDEN tier is checked for every repo,
250+
// allowlisted or not, because that is where every qualified private-value form lives. Same flag, same
251+
// allowlist, same positively-confirmed-per-repo requirement -- see AMBIGUOUS_PUBLIC_COMMENT_WORDS for why
252+
// the split falls where it does.
253+
const forbidden = options?.allowBareScoreTerm ? ALWAYS_FORBIDDEN_PUBLIC_COMMENT_WORDS : FORBIDDEN_PUBLIC_COMMENT_WORDS;
254+
for (const forbiddenWord of forbidden) {
222255
if (comment.toLowerCase().includes(forbiddenWord.toLowerCase())) {
223256
throw new Error(`Public comment contains forbidden word: "${forbiddenWord}"`);
224257
}

src/selfhost/inert-config.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// #9433: config-dependent fixes that ship INERT.
2+
//
3+
// Several production-behaviour fixes are gated on env vars that were never set, so the shipped code path
4+
// stayed inert and the deployment silently behaved exactly like the pre-fix build. No unit test can catch this
5+
// class: tests verify the mechanism works WHEN ENABLED, never that an operator set the variable. The confirmed
6+
// instance (#9433) — `LOOPOVER_PUBLIC_SCORE_TERMS_ALLOWED_REPOS` unset, so every AI review narrative
7+
// containing the ordinary word "score" had its whole summary silently replaced by a placeholder — sat live for
8+
// weeks with a green test suite.
9+
//
10+
// WHY THIS IS A REPORT, NOT MORE BOOT WARNINGS. The obvious fix — one `console.error` per var, mirroring
11+
// `shouldWarnRagEmbedUnavailable` — does not generalize, and following it blindly makes things worse. Verified
12+
// while writing this: `LOOPOVER_PUBLIC_STATS_REPOS` is unset on the self-host box and that is CORRECT, because
13+
// the public-stats surface is served by the Cloudflare Worker (where wrangler.jsonc sets it to four repos).
14+
// A boot warning there would fire on every self-host deployment forever, for a non-problem — the same alert
15+
// fatigue that let a genuinely broken backup alert be ignored for 8 days. A warning is only justified when
16+
// "unset" is wrong for EVERY deployment; that is rare, and each such case still earns its own dedicated
17+
// warning at its own call site (the two that exist today are correct and stay).
18+
//
19+
// What is missing is not more noise but ANSWERABILITY: an operator has no way to ask "which config-gated
20+
// behaviours are currently inert on this box?" without reading boot logs they have long since scrolled past.
21+
// This module answers exactly that question, on demand, with zero steady-state noise.
22+
//
23+
// SCOPE, deliberately narrow. This reports only vars whose unset state changes OUTPUT CORRECTNESS — review
24+
// content, gate disposition, or a published number. It does NOT report the ~100 `Default OFF` convergence
25+
// flags in env.d.ts: those are opt-in features whose absence keeps the review path byte-identical, so listing
26+
// them would bury the few entries that matter under a wall of working-as-intended noise.
27+
28+
/** One config-gated behaviour that is currently inert. */
29+
export type InertConfigEntry = {
30+
/** The env var an operator would set. */
31+
key: string;
32+
/** What stops working while it is unset — phrased as the OBSERVABLE effect, not the mechanism. */
33+
impact: string;
34+
/**
35+
* Whether an unset value is wrong for every deployment, or legitimately correct for some.
36+
*
37+
* `always-wrong` earns a boot warning too (and today's two both have one). `deployment-specific` must NOT
38+
* be warned about — it is exactly the `LOOPOVER_PUBLIC_STATS_REPOS` case above, where the same unset value
39+
* is correct on one runtime and a defect on another, and only the operator knows which they are running.
40+
*/
41+
severity: "always-wrong" | "deployment-specific";
42+
};
43+
44+
/** Reads only the vars it names, so it is safe to call with `process.env` directly. */
45+
export type InertConfigEnv = Record<string, string | undefined>;
46+
47+
function unset(value: string | undefined): boolean {
48+
return (value ?? "").trim() === "";
49+
}
50+
51+
/**
52+
* Every config-gated behaviour currently inert in `env`, in a stable order.
53+
*
54+
* PURE — no IO, no clock, no logging — so the `/metrics` gauge, a `/ready` field, and a test can all read the
55+
* identical answer rather than three hand-maintained lists drifting apart (which is the same drift class this
56+
* whole issue is about).
57+
*/
58+
export function inertConfigEntries(env: InertConfigEnv): InertConfigEntry[] {
59+
const entries: InertConfigEntry[] = [];
60+
61+
// The confirmed #9433 instance. Fail-closed by design, and the fail-closed direction is content-destroying:
62+
// sanitizePublicComment THROWS on a bare "score" match and the caller degrades to a generic placeholder, so
63+
// an unset allowlist silently strips narrative sentences on every repo. Correct for a deployment whose repos
64+
// genuinely carry private trust/reward data, hence deployment-specific rather than always-wrong.
65+
if (unset(env.LOOPOVER_PUBLIC_SCORE_TERMS_ALLOWED_REPOS)) {
66+
entries.push({
67+
key: "LOOPOVER_PUBLIC_SCORE_TERMS_ALLOWED_REPOS",
68+
impact:
69+
"AI review narratives lose any sentence using ordinary scoring vocabulary (\"score\", \"ranking\", \"reward\", \"reviewability\"), silently — the published summary looks fine, just shorter.",
70+
severity: "deployment-specific",
71+
});
72+
}
73+
74+
// Verified unset on the ORB self-host box and CORRECT there: the public-stats surface runs on the Cloudflare
75+
// Worker, whose wrangler.jsonc sets it. Reported (never warned) precisely so an operator on a runtime that
76+
// DOES serve /v1/public/stats can see that the own-ledger half is publishing zeros.
77+
if (unset(env.LOOPOVER_PUBLIC_STATS_REPOS)) {
78+
entries.push({
79+
key: "LOOPOVER_PUBLIC_STATS_REPOS",
80+
impact:
81+
"The own-ledger half of /v1/public/stats reports zero (disposition counts, reversal-grounded accuracy, weekly totals). Harmless on a runtime that does not serve public stats; silently wrong on one that does.",
82+
severity: "deployment-specific",
83+
});
84+
}
85+
86+
// Redaction is flag-gated while DETECTION is not (verified: reviewInputHasPromptInjection and its hold run
87+
// unconditionally), so an unset flag never lets a manipulated verdict through — it only means the reviewer
88+
// sees the raw injected text rather than a defanged copy. Reported because the inconclusive-finding copy
89+
// tells a reader the content "was redacted before review", which is untrue while this is off.
90+
if (unset(env.LOOPOVER_REVIEW_SAFETY)) {
91+
entries.push({
92+
key: "LOOPOVER_REVIEW_SAFETY",
93+
impact:
94+
"Prompt-injection text is still DETECTED and still holds the PR, but is not defanged before the model sees it — and the public finding claims it was redacted.",
95+
severity: "deployment-specific",
96+
});
97+
}
98+
99+
return entries;
100+
}
101+
102+
/** Stable, bounded label values for the `/metrics` gauge — the key set is fixed in code, so cardinality is
103+
* bounded by construction and an operator can alert on a specific key without a cardinality risk. */
104+
export function inertConfigGaugeSamples(env: InertConfigEnv): Array<{ labels: Record<string, string>; value: number }> {
105+
return inertConfigEntries(env).map((entry) => ({
106+
labels: { key: entry.key, severity: entry.severity },
107+
value: 1,
108+
}));
109+
}

src/selfhost/metrics.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export const DEFAULT_METRIC_META: readonly (readonly [string, MetricMeta])[] = [
5757
["loopover_backup_acknowledged", { help: "1 when SQLite backup is acknowledged or Postgres is in use; 0 when the boot backup advisory would fire.", type: "gauge" }],
5858
["loopover_config_dir_empty_acknowledged", { help: "1 when LOOPOVER_REPO_CONFIG_DIR is unset, has entries, or is acknowledged; 0 when it's configured but the mounted directory is empty.", type: "gauge" }],
5959
["loopover_http_requests_total", { help: "HTTP app requests by response status class.", type: "counter" }],
60+
["loopover_inert_config", { help: "One series per config-gated behaviour currently INERT on this instance (#9433) — labelled by env var key and whether an unset value is always wrong or deployment-specific. No series ⇒ nothing inert. See src/selfhost/inert-config.ts.", type: "gauge" }],
6061
["loopover_http_request_duration_seconds", { help: "HTTP app request duration in seconds, labelled by bounded route group (see httpRouteGroup).", type: "histogram" }],
6162
["loopover_visual_capture_total", { help: "Visual capture attempts by result -- a browserless outage is otherwise invisible while it silently degrades screenshots to dash cells, and the screenshot gate treats absent evidence as a close signal (#9487).", type: "counter" }],
6263
["loopover_webhook_dedup_total", { help: "Webhook deliveries deduplicated before enqueue.", type: "counter" }],

0 commit comments

Comments
 (0)