@rulemetric/local 0.12.2 → 0.12.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
-- A routine that looks for sessions nobody counted.
|
|
2
|
+
--
|
|
3
|
+
-- THE DEFECT CLASS. The proxy auto-bootstraps a session from LLM traffic before
|
|
4
|
+
-- any hook has run, so it has the wire id but no cwd, and the row lands with
|
|
5
|
+
-- project_path and project_id NULL. Events keep flowing. Nothing errors. But
|
|
6
|
+
-- BOTH insights consumers filter on attribution --
|
|
7
|
+
-- `regenerate_insights_jobs()` selects `where s.project_id is not null`, and
|
|
8
|
+
-- routes/insights/jobs.ts requires it too -- so the session is never counted.
|
|
9
|
+
--
|
|
10
|
+
-- The only symptom is silence: a project simply has no insights. Measured
|
|
11
|
+
-- 2026-08-26 on the cloud DB, 354 sessions / 39,193 events were orphaned this
|
|
12
|
+
-- way, one repo having lost every session it ever had across four days of work,
|
|
13
|
+
-- and it surfaced only because a human asked why that project looked empty.
|
|
14
|
+
-- `create.ts:repairAttribution` stops new sessions from orphaning; this
|
|
15
|
+
-- producer is what finds the ones already on the books, and anything a future
|
|
16
|
+
-- gap lets through.
|
|
17
|
+
--
|
|
18
|
+
-- WHY A WORKER JOB AND NOT A SQL BACKFILL. This function deliberately does no
|
|
19
|
+
-- repairing itself. Repair needs a git remote or root commit, and only 6 of
|
|
20
|
+
-- those 354 rows carried ANY git metadata -- the server has nothing to match
|
|
21
|
+
-- on. Matching on project_path alone is precisely the cwd-only fallback Phase
|
|
22
|
+
-- 12 removed for causing misassignment, and a routine running unattended every
|
|
23
|
+
-- hour must not reintroduce it. The evidence lives on the machine that ran the
|
|
24
|
+
-- session: the checkout is still on disk and `git remote get-url origin` still
|
|
25
|
+
-- answers. So the handler reads the anchors there and POSTs them to
|
|
26
|
+
-- POST /api/sessions, which routes into the same `repairAttribution` the live
|
|
27
|
+
-- hook uses -- one implementation of the match order, two callers.
|
|
28
|
+
--
|
|
29
|
+
-- SCHEDULE. Hourly at :20, off the nightly loop slots (04:30 suggestions ->
|
|
30
|
+
-- 05:00 auto-accept -> 05:30 refine -> 05:45 retire -> 06:10 autorun -> 07:30
|
|
31
|
+
-- evolution). Attribution repair is cheap, reads no LLM, and wants to happen
|
|
32
|
+
-- BEFORE the 6-hourly insights producer at 00/06/12/18 so a session repaired
|
|
33
|
+
-- this hour is counted in the next insights pass rather than waiting a day.
|
|
34
|
+
-- ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
create or replace function public.enqueue_reconcile_attribution()
|
|
37
|
+
returns integer
|
|
38
|
+
language plpgsql
|
|
39
|
+
security definer
|
|
40
|
+
set search_path = public
|
|
41
|
+
as $$
|
|
42
|
+
declare
|
|
43
|
+
queued integer;
|
|
44
|
+
begin
|
|
45
|
+
with eligible as (
|
|
46
|
+
select s.user_id
|
|
47
|
+
from public.sessions s
|
|
48
|
+
-- INVISIBLE TO INSIGHTS is wider than project_id IS NULL. This function
|
|
49
|
+
-- INNER JOINs project_canonical_checkouts, which is built from
|
|
50
|
+
-- project_checkouts -- so a project with no checkout row is skipped no
|
|
51
|
+
-- matter how well its sessions are attributed, and a project whose every
|
|
52
|
+
-- session was orphaned has never had one recorded (the insert path only
|
|
53
|
+
-- upserts on a match). Chasing only the NULL half is how the first real
|
|
54
|
+
-- reconcile pass attributed Thrakopontia and left it exactly as unable to
|
|
55
|
+
-- produce insights as before.
|
|
56
|
+
where (
|
|
57
|
+
s.project_id is null
|
|
58
|
+
or not exists (
|
|
59
|
+
select 1 from public.project_checkouts pc where pc.project_id = s.project_id
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
-- Only chase orphans a worker could plausibly still repair. A session
|
|
63
|
+
-- whose checkout was deleted years ago is permanently unrepairable, and
|
|
64
|
+
-- re-walking it every hour forever is pure noise. 90 days is well past
|
|
65
|
+
-- Claude Code's transcript retention, which is the recovery source for
|
|
66
|
+
-- the pathless shape.
|
|
67
|
+
and s.started_at > now() - interval '90 days'
|
|
68
|
+
-- Sessions with no external id cannot be addressed through
|
|
69
|
+
-- POST /api/sessions at all (it matches on external_session_id), so the
|
|
70
|
+
-- handler would only count them as unrepairable. Do not enqueue for a
|
|
71
|
+
-- user who has nothing BUT those.
|
|
72
|
+
and s.external_session_id is not null
|
|
73
|
+
-- Temp fixtures and home directories are orphaned CORRECTLY. The handler
|
|
74
|
+
-- refuses them; not enqueuing on their account keeps a guaranteed refusal
|
|
75
|
+
-- out of the hourly feed.
|
|
76
|
+
and (
|
|
77
|
+
s.project_path is null
|
|
78
|
+
or (
|
|
79
|
+
s.project_path !~ '^/(private/)?(tmp|var/folders)'
|
|
80
|
+
and s.project_path !~ '^/(Users|home)/[^/]+/?$'
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
group by s.user_id
|
|
84
|
+
), fresh as (
|
|
85
|
+
select e.user_id
|
|
86
|
+
from eligible e
|
|
87
|
+
-- One in flight at a time, per the 00083/00086 pattern.
|
|
88
|
+
where not exists (
|
|
89
|
+
select 1 from public.agent_jobs j
|
|
90
|
+
where j.task_kind = 'cron_reconcile_attribution'
|
|
91
|
+
and j.status in ('pending', 'claimed', 'running')
|
|
92
|
+
and j.user_id = e.user_id
|
|
93
|
+
)
|
|
94
|
+
-- Do not re-run within the hour for a user whose last pass just completed.
|
|
95
|
+
-- The orphan set only changes when new sessions land or a checkout appears.
|
|
96
|
+
and not exists (
|
|
97
|
+
select 1 from public.agent_jobs j
|
|
98
|
+
where j.task_kind = 'cron_reconcile_attribution'
|
|
99
|
+
and j.user_id = e.user_id
|
|
100
|
+
and j.status = 'completed'
|
|
101
|
+
and j.completed_at > now() - interval '50 minutes'
|
|
102
|
+
)
|
|
103
|
+
), ins as (
|
|
104
|
+
insert into agent_jobs (user_id, task_kind, status, payload, dedupe_key)
|
|
105
|
+
select f.user_id,
|
|
106
|
+
'cron_reconcile_attribution',
|
|
107
|
+
'pending',
|
|
108
|
+
jsonb_build_object('limit', 200),
|
|
109
|
+
'reconcile-attribution:' || f.user_id
|
|
110
|
+
from fresh f
|
|
111
|
+
returning 1
|
|
112
|
+
)
|
|
113
|
+
select count(*)::int into queued from ins;
|
|
114
|
+
|
|
115
|
+
return queued;
|
|
116
|
+
end;
|
|
117
|
+
$$;
|
|
118
|
+
|
|
119
|
+
comment on function public.enqueue_reconcile_attribution() is
|
|
120
|
+
'Hourly producer for cron_reconcile_attribution (:20). Enqueues one run per user holding sessions with project_id NULL that a worker could still repair (under 90 days old, has an external_session_id, not a temp/home path). Does NOT repair anything itself: attribution needs git anchors that live on the machine that ran the session, and matching on project_path alone is the cwd-only fallback Phase 12 removed. The handler reads those anchors from disk and POSTs them to POST /api/sessions, where repairAttribution performs the write.';
|
|
121
|
+
|
|
122
|
+
-- Observability: the residual count, so a rising floor of unrepairable orphans
|
|
123
|
+
-- is visible instead of being smoothed away. `repairable_recent` is what the
|
|
124
|
+
-- producer actually chases; `total` is every orphan on the books.
|
|
125
|
+
create or replace view public.session_attribution_health as
|
|
126
|
+
select
|
|
127
|
+
s.user_id,
|
|
128
|
+
count(*)::int as total_unattributed,
|
|
129
|
+
count(*) filter (
|
|
130
|
+
where s.started_at > now() - interval '90 days'
|
|
131
|
+
and s.external_session_id is not null
|
|
132
|
+
and (
|
|
133
|
+
s.project_path is null
|
|
134
|
+
or (
|
|
135
|
+
s.project_path !~ '^/(private/)?(tmp|var/folders)'
|
|
136
|
+
and s.project_path !~ '^/(Users|home)/[^/]+/?$'
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
)::int as repairable_recent,
|
|
140
|
+
sum(s.event_count)::bigint as orphaned_events,
|
|
141
|
+
max(s.started_at) as newest_orphan
|
|
142
|
+
from public.sessions s
|
|
143
|
+
where s.project_id is null
|
|
144
|
+
or not exists (
|
|
145
|
+
select 1 from public.project_checkouts pc where pc.project_id = s.project_id
|
|
146
|
+
)
|
|
147
|
+
group by s.user_id;
|
|
148
|
+
|
|
149
|
+
comment on view public.session_attribution_health is
|
|
150
|
+
'Per-user count of sessions invisible to the insights pipeline (project_id IS NULL). repairable_recent is the subset cron_reconcile_attribution will attempt; a total that keeps climbing while repairable_recent stays near zero means the orphans are structurally unrepairable, not that the reconciler is failing.';
|
|
151
|
+
|
|
152
|
+
select cron.schedule(
|
|
153
|
+
'enqueue_reconcile_attribution',
|
|
154
|
+
'20 * * * *',
|
|
155
|
+
$$select public.enqueue_reconcile_attribution();$$
|
|
156
|
+
);
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
-- Repair the two data shapes that leave a correctly-attributed project unable
|
|
2
|
+
-- to produce insights.
|
|
3
|
+
--
|
|
4
|
+
-- Both are consequences of the same root cause fixed in code on 2026-08-26: the
|
|
5
|
+
-- subdir-consolidation normalizer in POST /sessions collapsed any path under a
|
|
6
|
+
-- known session path to that parent, treated `/Users/<user>` as a valid parent,
|
|
7
|
+
-- and picked an ARBITRARY first match. So sessions landed with the HOME
|
|
8
|
+
-- DIRECTORY as their project_path, and `upsertProjectCheckout` (rightly)
|
|
9
|
+
-- refuses to record a home dir as a checkout. The project_id was correct the
|
|
10
|
+
-- whole time -- it comes from the git remote at capture -- but with no row in
|
|
11
|
+
-- project_checkouts, `project_canonical_checkouts` is empty for that project
|
|
12
|
+
-- and `regenerate_insights_jobs()` INNER JOINs that view. Attributed, and
|
|
13
|
+
-- invisible.
|
|
14
|
+
--
|
|
15
|
+
-- Measured on prod 2026-08-26:
|
|
16
|
+
-- * kc-content-workspace: 191 sessions, 181 of them at `/Users/kylechalmers`,
|
|
17
|
+
-- ZERO checkouts. Same for kc-ops-workspace (41 sessions, 28 at home).
|
|
18
|
+
-- This is also what 00205's canonical-checkout gate blocks: that user's
|
|
19
|
+
-- auto-accept lane is dead until his checkouts are registered. Registering
|
|
20
|
+
-- them is the fix; widening the gate is not.
|
|
21
|
+
-- * context-engineering-intro exists TWICE -- one row with a git remote and
|
|
22
|
+
-- root commit (4 sessions, no checkout) and one anchorless legacy row (no
|
|
23
|
+
-- remote, no root commit, 1 session) that HOLDS the checkout for the path.
|
|
24
|
+
-- Because project_checkouts has a GLOBAL unique index on `path` while the
|
|
25
|
+
-- upsert conflicts on the (project_id, path) primary key, the real project
|
|
26
|
+
-- could never record its own checkout: 23505, swallowed, forever.
|
|
27
|
+
--
|
|
28
|
+
-- Both halves are written as idempotent FUNCTIONS rather than one-shot DO
|
|
29
|
+
-- blocks so they can be tested, re-run, and called again if the shape recurs.
|
|
30
|
+
-- ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
-- ── 1. Merge anchorless duplicate project rows ──────────────────────────────
|
|
33
|
+
--
|
|
34
|
+
-- A project row with NEITHER a git_remote_url NOR a git_root_commit has no
|
|
35
|
+
-- identity anchor at all -- it predates git-context-as-identity and can never
|
|
36
|
+
-- be matched again by capture. When such a row shares a name with a project
|
|
37
|
+
-- that DOES have an anchor, it is the same repo, and everything hanging off it
|
|
38
|
+
-- belongs to the anchored row.
|
|
39
|
+
--
|
|
40
|
+
-- STRICT: only merges when exactly ONE candidate keeper exists for the name.
|
|
41
|
+
-- Two anchored rows for one name is a different problem (a real fork, or a
|
|
42
|
+
-- rename) and must not be resolved by guessing.
|
|
43
|
+
create or replace function public.merge_anchorless_duplicate_projects()
|
|
44
|
+
returns integer
|
|
45
|
+
language plpgsql
|
|
46
|
+
security definer
|
|
47
|
+
set search_path = public
|
|
48
|
+
as $$
|
|
49
|
+
declare
|
|
50
|
+
merged integer := 0;
|
|
51
|
+
r record;
|
|
52
|
+
begin
|
|
53
|
+
for r in
|
|
54
|
+
select dud.id as dud_id, keeper.id as keeper_id
|
|
55
|
+
from public.projects dud
|
|
56
|
+
join lateral (
|
|
57
|
+
select k.id
|
|
58
|
+
from public.projects k
|
|
59
|
+
where k.name = dud.name
|
|
60
|
+
and k.id <> dud.id
|
|
61
|
+
and (k.git_remote_url is not null or k.git_root_commit is not null)
|
|
62
|
+
limit 2
|
|
63
|
+
) keeper on true
|
|
64
|
+
where dud.git_remote_url is null
|
|
65
|
+
and dud.git_root_commit is null
|
|
66
|
+
-- Exactly one keeper, or we are guessing.
|
|
67
|
+
and (
|
|
68
|
+
select count(*) from public.projects k2
|
|
69
|
+
where k2.name = dud.name and k2.id <> dud.id
|
|
70
|
+
and (k2.git_remote_url is not null or k2.git_root_commit is not null)
|
|
71
|
+
) = 1
|
|
72
|
+
loop
|
|
73
|
+
-- project_checkouts carries a GLOBAL unique index on `path`, so a straight
|
|
74
|
+
-- repoint collides whenever the keeper already knows the path. Drop the
|
|
75
|
+
-- dud's row in that case; otherwise move it.
|
|
76
|
+
delete from public.project_checkouts c
|
|
77
|
+
where c.project_id = r.dud_id
|
|
78
|
+
and exists (
|
|
79
|
+
select 1 from public.project_checkouts k
|
|
80
|
+
where k.path = c.path and k.project_id = r.keeper_id
|
|
81
|
+
);
|
|
82
|
+
update public.project_checkouts set project_id = r.keeper_id where project_id = r.dud_id;
|
|
83
|
+
|
|
84
|
+
update public.sessions set project_id = r.keeper_id where project_id = r.dud_id;
|
|
85
|
+
update public.insights_jobs set project_id = r.keeper_id where project_id = r.dud_id;
|
|
86
|
+
update public.instruction_suggestions set project_id = r.keeper_id where project_id = r.dud_id;
|
|
87
|
+
update public.agent_runs set project_id = r.keeper_id where project_id = r.dud_id;
|
|
88
|
+
update public.treatment_assignments set project_id = r.keeper_id where project_id = r.dud_id;
|
|
89
|
+
update public.memories set project_id = r.keeper_id where project_id = r.dud_id;
|
|
90
|
+
update public.pending_treatment_exposures set project_id = r.keeper_id where project_id = r.dud_id;
|
|
91
|
+
update public.recommendation_applications set project_id = r.keeper_id where project_id = r.dud_id;
|
|
92
|
+
|
|
93
|
+
-- project_instructions is UNIQUE on (project_id, instruction_id) and
|
|
94
|
+
-- CASCADE-deletes, so drop links the keeper already holds before moving.
|
|
95
|
+
delete from public.project_instructions pi
|
|
96
|
+
where pi.project_id = r.dud_id
|
|
97
|
+
and exists (
|
|
98
|
+
select 1 from public.project_instructions k
|
|
99
|
+
where k.project_id = r.keeper_id and k.instruction_id = pi.instruction_id
|
|
100
|
+
);
|
|
101
|
+
update public.project_instructions set project_id = r.keeper_id where project_id = r.dud_id;
|
|
102
|
+
|
|
103
|
+
delete from public.projects where id = r.dud_id;
|
|
104
|
+
merged := merged + 1;
|
|
105
|
+
end loop;
|
|
106
|
+
|
|
107
|
+
return merged;
|
|
108
|
+
end;
|
|
109
|
+
$$;
|
|
110
|
+
|
|
111
|
+
comment on function public.merge_anchorless_duplicate_projects() is
|
|
112
|
+
'Merges a projects row that has NEITHER git_remote_url NOR git_root_commit into the single same-named row that does have an anchor, repointing every referencing table first. Such a row predates git-context-as-identity and can never be matched by capture again, but it can still HOLD a project_checkouts row that the real project then cannot record (project_checkouts has a global unique index on path while the upsert conflicts on the (project_id, path) PK) -- which is how context-engineering-intro had two rows, one owning the path and the other owning the sessions. Refuses to act when more than one anchored candidate shares the name.';
|
|
113
|
+
|
|
114
|
+
-- ── 2. Register the checkout an attributed project already demonstrates ─────
|
|
115
|
+
--
|
|
116
|
+
-- NOT attribution inference. `sessions.project_id` is already set here, decided
|
|
117
|
+
-- at capture time by a git remote or root commit; this only records WHICH
|
|
118
|
+
-- DIRECTORY those already-attributed sessions ran in. That distinction is why
|
|
119
|
+
-- this is safe and why the cwd-only fallback removed in Phase 12 is not being
|
|
120
|
+
-- reintroduced: nothing here decides which project a session belongs to.
|
|
121
|
+
--
|
|
122
|
+
-- Refuses: home directories and temp paths (never checkouts), worktree paths
|
|
123
|
+
-- (a worktree is not the canonical root -- 00186/00201 exist because electing
|
|
124
|
+
-- one aims the whole loop at a path nobody works in), and any path already
|
|
125
|
+
-- claimed by another project (the global unique index).
|
|
126
|
+
create or replace function public.backfill_project_checkouts_from_sessions()
|
|
127
|
+
returns integer
|
|
128
|
+
language plpgsql
|
|
129
|
+
security definer
|
|
130
|
+
set search_path = public
|
|
131
|
+
as $$
|
|
132
|
+
declare
|
|
133
|
+
inserted integer;
|
|
134
|
+
begin
|
|
135
|
+
with candidate as (
|
|
136
|
+
select
|
|
137
|
+
s.project_id,
|
|
138
|
+
s.project_path,
|
|
139
|
+
count(*) as sessions,
|
|
140
|
+
row_number() over (
|
|
141
|
+
partition by s.project_id
|
|
142
|
+
-- Most-used real path wins; shallower breaks ties, so a repo root is
|
|
143
|
+
-- preferred over a subdirectory of it.
|
|
144
|
+
order by count(*) desc, length(s.project_path) asc, s.project_path asc
|
|
145
|
+
) as rank
|
|
146
|
+
from public.sessions s
|
|
147
|
+
where s.project_id is not null
|
|
148
|
+
and s.project_path is not null
|
|
149
|
+
and s.project_path !~ '^/(Users|home)/[^/]+/?$'
|
|
150
|
+
and s.project_path !~ '^/(private/)?(tmp|var/folders)'
|
|
151
|
+
and s.project_path not like '%/.claude/worktrees/%'
|
|
152
|
+
and s.project_path not like '%/.worktrees/%'
|
|
153
|
+
-- Only projects that have NO checkout at all. Never second-guess a
|
|
154
|
+
-- project whose checkouts are already known.
|
|
155
|
+
and not exists (
|
|
156
|
+
select 1 from public.project_checkouts c where c.project_id = s.project_id
|
|
157
|
+
)
|
|
158
|
+
-- Global unique on path: skip anything another project already owns.
|
|
159
|
+
and not exists (
|
|
160
|
+
select 1 from public.project_checkouts c2 where c2.path = s.project_path
|
|
161
|
+
)
|
|
162
|
+
group by s.project_id, s.project_path
|
|
163
|
+
), ins as (
|
|
164
|
+
insert into public.project_checkouts (project_id, path, is_worktree)
|
|
165
|
+
select c.project_id, c.project_path, false
|
|
166
|
+
from candidate c
|
|
167
|
+
where c.rank = 1
|
|
168
|
+
on conflict do nothing
|
|
169
|
+
returning 1
|
|
170
|
+
)
|
|
171
|
+
select count(*)::int into inserted from ins;
|
|
172
|
+
|
|
173
|
+
return inserted;
|
|
174
|
+
end;
|
|
175
|
+
$$;
|
|
176
|
+
|
|
177
|
+
comment on function public.backfill_project_checkouts_from_sessions() is
|
|
178
|
+
'Registers one canonical checkout for each ALREADY-ATTRIBUTED project that has none, using the most-used real directory its own sessions ran in. Not attribution inference: sessions.project_id is already set from a git anchor, and this only records which directory those sessions used. Skips home dirs, temp paths, worktree paths, and any path another project already claims. Exists because the POST /sessions path normalizer used to collapse session paths onto the home directory, which upsertProjectCheckout then rightly refused to record -- leaving projects correctly attributed and still invisible to regenerate_insights_jobs(), which INNER JOINs project_canonical_checkouts.';
|
|
179
|
+
|
|
180
|
+
-- Run both once, now. Order matters: merging frees a path that the keeper can
|
|
181
|
+
-- then be given, and the backfill skips projects that already have a checkout.
|
|
182
|
+
select public.merge_anchorless_duplicate_projects();
|
|
183
|
+
select public.backfill_project_checkouts_from_sessions();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rulemetric/local",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.4",
|
|
4
4
|
"description": "RuleMetric local-mode runtime: bundled API server, web dashboard, and database migrations for fully local (zero cloud contact) deployments. Installed on demand by `rulemetric local up` — not meant to be used directly.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"postgres": "^3.4.0",
|
|
29
29
|
"resend": "^4.0.0",
|
|
30
30
|
"zod": "^3.24.0",
|
|
31
|
-
"@rulemetric/skills-registry": "0.12.
|
|
31
|
+
"@rulemetric/skills-registry": "0.12.4"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
34
|
"esbuild": "^0.25.0",
|