Skip to content

Commit f84ab1d

Browse files
authored
fix(orb): live fleet reversal + reuse telemetry for the homepage hero metrics (#8821)
* fix(orb): export, ingest, and score the superseded reversal in fleet calibration The fleet pipeline silently dropped reversal_superseded (#8166) at every hop: the self-host exporter's rev CTE only matched reverted/reopened, the collector's ingest whitelist downgraded an unknown flag to 'none', and orb_signals' CHECK constraint would have rejected the value anyway (swallowed by the best-effort insert). Since supersession is the one-shot culture's dominant real reversal shape, the fleet's published reversalRate stayed pinned at 0 and the homepage's reversal-grounded decision accuracy read a degenerate 100%. Advances #8820 (the accuracy-number half; the reuse-rate tile is a separate change). - orb-collector: rev CTE + flag mapping carry 'superseded' (priority reverted > reopened > superseded), regression-tested for the reversal-recorded-after-first-export re-export path - ingest: whitelist 'superseded' - analytics: a superseded close disconfirms closePrecision and counts toward reversalRate exactly like a reopen - migration 0176: rebuild orb_signals with the widened CHECK * feat(orb): stream live self-host reuse counters into the public AI-work-reused trend The homepage reuse-rate trend reads cache hit/miss audit events from the cloud worker's own ledger, which froze at the self-host cutover (last event 2026-06-29) — recent weekly buckets fall under the publish floor, so the hero tile renders a dash beside a decaying sparkline while the live signal (133k+ cache events) accrues unexported on the self-hosted instances. Advances #8820 (the reuse-rate half; the accuracy half is the superseded-reversal export on this same branch's sibling commit). - orb-collector: export day-bucketed hit/miss aggregates (counts only, no repos/PRs) over a 70-day rolling window on the same hourly POST; fail-safe when the ledger lacks the table - ingest: validate (strict day format, clamped non-negative counts) and upsert per (instance, day); malformed rows skipped row-by-row - public-reuse-rate-trend: fold counters from REGISTERED instances into the same weekly buckets, unconditional on the own-ledger repo allowlist (parity with the fleet-accuracy fold) - migration 0177: orb_reuse_counters * chore(db): register orb_reuse_counters as a raw-SQL-only table in the drift check
1 parent e10abf2 commit f84ab1d

11 files changed

Lines changed: 343 additions & 14 deletions
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
-- #8820: admit the successor-merge reversal (#8166's reversal_superseded) into the fleet-calibration signal.
2+
--
3+
-- The exporter/ingest whitelist now carries reversal_flag='superseded', but orb_signals' CHECK constraint
4+
-- (0060) still pins the column to ('none','reopened','reverted') — the ingest's INSERT OR REPLACE would hit
5+
-- the constraint and its best-effort catch would SILENTLY skip the row, so the fleet's published
6+
-- reversalRate stayed pinned at 0 no matter how many supersessions the instances detected. SQLite can't
7+
-- alter a CHECK, so rebuild the table with the widened constraint, preserving existing rows (they are
8+
-- continuously re-exported telemetry, but keeping them avoids a multi-day fleet-metrics blackout while
9+
-- instances re-fill).
10+
11+
CREATE TABLE orb_signals_new (
12+
id INTEGER PRIMARY KEY,
13+
instance_id TEXT NOT NULL, -- SHA256(ORB_APP_ID) prefix; one-way, no PII
14+
repo_hash TEXT NOT NULL, -- HMAC(repo, instance secret); collector can't reverse
15+
pr_hash TEXT NOT NULL, -- HMAC(repo#pr, instance secret)
16+
gate_verdict TEXT, -- the prediction: 'merge' | 'close' | 'hold'
17+
outcome TEXT NOT NULL CHECK (outcome IN ('merged', 'closed')), -- realized ground truth
18+
reversal_flag TEXT NOT NULL DEFAULT 'none' CHECK (reversal_flag IN ('none', 'reopened', 'reverted', 'superseded')),
19+
gate_reasoncode_bucket TEXT, -- low-cardinality category, bucketed at source
20+
time_to_close_ms INTEGER, -- decision -> close cycle time (nullable)
21+
decision_timestamp TEXT, -- when the gate decided
22+
outcome_timestamp TEXT, -- when the PR resolved
23+
sent_at TEXT,
24+
received_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
25+
UNIQUE (instance_id, repo_hash, pr_hash) -- dedup unit: one row per PR per instance, upserted
26+
);
27+
28+
INSERT INTO orb_signals_new (id, instance_id, repo_hash, pr_hash, gate_verdict, outcome, reversal_flag, gate_reasoncode_bucket, time_to_close_ms, decision_timestamp, outcome_timestamp, sent_at, received_at)
29+
SELECT id, instance_id, repo_hash, pr_hash, gate_verdict, outcome, reversal_flag, gate_reasoncode_bucket, time_to_close_ms, decision_timestamp, outcome_timestamp, sent_at, received_at
30+
FROM orb_signals;
31+
32+
DROP TABLE orb_signals;
33+
ALTER TABLE orb_signals_new RENAME TO orb_signals;
34+
35+
-- Recreate the indexes the rename does not carry over (same shapes as 0060).
36+
CREATE INDEX IF NOT EXISTS orb_signals_calibration ON orb_signals (instance_id, gate_verdict, outcome, reversal_flag);
37+
CREATE INDEX IF NOT EXISTS orb_signals_instance ON orb_signals (instance_id, received_at);
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
-- #8820 (reuse-rate half): live fleet source for the homepage "AI work reused" trend.
2+
--
3+
-- The public reuse-rate trend reads github_app.%cache_hit/%cache_miss audit events from THIS worker's own
4+
-- ledger — which froze at the self-host cutover (last event 2026-06-29), so the latest weekly buckets fell
5+
-- under the publish floor and the hero tile rendered a dash next to a decaying sparkline. The live signal
6+
-- (133k+ cache events and growing) accrues on the self-hosted instances; this table receives their
7+
-- day-bucketed, instance-level aggregate counters (counts only — no repos, no PRs, no content), exported on
8+
-- the same hourly tick as orb_signals and folded into the public trend for REGISTERED instances only (the
9+
-- same trust anchor computeFleetAnalytics uses).
10+
CREATE TABLE IF NOT EXISTS orb_reuse_counters (
11+
instance_id TEXT NOT NULL,
12+
day TEXT NOT NULL, -- YYYY-MM-DD (UTC)
13+
hits INTEGER NOT NULL DEFAULT 0,
14+
misses INTEGER NOT NULL DEFAULT 0,
15+
received_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
16+
PRIMARY KEY (instance_id, day) -- senders re-export a rolling window; the upsert keeps the freshest counts
17+
);

scripts/check-schema-drift.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export const RAW_SQL_ONLY_TABLES: Set<string> = new Set([
4949
"orb_instances",
5050
"orb_pr_outcomes",
5151
"orb_relay_failures",
52+
"orb_reuse_counters",
5253
"orb_signals",
5354
"orb_webhook_events",
5455
"override_audit",

src/orb/analytics.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,9 @@ export function percentile(sorted: number[], p: number): number | null {
107107
}
108108

109109
/** Fold the confusion-matrix cells for one instance into accuracy metrics (reversals count as the gate
110-
* being wrong: a reverted merge is a false positive; a reopened close is a false negative).
110+
* being wrong: a reverted merge is a false positive; a reopened OR superseded close is a false negative —
111+
* `superseded` (#8166) is the one-shot culture's dominant "bot was wrong" shape: the closed PR's work later
112+
* merged via a successor PR, so the close is disconfirmed exactly like a literal reopen).
111113
*
112114
* Exported for the federated bundle export (#1970, src/orb/federated-bundle.ts): a bundle publishes this
113115
* instance's own precision for #6481 to compare against the peer median computed here, so both sides MUST use
@@ -126,7 +128,7 @@ export function foldInstance(instanceId: string, cells: Cell[]): InstanceMetrics
126128
else mergeFalse += c.n;
127129
} else if (c.verdict === "close") {
128130
wouldClose += c.n;
129-
if (c.outcome === "closed" && c.reversal_flag !== "reopened") closeConfirmed += c.n;
131+
if (c.outcome === "closed" && c.reversal_flag !== "reopened" && c.reversal_flag !== "superseded") closeConfirmed += c.n;
130132
else closeFalse += c.n;
131133
}
132134
}

src/orb/ingest.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ const MAX_HASH_CHARS = 128;
99
const MAX_BUCKET_CHARS = 64;
1010
const MAX_VERDICT_CHARS = 32;
1111
const VALID_OUTCOMES = new Set(["merged", "closed"]);
12-
const VALID_REVERSALS = new Set(["none", "reopened", "reverted"]);
12+
const VALID_REVERSALS = new Set(["none", "reopened", "reverted", "superseded"]);
1313
const MIN_CYCLE_MS = 1_000; // <1s is implausible
1414
const MAX_CYCLE_MS = 31_536_000_000; // >1y is implausible
1515

@@ -75,6 +75,23 @@ interface OrbIngestPayload {
7575
// #4933: optional -- an older self-host build that hasn't upgraded yet simply omits this, and the
7676
// instance's stored health stays whatever it last was (or NULL/unknown on first contact).
7777
health?: { ok: boolean };
78+
// #8820: optional day-bucketed cache hit/miss aggregates for the public "AI work reused" trend (counts
79+
// only). A rolling window re-sent every tick; upserted per (instance, day). Absent from older builds.
80+
reuse_counters?: Array<{ day?: unknown; hits?: unknown; misses?: unknown }>;
81+
}
82+
83+
/** Rolling-window bound: the sender exports ~70 days (REUSE_COUNTER_WINDOW_DAYS); anything wildly larger is
84+
* a hostile payload padding the loop, not a real export. */
85+
const MAX_REUSE_COUNTER_DAYS = 400;
86+
const MAX_REUSE_COUNT = 10_000_000; // per-day per-instance ceiling — beyond this is fabrication, not telemetry
87+
const REUSE_DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
88+
89+
/** Clamp a sender-supplied per-day counter to a plausible non-negative integer; null rejects the row. */
90+
function clampReuseCount(value: unknown): number | null {
91+
if (typeof value !== "number" || !Number.isFinite(value)) return null;
92+
const rounded = Math.round(value);
93+
if (rounded < 0 || rounded > MAX_REUSE_COUNT) return null;
94+
return rounded;
7895
}
7996

8097
export type OrbIngestResult = { accepted: number } | { error: string };
@@ -191,5 +208,31 @@ export async function handleOrbIngest(body: string, db: D1Database): Promise<Orb
191208
}
192209
}
193210

211+
// #8820: day-bucketed reuse counters (optional field; older builds omit it). Every row is
212+
// whitelist-validated (strict YYYY-MM-DD day, clamped non-negative counts) and upserted on
213+
// (instance_id, day) — the sender re-exports a rolling window each tick, so REPLACE keeps the freshest
214+
// counts idempotently. Malformed rows are skipped one-by-one (same best-effort posture as events above);
215+
// a malformed CONTAINER (non-array) is ignored rather than failing the outcome batch riding alongside.
216+
const reuseCounters = (payload as OrbIngestPayload).reuse_counters;
217+
if (Array.isArray(reuseCounters)) {
218+
for (const counter of reuseCounters.slice(0, MAX_REUSE_COUNTER_DAYS)) {
219+
const day = typeof counter?.day === "string" && REUSE_DAY_PATTERN.test(counter.day) ? counter.day : null;
220+
const hits = clampReuseCount(counter?.hits);
221+
const misses = clampReuseCount(counter?.misses);
222+
if (day === null || hits === null || misses === null) continue;
223+
try {
224+
await db
225+
.prepare(
226+
`INSERT OR REPLACE INTO orb_reuse_counters (instance_id, day, hits, misses, received_at)
227+
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`,
228+
)
229+
.bind(instance_id, day, hits, misses)
230+
.run();
231+
} catch {
232+
// best-effort — a counter hiccup must never fail the outcome batch
233+
}
234+
}
235+
}
236+
194237
return { accepted };
195238
}

src/selfhost/orb-collector.ts

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
// can never de-anonymize).
1818
import { createHash, createHmac } from "node:crypto";
1919
import { generateAnonSecret, hmacAnonymize } from "../../packages/loopover-engine/src/telemetry/anonymize.js";
20+
import { AI_REVIEW_REUSE_EVENT_TYPES } from "../services/public-reuse-rate-trend";
2021
import { incr } from "./metrics";
2122

2223
/** Key under which the per-instance anonymization secret is persisted in system_flags. */
@@ -33,6 +34,7 @@ interface FleetRow {
3334
outcome_at: string;
3435
reverted: number; // 0|1
3536
reopened: number; // 0|1
37+
superseded: number; // 0|1
3638
event_at: string; // max(outcome_at, latest reversal time) — the export watermark unit
3739
}
3840

@@ -41,7 +43,7 @@ interface FleetEvent {
4143
pr_hash: string;
4244
gate_verdict: string | null;
4345
outcome: string;
44-
reversal_flag: "none" | "reopened" | "reverted";
46+
reversal_flag: "none" | "reopened" | "reverted" | "superseded";
4547
gate_reasoncode_bucket: string;
4648
time_to_close_ms: number | null;
4749
decision_timestamp: string | null;
@@ -52,6 +54,45 @@ interface OrbExportPayload {
5254
instance_id: string;
5355
events: FleetEvent[];
5456
health?: { ok: boolean };
57+
/** #8820: day-bucketed cache hit/miss aggregates for the public "AI work reused" trend. Counts only —
58+
* no repos, no PRs, no content. A rolling window re-sent every tick (the collector upserts per day),
59+
* so the field is self-healing and needs no cursor. Omitted when the window has no cache events. */
60+
reuse_counters?: Array<{ day: string; hits: number; misses: number }>;
61+
}
62+
63+
/** Rolling window the reuse counters cover — the public trend renders 8 weeks; the extra buffer keeps the
64+
* oldest visible bucket complete across week boundaries and export lag. */
65+
export const REUSE_COUNTER_WINDOW_DAYS = 70;
66+
67+
/** Day-bucketed reuse counters from the local audit_events ledger. Same event population as
68+
* loadReuseRateDayRows (public-reuse-rate-trend.ts) — the LIKE convention plus ai_review's three
69+
* non-suffix-conforming reuse variants — so the fleet fold can never drift from the own-ledger count.
70+
* substr(created_at, 1, 10) is the portable day bucket (runs on the SQLite AND Postgres backends, and
71+
* tolerates this ledger's mixed 'YYYY-MM-DD hh:mm:ss' / ISO-with-T timestamp formats). */
72+
const REUSE_COUNTER_QUERY = `
73+
SELECT substr(created_at, 1, 10) AS day,
74+
SUM(CASE WHEN event_type LIKE 'github_app.%cache_hit' OR event_type IN (${AI_REVIEW_REUSE_EVENT_TYPES.map(() => "?").join(", ")}) THEN 1 ELSE 0 END) AS hits,
75+
SUM(CASE WHEN event_type LIKE 'github_app.%cache_miss' THEN 1 ELSE 0 END) AS misses
76+
FROM audit_events
77+
WHERE (event_type LIKE 'github_app.%cache_hit' OR event_type LIKE 'github_app.%cache_miss' OR event_type IN (${AI_REVIEW_REUSE_EVENT_TYPES.map(() => "?").join(", ")}))
78+
AND created_at >= ?
79+
GROUP BY day`;
80+
81+
/** Read the rolling reuse-counter window; fail-safe → [] (a counter hiccup must never block the outcome
82+
* export riding the same tick). */
83+
async function loadReuseCounters(db: D1Database, nowMs: number): Promise<Array<{ day: string; hits: number; misses: number }>> {
84+
const sinceIso = new Date(nowMs - REUSE_COUNTER_WINDOW_DAYS * 86_400_000).toISOString();
85+
try {
86+
// The rows already carry exactly the export shape; SUM(CASE…) over a GROUP BY never yields SQL NULL,
87+
// so no per-field fallback is needed (a missing table / failed query is the catch below).
88+
const result = await db
89+
.prepare(REUSE_COUNTER_QUERY)
90+
.bind(...AI_REVIEW_REUSE_EVENT_TYPES, ...AI_REVIEW_REUSE_EVENT_TYPES, sinceIso)
91+
.all<{ day: string; hits: number; misses: number }>();
92+
return result.results;
93+
} catch {
94+
return [];
95+
}
5596
}
5697

5798
/** Stable instance identifier (hash of the Orb/App ID — no PII). A brokered instance holds no App id, so its
@@ -125,16 +166,18 @@ const FLEET_QUERY = `
125166
SELECT target_id,
126167
MAX(CASE WHEN event_type = 'reversal_reverted' THEN 1 ELSE 0 END) AS reverted,
127168
MAX(CASE WHEN event_type = 'reversal_reopened' THEN 1 ELSE 0 END) AS reopened,
169+
MAX(CASE WHEN event_type = 'reversal_superseded' THEN 1 ELSE 0 END) AS superseded,
128170
MAX(created_at) AS rev_at
129171
FROM review_audit
130-
WHERE event_type IN ('reversal_reverted', 'reversal_reopened')
172+
WHERE event_type IN ('reversal_reverted', 'reversal_reopened', 'reversal_superseded')
131173
GROUP BY target_id
132174
)
133-
SELECT project, target_id, verdict, reasoncode, decided_at, outcome, outcome_at, reverted, reopened, event_at
175+
SELECT project, target_id, verdict, reasoncode, decided_at, outcome, outcome_at, reverted, reopened, superseded, event_at
134176
FROM (
135177
SELECT gd.project AS project, gd.target_id AS target_id, gd.verdict AS verdict, gd.reasoncode AS reasoncode,
136178
gd.decided_at AS decided_at, po.outcome AS outcome, po.outcome_at AS outcome_at,
137179
COALESCE(rev.reverted, 0) AS reverted, COALESCE(rev.reopened, 0) AS reopened,
180+
COALESCE(rev.superseded, 0) AS superseded,
138181
CASE WHEN rev.rev_at IS NOT NULL AND rev.rev_at > po.outcome_at THEN rev.rev_at ELSE po.outcome_at END AS event_at
139182
FROM gd
140183
JOIN po ON gd.target_id = po.target_id
@@ -193,6 +236,10 @@ export async function exportOrbBatch(db: D1Database, batchSize = 200, fetchFn: t
193236
// otherwise, exactly as before, nothing new means nothing to do.
194237
if ((!results || results.length === 0) && healthOk === undefined) return 0;
195238

239+
// #8820: the reuse counters ride the same POST as the outcome events (same tick, same signature). Loaded
240+
// AFTER the early "nothing to send" return above, so a truly idle tick still costs nothing extra.
241+
const reuseCounters = await loadReuseCounters(db, Date.now());
242+
196243
const payload: OrbExportPayload = {
197244
instance_id: instance,
198245
/* v8 ignore next -- D1's .all() always returns a `results` array (possibly empty), never omits the field;
@@ -202,13 +249,17 @@ export async function exportOrbBatch(db: D1Database, batchSize = 200, fetchFn: t
202249
pr_hash: anonymize ? hmacAnonymize(r.target_id, secret) : r.target_id,
203250
gate_verdict: r.verdict,
204251
outcome: r.outcome,
205-
reversal_flag: r.reverted ? "reverted" : r.reopened ? "reopened" : "none",
252+
// Priority mirrors signal strength: an explicit revert PR beats a reopen beats the successor-merge
253+
// heuristic (#8166's reversal_superseded — the one-shot culture's dominant real "bot was wrong" shape,
254+
// which this export previously DROPPED entirely, silently pinning the fleet's reversalRate at 0).
255+
reversal_flag: r.reverted ? "reverted" : r.reopened ? "reopened" : r.superseded ? "superseded" : "none",
206256
gate_reasoncode_bucket: bucketReasonCode(r.reasoncode),
207257
time_to_close_ms: cycleTimeMs(r.decided_at, r.outcome_at),
208258
decision_timestamp: r.decided_at,
209259
outcome_timestamp: r.outcome_at,
210260
})),
211261
...(healthOk !== undefined ? { health: { ok: healthOk } } : {}),
262+
...(reuseCounters.length > 0 ? { reuse_counters: reuseCounters } : {}),
212263
};
213264

214265
const body = JSON.stringify(payload);

src/services/public-reuse-rate-trend.ts

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,10 @@ export const PUBLIC_REUSE_RATE_TREND_WEEKS = 8;
2626
export const MIN_REUSE_RATE_TREND_SAMPLE = 5;
2727

2828
/** ai_review reuse events that don't follow the `_cache_hit` suffix convention but are the SAME "avoided a
29-
* redundant AI call" signal -- each one means the review pass reused a prior state instead of re-running. */
30-
const AI_REVIEW_REUSE_EVENT_TYPES = ["github_app.ai_review_frozen_reuse", "github_app.ai_review_paused_reuse", "github_app.ai_review_one_shot_reuse"] as const;
29+
* redundant AI call" signal -- each one means the review pass reused a prior state instead of re-running.
30+
* Exported for the self-host reuse-counter export (orb-collector.ts, #8820) so both sides count the exact
31+
* same event population -- a drifted copy there would silently skew the published fleet rate. */
32+
export const AI_REVIEW_REUSE_EVENT_TYPES = ["github_app.ai_review_frozen_reuse", "github_app.ai_review_paused_reuse", "github_app.ai_review_one_shot_reuse"] as const;
3133

3234
export type PublicReuseRateTrendWeek = {
3335
/** UTC Monday (YYYY-MM-DD) that starts the bucket. */
@@ -104,11 +106,35 @@ async function loadReuseRateDayRows(env: Env, projects: string[], sinceIso: stri
104106
return rows.map((row) => ({ day: row.day, hits: row.hits ?? 0, misses: row.misses ?? 0 }));
105107
}
106108

109+
/** Day-bucketed reuse counters exported by REGISTERED self-hosted instances (orb_reuse_counters, #8820) --
110+
* the LIVE side of this trend: the own-ledger audit_events below froze at the self-host cutover, so recent
111+
* weeks otherwise fall under the publish floor and the homepage tile renders a dash. Registration is the
112+
* same trust anchor computeFleetAnalytics uses -- open ingest stores everyone's counters, but an
113+
* unregistered stranger can't move the published rate. Deliberately NOT scoped by the own-ledger repo
114+
* allowlist (counters are instance-level counts only -- no repos to scope by), matching the fleet-accuracy
115+
* fold's own unconditional-regardless-of-allowlist behavior in public-stats.ts. */
116+
async function loadFleetReuseDayRows(env: Env, sinceIso: string): Promise<DayRow[]> {
117+
const rows = await safeAll<{ day: string; hits: number; misses: number }>(
118+
env,
119+
`SELECT c.day AS day, SUM(c.hits) AS hits, SUM(c.misses) AS misses
120+
FROM orb_reuse_counters c
121+
JOIN orb_instances i ON i.instance_id = c.instance_id AND i.registered = 1
122+
WHERE c.day >= ?
123+
GROUP BY c.day`,
124+
sinceIso.slice(0, 10),
125+
);
126+
/* v8 ignore next -- same guard shape as loadReuseRateDayRows above: SUM over a GROUP BY day of NOT NULL
127+
* integer columns always yields a defined integer, never SQL NULL; kept for defense against a future
128+
* query-shape change. */
129+
return rows.map((row) => ({ day: row.day, hits: row.hits ?? 0, misses: row.misses ?? 0 }));
130+
}
131+
107132
/** Assemble the public reuse-rate trend from the SAME live audit_events ledger every instrumented capability
108-
* already writes to. */
133+
* already writes to, plus the registered fleet's exported day counters (#8820) -- buildPublicReuseRateTrend
134+
* sums overlapping days from both sources into the same weekly buckets. */
109135
export async function loadPublicReuseRateTrend(env: Env, nowMs: number = Date.now()): Promise<PublicReuseRateTrendWeek[]> {
110136
const projects = publicStatsProjects(env);
111137
const sinceIso = new Date(Date.parse(isoWeekStart(nowMs)) - (PUBLIC_REUSE_RATE_TREND_WEEKS - 1) * MS_PER_WEEK).toISOString();
112-
const dayRows = await loadReuseRateDayRows(env, projects, sinceIso);
113-
return buildPublicReuseRateTrend(dayRows, nowMs);
138+
const [ownRows, fleetRows] = await Promise.all([loadReuseRateDayRows(env, projects, sinceIso), loadFleetReuseDayRows(env, sinceIso)]);
139+
return buildPublicReuseRateTrend([...ownRows, ...fleetRows], nowMs);
114140
}

0 commit comments

Comments
 (0)