@rulemetric/local 0.10.0 → 0.12.0
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.
- package/dist/meta.json +2 -2
- package/dist/server.mjs +682 -496
- package/dist/supabase/migrations/00087_cleanup_test_data_fix.sql +1 -1
- package/dist/supabase/migrations/00184_auto_accept_budget_settings.sql +113 -0
- package/dist/supabase/migrations/00185_trial_throughput_settings.sql +43 -0
- package/dist/supabase/migrations/00186_canonical_checkout_prefers_live.sql +256 -0
- package/dist/supabase/migrations/00187_recommendation_applications.sql +32 -0
- package/dist/supabase/migrations/00188_training_producer_canonical_path.sql +117 -0
- package/dist/supabase/migrations/00189_limit_obs_window_index.sql +37 -0
- package/dist/supabase/migrations/00190_eval_targets_artifact_link.sql +176 -0
- package/dist/supabase/migrations/00191_recommendation_applications_effect_ledger.sql +106 -0
- package/dist/supabase/migrations/00192_cleanup_reaps_memberless_orgs.sql +142 -0
- package/dist/supabase/migrations/00193_judge_segment_pin_and_anchors.sql +367 -0
- package/dist/supabase/migrations/00194_refine_cases_producer.sql +91 -0
- package/dist/supabase/migrations/00195_skill_usage_views.sql +109 -0
- package/dist/supabase/migrations/00196_retire_unused_skills_producer.sql +102 -0
- package/dist/supabase/migrations/00197_auto_accept_producer_admits_skills.sql +115 -0
- package/dist/supabase/migrations/00198_unstale_non_catalog_suggestions.sql +51 -0
- package/dist/supabase/migrations/00199_auto_accept_runs_every_two_hours.sql +54 -0
- package/dist/supabase/migrations/00200_auto_accept_runs_hourly.sql +42 -0
- package/dist/web/assets/{docs-D_fJ3Svo.js → docs-KIeJWTb-.js} +35 -48
- package/dist/web/assets/index-CFJatBZ3.css +1 -0
- package/dist/web/assets/{index-D8PcENaI.js → index-MYof2fnF.js} +59 -59
- package/dist/web/index.html +3 -3
- package/package.json +2 -2
- package/dist/web/assets/index-B2QaicI_.css +0 -1
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
-- Give an eval target a real link to the artifact it measures.
|
|
2
|
+
--
|
|
3
|
+
-- THE STRUCTURAL DEFECT
|
|
4
|
+
--
|
|
5
|
+
-- `eval_targets` stored no reference to the thing under test: no
|
|
6
|
+
-- `instruction_id`, no project. Just a COPY of the content and a `name` string.
|
|
7
|
+
-- "Which artifact does this target measure?" was answered by parsing the name —
|
|
8
|
+
-- if it starts with '/', treat it as a file path — a convention enforced
|
|
9
|
+
-- nowhere that the actuator nonetheless depends on
|
|
10
|
+
-- (`cron-instruction-evolution.ts`: `target.name.startsWith('/') && existsSync`).
|
|
11
|
+
--
|
|
12
|
+
-- When the name is not a path, the loop still measures the target and still
|
|
13
|
+
-- "promotes" it; the promotion writes back to `eval_targets.content`, the
|
|
14
|
+
-- target's own copy, which nothing downstream reads. Measured 2026-08-15:
|
|
15
|
+
-- `TypeScript Best Practices` — a 129-character demo row seeded 2026-03-10 —
|
|
16
|
+
-- was one of only 2 autorun-enabled targets out of 251, write-enabled, and had
|
|
17
|
+
-- burned 102 eval runs producing edits no session could ever load.
|
|
18
|
+
--
|
|
19
|
+
-- The link was available and discarded. `sessions/linking.ts` auto-creates
|
|
20
|
+
-- targets from captured instructions (169 of 197 instruction targets came from
|
|
21
|
+
-- there); it SELECTs `instructions` by `inst.id` to read the content, then
|
|
22
|
+
-- stores only `inst.name`. The session — and therefore the project — is in
|
|
23
|
+
-- scope in the same function.
|
|
24
|
+
--
|
|
25
|
+
-- WHY project_path RATHER THAN project_id
|
|
26
|
+
--
|
|
27
|
+
-- The trial half of the system already models this correctly:
|
|
28
|
+
-- `instruction_experiments` carries `instruction_id` + `project_path`, and its
|
|
29
|
+
-- verdicts (`instruction_verdicts`) carry the same pair. Skills are globally
|
|
30
|
+
-- defined (scope 'public') but their EFFECTIVENESS is per-project — the same
|
|
31
|
+
-- skill helps in one repo and not another. Matching that table's shape keeps
|
|
32
|
+
-- the two measurement modes joinable on the same keys. (Modes A and B are
|
|
33
|
+
-- complementary, not redundant: a repo file cannot be withheld from real work,
|
|
34
|
+
-- so CLAUDE.md is Mode A only, while a skill can be, so it is trialled.)
|
|
35
|
+
--
|
|
36
|
+
-- Both columns are NULLABLE and nothing is backfilled. 249 of 251 targets are
|
|
37
|
+
-- not enrolled in anything; guessing their provenance from a name would be
|
|
38
|
+
-- inventing data. The gate below applies only where promotions can happen.
|
|
39
|
+
|
|
40
|
+
alter table public.eval_targets
|
|
41
|
+
add column if not exists instruction_id uuid references public.instructions(id) on delete set null,
|
|
42
|
+
add column if not exists project_path text;
|
|
43
|
+
|
|
44
|
+
comment on column public.eval_targets.instruction_id is
|
|
45
|
+
'The instruction this target measures. Null for targets whose provenance was never recorded (everything created before 00190) and for prompt-type targets captured from a session. Populated at auto-creation, where the id is already in hand.';
|
|
46
|
+
|
|
47
|
+
comment on column public.eval_targets.project_path is
|
|
48
|
+
'Project the target is measured FOR. Mirrors instruction_experiments.project_path so the eval (Mode A) and trial (Mode B) halves join on the same keys. A public skill is global; its effectiveness is not.';
|
|
49
|
+
|
|
50
|
+
create index if not exists idx_eval_targets_instruction
|
|
51
|
+
on public.eval_targets (instruction_id) where instruction_id is not null;
|
|
52
|
+
|
|
53
|
+
create index if not exists idx_eval_targets_project
|
|
54
|
+
on public.eval_targets (project_path) where project_path is not null;
|
|
55
|
+
|
|
56
|
+
-- ---------------------------------------------------------------------------
|
|
57
|
+
-- The gate, at the producer — the same place 00188 gated the training producer
|
|
58
|
+
-- on the canonical checkout, and for the same reason: enrolment is a manual
|
|
59
|
+
-- metadata toggle, so the producer is the only chokepoint every scheduled batch
|
|
60
|
+
-- must pass through.
|
|
61
|
+
--
|
|
62
|
+
-- A target may only consume the autorun budget if the loop can name what a
|
|
63
|
+
-- promotion would land on. Two ways to qualify:
|
|
64
|
+
--
|
|
65
|
+
-- (a) it carries a link (instruction_id or project_path), or
|
|
66
|
+
-- (b) it is named by an absolute path — the legacy convention, kept so the
|
|
67
|
+
-- 163 file-anchored targets that predate this migration keep working
|
|
68
|
+
-- without a backfill.
|
|
69
|
+
--
|
|
70
|
+
-- A target with neither is refused. That is exactly the demo row: no
|
|
71
|
+
-- instruction, no project, and a name that is a label rather than a path.
|
|
72
|
+
-- ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
create or replace function public.enqueue_eval_autorun()
|
|
75
|
+
returns integer
|
|
76
|
+
language plpgsql
|
|
77
|
+
security definer
|
|
78
|
+
set search_path = public
|
|
79
|
+
as $$
|
|
80
|
+
declare
|
|
81
|
+
queued integer;
|
|
82
|
+
begin
|
|
83
|
+
with eligible as (
|
|
84
|
+
select t.id, t.user_id,
|
|
85
|
+
-- Current judge for this target = most recent stamped run, mirroring
|
|
86
|
+
-- judge-calibration's own resolution (created_at desc, model desc for a
|
|
87
|
+
-- stable tie-break).
|
|
88
|
+
(
|
|
89
|
+
select r.grading->>'judge_model'
|
|
90
|
+
from eval_runs r
|
|
91
|
+
where r.eval_target_id = t.id
|
|
92
|
+
and r.grading->>'judge_model' is not null
|
|
93
|
+
order by r.created_at desc, r.grading->>'judge_model' desc
|
|
94
|
+
limit 1
|
|
95
|
+
) as current_judge
|
|
96
|
+
from eval_targets t
|
|
97
|
+
where coalesce(t.metadata->>'autoRunEnabled', 'false') = 'true'
|
|
98
|
+
-- Gate 0 (00190): the target must be anchored to something a promotion
|
|
99
|
+
-- could land on. Without this, a batch is two LLM executions plus a
|
|
100
|
+
-- judged grade spent to rewrite a row nothing reads.
|
|
101
|
+
and (
|
|
102
|
+
t.instruction_id is not null
|
|
103
|
+
or t.project_path is not null
|
|
104
|
+
or t.name like '/%'
|
|
105
|
+
)
|
|
106
|
+
-- Gate 2a: recent grading wasn't MOSTLY ungradeable (proportional, and
|
|
107
|
+
-- identical to the evolution producer's 2a since 00163).
|
|
108
|
+
and not exists (
|
|
109
|
+
select 1 from eval_runs r
|
|
110
|
+
where r.eval_target_id = t.id
|
|
111
|
+
and r.created_at > now() - interval '7 days'
|
|
112
|
+
group by r.eval_target_id
|
|
113
|
+
having count(*) >= 5
|
|
114
|
+
and count(*) filter (
|
|
115
|
+
where jsonb_typeof(r.grading->'expectations') = 'array'
|
|
116
|
+
and exists (
|
|
117
|
+
select 1 from jsonb_array_elements(r.grading->'expectations') e
|
|
118
|
+
where coalesce(e->>'errored', 'false') = 'true'
|
|
119
|
+
)
|
|
120
|
+
) * 4 >= count(*)
|
|
121
|
+
)
|
|
122
|
+
-- Gate 2b: no human anchors pointing at an ungradeable assertion.
|
|
123
|
+
and not exists (
|
|
124
|
+
select 1
|
|
125
|
+
from eval_grade_annotations a
|
|
126
|
+
join eval_runs r on r.id = a.eval_run_id
|
|
127
|
+
where a.eval_target_id = t.id
|
|
128
|
+
and jsonb_typeof(r.grading->'expectations') = 'array'
|
|
129
|
+
and coalesce(
|
|
130
|
+
r.grading->'expectations'->(a.expectation_index)->>'errored', 'false'
|
|
131
|
+
) = 'true'
|
|
132
|
+
)
|
|
133
|
+
-- Gate 2c: last week's batch wasn't mostly failures.
|
|
134
|
+
and not exists (
|
|
135
|
+
select 1 from eval_runs r
|
|
136
|
+
where r.eval_target_id = t.id
|
|
137
|
+
and r.created_at > now() - interval '7 days'
|
|
138
|
+
group by r.eval_target_id
|
|
139
|
+
having count(*) >= 5
|
|
140
|
+
and count(*) filter (where r.status = 'failed') * 4 >= count(*)
|
|
141
|
+
)
|
|
142
|
+
-- Gate 3: the target has live cases to actually run.
|
|
143
|
+
and exists (
|
|
144
|
+
select 1 from evals ev
|
|
145
|
+
where ev.eval_target_id = t.id and ev.retired_at is null
|
|
146
|
+
)
|
|
147
|
+
-- One in flight at a time, per the 00083/00086 pattern.
|
|
148
|
+
and not exists (
|
|
149
|
+
select 1 from agent_jobs j
|
|
150
|
+
where j.task_kind = 'cron_eval_autorun'
|
|
151
|
+
and j.status in ('pending', 'claimed', 'running')
|
|
152
|
+
and j.payload->>'evalTargetId' = t.id::text
|
|
153
|
+
)
|
|
154
|
+
), ins as (
|
|
155
|
+
insert into agent_jobs (user_id, task_kind, status, payload, dedupe_key)
|
|
156
|
+
select e.user_id,
|
|
157
|
+
'cron_eval_autorun',
|
|
158
|
+
'pending',
|
|
159
|
+
jsonb_build_object('evalTargetId', e.id::text, 'maxRuns', 12)
|
|
160
|
+
|| case
|
|
161
|
+
when e.current_judge is not null
|
|
162
|
+
then jsonb_build_object('graderModel', e.current_judge)
|
|
163
|
+
else '{}'::jsonb
|
|
164
|
+
end,
|
|
165
|
+
'eval-autorun:' || e.id::text
|
|
166
|
+
from eligible e
|
|
167
|
+
returning 1
|
|
168
|
+
)
|
|
169
|
+
select count(*)::int into queued from ins;
|
|
170
|
+
|
|
171
|
+
return queued;
|
|
172
|
+
end;
|
|
173
|
+
$$;
|
|
174
|
+
|
|
175
|
+
comment on function public.enqueue_eval_autorun() is
|
|
176
|
+
'Nightly producer for cron_eval_autorun. Enqueues one bounded depth batch per OPTED-IN, ANCHORED, healthy, under-powered eval target, with the grader pinned to the target''s current judge (00148, #196). Gate 0 (00190) requires instruction_id, project_path, or an absolute-path name: a target anchored to nothing can be measured and "promoted" forever while rewriting only its own content column — measured 2026-08-15, a 129-char demo row had burned 102 runs that way. Health 2a is PROPORTIONAL as of 00169. Opt in with: update eval_targets set metadata = coalesce(metadata, ''{}''::jsonb) || ''{"autoRunEnabled":true}''::jsonb where id = ...';
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
-- Turn `recommendation_applications` into the effect ledger.
|
|
2
|
+
--
|
|
3
|
+
-- 00187 shipped this table with the apply → measure → revert lifecycle, the
|
|
4
|
+
-- before/after hashes, and a CAS transition guard — and then nothing ever wrote
|
|
5
|
+
-- a row (0 in prod as of 2026-08-16). The multi-artifact actuator plan
|
|
6
|
+
-- specified a second table, `applied_artifacts`, for the same job. Two empty
|
|
7
|
+
-- ledgers and two dead read surfaces is the outcome nobody wanted, so the
|
|
8
|
+
-- columns that plan needed are added here instead.
|
|
9
|
+
--
|
|
10
|
+
-- What was missing, and why each one is load-bearing:
|
|
11
|
+
--
|
|
12
|
+
-- boundary_class Whether the actuator owned its targets. `inside` means an
|
|
13
|
+
-- exact inverse was possible and is therefore required;
|
|
14
|
+
-- `outside` means the user/git/another tool also writes there,
|
|
15
|
+
-- so the row owes a compensation instead. Declared per
|
|
16
|
+
-- artifact rather than rediscovered per actuator.
|
|
17
|
+
--
|
|
18
|
+
-- targets EVERY path the apply path wrote. `target_path` (00187) holds
|
|
19
|
+
-- one, which is fine for a single-file recommendation and
|
|
20
|
+
-- wrong for a skill that lands in two locations. `target_path`
|
|
21
|
+
-- stays as the primary target so the existing unique index and
|
|
22
|
+
-- its callers keep working; `targets` is the complete set.
|
|
23
|
+
--
|
|
24
|
+
-- inverse How to undo this row, RECORDED AT WRITE TIME. The system's
|
|
25
|
+
-- other undos are reconstructed — `removeHookByCommand`
|
|
26
|
+
-- searches a settings file for a command substring, and skill
|
|
27
|
+
-- retraction searches six directories by name. A search is not
|
|
28
|
+
-- an inverse: it misses silently when the thing was renamed
|
|
29
|
+
-- and removes the wrong entry when it matches twice.
|
|
30
|
+
--
|
|
31
|
+
-- requires The coeffect declaration. Two rules make it load-bearing:
|
|
32
|
+
-- provides do not apply a dependent before its dependency is present,
|
|
33
|
+
-- and do not withdraw a provider while an applied dependent
|
|
34
|
+
-- names it. The second closes a live hole — archiving is a
|
|
35
|
+
-- soft delete, so archiving a skill today leaves every
|
|
36
|
+
-- instruction naming it pointing at nothing, silently.
|
|
37
|
+
--
|
|
38
|
+
-- apply_batch_id The ordering. Not decoration: inverses must run LIFO,
|
|
39
|
+
-- apply_seq because a batch can install a provider and its dependent
|
|
40
|
+
-- moments apart, and undoing in application order withdraws
|
|
41
|
+
-- the provider while the dependent is still live. It is also
|
|
42
|
+
-- the only thing that can answer "undo everything last
|
|
43
|
+
-- night's loop applied", a question that currently has no
|
|
44
|
+
-- answer for any artifact type.
|
|
45
|
+
--
|
|
46
|
+
-- Every column is nullable or defaulted: the 00187 rows that do not exist yet,
|
|
47
|
+
-- and any that appear between deploy and this migration, stay readable.
|
|
48
|
+
|
|
49
|
+
alter table public.recommendation_applications
|
|
50
|
+
add column if not exists boundary_class text,
|
|
51
|
+
add column if not exists targets text[] not null default '{}',
|
|
52
|
+
add column if not exists inverse jsonb,
|
|
53
|
+
add column if not exists requires text[] not null default '{}',
|
|
54
|
+
add column if not exists provides text[] not null default '{}',
|
|
55
|
+
add column if not exists apply_batch_id uuid,
|
|
56
|
+
add column if not exists apply_seq integer;
|
|
57
|
+
|
|
58
|
+
do $$
|
|
59
|
+
begin
|
|
60
|
+
if not exists (
|
|
61
|
+
select 1 from pg_constraint
|
|
62
|
+
where conname = 'recommendation_applications_boundary_class_check'
|
|
63
|
+
) then
|
|
64
|
+
alter table public.recommendation_applications
|
|
65
|
+
add constraint recommendation_applications_boundary_class_check
|
|
66
|
+
check (boundary_class is null or boundary_class in ('inside', 'outside'));
|
|
67
|
+
end if;
|
|
68
|
+
end $$;
|
|
69
|
+
|
|
70
|
+
-- A row that reached `applied` must carry the inverse it recorded at that
|
|
71
|
+
-- moment. Enforced in the database rather than only in the actuator because the
|
|
72
|
+
-- actuator is one of several writers-to-be (CLI accept, worker auto-apply, the
|
|
73
|
+
-- API's own transition endpoint), and "we forgot to record the undo" is not a
|
|
74
|
+
-- failure any of them would notice at the time — it surfaces months later when
|
|
75
|
+
-- someone asks to revert and the row cannot say how.
|
|
76
|
+
--
|
|
77
|
+
-- Statuses before `applied` (proposed, ready) and the terminal `failed` are
|
|
78
|
+
-- exempt: nothing was written, so there is nothing to undo.
|
|
79
|
+
do $$
|
|
80
|
+
begin
|
|
81
|
+
if not exists (
|
|
82
|
+
select 1 from pg_constraint
|
|
83
|
+
where conname = 'recommendation_applications_applied_has_inverse'
|
|
84
|
+
) then
|
|
85
|
+
alter table public.recommendation_applications
|
|
86
|
+
add constraint recommendation_applications_applied_has_inverse
|
|
87
|
+
check (
|
|
88
|
+
status in ('proposed', 'ready', 'failed')
|
|
89
|
+
or inverse is not null
|
|
90
|
+
) not valid;
|
|
91
|
+
end if;
|
|
92
|
+
end $$;
|
|
93
|
+
|
|
94
|
+
-- NOT VALID above, validated here: the constraint applies to every future write
|
|
95
|
+
-- immediately, and the validation pass confirms the (currently empty) backlog
|
|
96
|
+
-- without taking an ACCESS EXCLUSIVE lock for the scan.
|
|
97
|
+
alter table public.recommendation_applications
|
|
98
|
+
validate constraint recommendation_applications_applied_has_inverse;
|
|
99
|
+
|
|
100
|
+
-- The withdrawal guard's query: "which applied rows require any of these keys?"
|
|
101
|
+
create index if not exists idx_recommendation_applications_requires
|
|
102
|
+
on public.recommendation_applications using gin (requires);
|
|
103
|
+
|
|
104
|
+
-- The reversal query: one batch, newest first.
|
|
105
|
+
create index if not exists idx_recommendation_applications_batch
|
|
106
|
+
on public.recommendation_applications (user_id, apply_batch_id, apply_seq desc);
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
-- cleanup_stale_test_users cannot reach an org whose members are already gone.
|
|
2
|
+
--
|
|
3
|
+
-- The org sweep (00087, unchanged through 00121) elects orgs by joining
|
|
4
|
+
-- `user_organizations` to the set of users it is about to delete:
|
|
5
|
+
--
|
|
6
|
+
-- and exists (select 1 from user_organizations uo
|
|
7
|
+
-- where uo.org_id = o.id and uo.user_id = any(v_user_ids))
|
|
8
|
+
--
|
|
9
|
+
-- That arm only ever matches while the membership row still exists. Anything
|
|
10
|
+
-- that deletes the user first — the e2e suites' own `deleteTestUser`, an admin
|
|
11
|
+
-- delete, a user deleting their account — cascades `user_organizations` away,
|
|
12
|
+
-- and from that moment the org is invisible to every subsequent sweep. It has no
|
|
13
|
+
-- members, so nobody can see it, join it, or delete it through the product; it
|
|
14
|
+
-- is unreachable and permanent.
|
|
15
|
+
--
|
|
16
|
+
-- Measured in prod 2026-08-16: 30,276 memberless orgs of 30,307 total,
|
|
17
|
+
-- accumulating since 2026-07-12 (30,261 of them named "…'s Workspace", the
|
|
18
|
+
-- signup default; the other 15 are timestamp-suffixed org/team test fixtures).
|
|
19
|
+
-- They hold nothing — 0 projects, 0 sessions, 0 invitations, 0 agent_runs,
|
|
20
|
+
-- 3 test teams.
|
|
21
|
+
--
|
|
22
|
+
-- The fix is a second, independent sweep: an org with zero members, older than
|
|
23
|
+
-- an hour, is dead by construction. It cannot arise from normal use — the
|
|
24
|
+
-- `check_last_owner` trigger refuses to remove an org's final owner, so the only
|
|
25
|
+
-- way to reach zero members is the cascade above — and the hour of grace keeps a
|
|
26
|
+
-- signup that inserts the org before the membership safe.
|
|
27
|
+
--
|
|
28
|
+
-- Note this sweep is deliberately NOT restricted to test-shaped names. The
|
|
29
|
+
-- defect is not "test orgs leak"; it is "an org outlives its last member". A
|
|
30
|
+
-- real user who deletes their account leaves exactly the same corpse, and it
|
|
31
|
+
-- should be reaped for exactly the same reason.
|
|
32
|
+
--
|
|
33
|
+
-- Two other changes, both forced by the first:
|
|
34
|
+
--
|
|
35
|
+
-- * The `v_user_ids is null` early return (00087) skipped everything below it.
|
|
36
|
+
-- A quiet hour with no test users to reap would have skipped the memberless
|
|
37
|
+
-- sweep too, which is the one that has 30k rows of backlog. The early return
|
|
38
|
+
-- is gone; the user sweep is now guarded in place.
|
|
39
|
+
-- * `superadmin_audit_log.target_org_id` is ON DELETE NO ACTION, so it is
|
|
40
|
+
-- nulled for the memberless set before the delete, the same way the existing
|
|
41
|
+
-- path does it for the membered set.
|
|
42
|
+
--
|
|
43
|
+
-- Return signature is unchanged (callers: `crons run cleanup_stale_test_users`).
|
|
44
|
+
-- `deleted_orgs` now counts both sweeps.
|
|
45
|
+
|
|
46
|
+
drop function if exists public.cleanup_stale_test_users();
|
|
47
|
+
|
|
48
|
+
create function public.cleanup_stale_test_users()
|
|
49
|
+
returns table(deleted_users int, deleted_orgs int, deleted_smoke_sessions int) as $$
|
|
50
|
+
declare
|
|
51
|
+
v_user_ids uuid[];
|
|
52
|
+
v_org_ids uuid[];
|
|
53
|
+
v_orphan_org_ids uuid[];
|
|
54
|
+
v_deleted_users int := 0;
|
|
55
|
+
v_deleted_orgs int := 0;
|
|
56
|
+
v_deleted_orphan_orgs int := 0;
|
|
57
|
+
v_deleted_smoke int := 0;
|
|
58
|
+
begin
|
|
59
|
+
-- Smoke-test session residue (any user). 1h threshold keeps an
|
|
60
|
+
-- in-flight smoke run safe.
|
|
61
|
+
delete from sessions
|
|
62
|
+
where created_at < now() - interval '1 hour'
|
|
63
|
+
and (
|
|
64
|
+
external_session_id like '00000000-0000-4000-8000-%'
|
|
65
|
+
or project_path = '/tmp/smoke-test-project'
|
|
66
|
+
or metadata->>'clientName' = 'smoke-test'
|
|
67
|
+
);
|
|
68
|
+
get diagnostics v_deleted_smoke = row_count;
|
|
69
|
+
|
|
70
|
+
-- Candidates: obvious test users older than 1h, excluding the pinned dev
|
|
71
|
+
-- login. See 00121 for why each arm can't hit a real signup.
|
|
72
|
+
select array_agg(id) into v_user_ids
|
|
73
|
+
from auth.users
|
|
74
|
+
where email != 'nick@rulemetric.test'
|
|
75
|
+
and created_at < now() - interval '1 hour'
|
|
76
|
+
and (
|
|
77
|
+
email ~* '\.(test|example|invalid|localhost|local)$'
|
|
78
|
+
or email ~* '@(example\.(com|net|org))$'
|
|
79
|
+
or email ~ '1[0-9]{12}@'
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
if v_user_ids is not null and array_length(v_user_ids, 1) > 0 then
|
|
83
|
+
-- Orgs whose every member is in the to-delete set. Cascade carve-out in
|
|
84
|
+
-- check_last_owner lets the user_organizations rows go when the parent
|
|
85
|
+
-- org is being deleted.
|
|
86
|
+
select array_agg(o.id) into v_org_ids
|
|
87
|
+
from organizations o
|
|
88
|
+
where not exists (
|
|
89
|
+
select 1 from user_organizations uo
|
|
90
|
+
where uo.org_id = o.id
|
|
91
|
+
and uo.user_id != all(v_user_ids)
|
|
92
|
+
)
|
|
93
|
+
and exists (
|
|
94
|
+
select 1 from user_organizations uo
|
|
95
|
+
where uo.org_id = o.id and uo.user_id = any(v_user_ids)
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
-- 1. NULL test-user references on NO ACTION FKs (preserve content)
|
|
99
|
+
update instructions set created_by = null where created_by = any(v_user_ids);
|
|
100
|
+
update instructions set archived_by = null where archived_by = any(v_user_ids);
|
|
101
|
+
update instruction_versions set created_by = null where created_by = any(v_user_ids);
|
|
102
|
+
update projects set created_by = null where created_by = any(v_user_ids);
|
|
103
|
+
update project_instructions set added_by = null where added_by = any(v_user_ids);
|
|
104
|
+
|
|
105
|
+
-- 2. Scrub superadmin_audit_log (actor_id is NOT NULL → delete; targets nullable)
|
|
106
|
+
delete from superadmin_audit_log where actor_id = any(v_user_ids);
|
|
107
|
+
update superadmin_audit_log set target_user_id = null where target_user_id = any(v_user_ids);
|
|
108
|
+
if v_org_ids is not null then
|
|
109
|
+
update superadmin_audit_log set target_org_id = null where target_org_id = any(v_org_ids);
|
|
110
|
+
end if;
|
|
111
|
+
|
|
112
|
+
-- 3. Delete orphan orgs
|
|
113
|
+
if v_org_ids is not null then
|
|
114
|
+
delete from organizations where id = any(v_org_ids);
|
|
115
|
+
get diagnostics v_deleted_orgs = row_count;
|
|
116
|
+
end if;
|
|
117
|
+
|
|
118
|
+
-- 4. Delete users (cascades to profiles, user_organizations, sessions, ...)
|
|
119
|
+
delete from auth.users where id = any(v_user_ids);
|
|
120
|
+
get diagnostics v_deleted_users = row_count;
|
|
121
|
+
end if;
|
|
122
|
+
|
|
123
|
+
-- 5. Memberless orgs, whoever emptied them and whenever. This runs on every
|
|
124
|
+
-- sweep, independent of whether step 4 deleted anyone, because the orgs it
|
|
125
|
+
-- reaps were emptied by an EARLIER run (or by a delete this function never
|
|
126
|
+
-- saw) and no later run can otherwise reach them.
|
|
127
|
+
select array_agg(o.id) into v_orphan_org_ids
|
|
128
|
+
from organizations o
|
|
129
|
+
where o.created_at < now() - interval '1 hour'
|
|
130
|
+
and not exists (select 1 from user_organizations uo where uo.org_id = o.id);
|
|
131
|
+
|
|
132
|
+
if v_orphan_org_ids is not null then
|
|
133
|
+
update superadmin_audit_log set target_org_id = null
|
|
134
|
+
where target_org_id = any(v_orphan_org_ids);
|
|
135
|
+
delete from organizations where id = any(v_orphan_org_ids);
|
|
136
|
+
get diagnostics v_deleted_orphan_orgs = row_count;
|
|
137
|
+
v_deleted_orgs := v_deleted_orgs + v_deleted_orphan_orgs;
|
|
138
|
+
end if;
|
|
139
|
+
|
|
140
|
+
return query select v_deleted_users, v_deleted_orgs, v_deleted_smoke;
|
|
141
|
+
end;
|
|
142
|
+
$$ language plpgsql security definer set search_path = public, auth;
|