Storage half of #310. Split out because it is a distinct chunk — a table, a cron pass, and one
semantic rule — and because the reason for it is not the one you would guess.
Why a rollup, and why it is not a perf optimisation
For agent-defined collections a daily snapshot is the only way to get a time series at all.
The lead-finder's leads collection holds 116 records under a schema the agent defined. You can
count them now. "Leads found per day" needs either a timestamp you control on every record —
which you do not, the shape is the agent's — or a snapshot taken each day. There is no third
option, and no amount of querying the collection later recovers a history nobody recorded.
Platform-owned sources (ai_usage, agent_loop_runs, agent_events) do carry timestamps and
could be aggregated live. Rolling those up too is then a genuine perf win, but it is the secondary
reason.
Shape
CREATE TABLE agent_stats_daily (
instance_id TEXT NOT NULL,
user_id TEXT NOT NULL,
card_id TEXT NOT NULL,
day TEXT NOT NULL, -- UTC date, YYYY-MM-DD
value_json TEXT NOT NULL,
computed_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (instance_id, card_id, day)
);
- The cron is a single
* * * * * entry. Guard on the UTC day with
INSERT … WHERE NOT EXISTS on the primary key — the same single-flight shape claimDelivery
and runDueTriggers already use. No second cron entry, and overlapping minutes cannot
double-write.
- Only instances with activity that day. Bounds the work, and is what was asked for.
- Retention cap (~400 days) swept in the same pass, so the table stays bounded.
The rule that must not be got wrong
A missing day is a gap, not a zero.
If the agent did not run, there is no row. The series type is therefore:
Array<{ day: string; value: number | null }>
and null must render as a break in the line, never as 0. Reporting 0 says "you found no leads
on Tuesday" when the truth is "nothing ran on Tuesday" — a plausible value standing in for absent
information. That is the same class as #243 (an unparseable limit silently meaning drop
everything) and #252 (idle rendered over live work), both of which shipped and both of which were
only found by reading.
Worth a test asserting the two are distinguishable end to end, because it is exactly the kind of
thing that gets "simplified" to ?? 0 by a later change.
Consequences for #310's schema
Cards split into two families, declared:
| family |
read path |
example |
| trend |
agent_stats_daily |
leads per day, runs per day |
| point-in-time |
live query |
current board counts, collection size now |
Only trend cards need history, and for them the cost section of #310 largely dissolves — a keyed
read replaces an aggregate. Point-in-time cards keep the bounded-window discipline.
No backfill
History starts the day this ships. The tab should say so rather than presenting a three-day series
as though it were the whole picture — an honest empty state beats a chart that implies the agent
did nothing before Tuesday.
Verification
- Two cron ticks in the same minute produce one row, not two.
- An instance idle for a day has no row for that day, and the series carries
null — asserted
distinctly from a real zero.
- A card removed from the schema stops being rolled up; its history is retained until the cap.
- Rollup cost scales with active instances, not with total instances.
Storage half of #310. Split out because it is a distinct chunk — a table, a cron pass, and one
semantic rule — and because the reason for it is not the one you would guess.
Why a rollup, and why it is not a perf optimisation
For agent-defined collections a daily snapshot is the only way to get a time series at all.
The lead-finder's
leadscollection holds 116 records under a schema the agent defined. You cancount them now. "Leads found per day" needs either a timestamp you control on every record —
which you do not, the shape is the agent's — or a snapshot taken each day. There is no third
option, and no amount of querying the collection later recovers a history nobody recorded.
Platform-owned sources (
ai_usage,agent_loop_runs,agent_events) do carry timestamps andcould be aggregated live. Rolling those up too is then a genuine perf win, but it is the secondary
reason.
Shape
* * * * *entry. Guard on the UTC day withINSERT … WHERE NOT EXISTSon the primary key — the same single-flight shapeclaimDeliveryand
runDueTriggersalready use. No second cron entry, and overlapping minutes cannotdouble-write.
The rule that must not be got wrong
A missing day is a gap, not a zero.
If the agent did not run, there is no row. The series type is therefore:
and
nullmust render as a break in the line, never as 0. Reporting 0 says "you found no leadson Tuesday" when the truth is "nothing ran on Tuesday" — a plausible value standing in for absent
information. That is the same class as #243 (an unparseable
limitsilently meaning dropeverything) and #252 (idle rendered over live work), both of which shipped and both of which were
only found by reading.
Worth a test asserting the two are distinguishable end to end, because it is exactly the kind of
thing that gets "simplified" to
?? 0by a later change.Consequences for #310's schema
Cards split into two families, declared:
agent_stats_dailyOnly trend cards need history, and for them the cost section of #310 largely dissolves — a keyed
read replaces an aggregate. Point-in-time cards keep the bounded-window discipline.
No backfill
History starts the day this ships. The tab should say so rather than presenting a three-day series
as though it were the whole picture — an honest empty state beats a chart that implies the agent
did nothing before Tuesday.
Verification
null— asserteddistinctly from a real zero.