History

Clean up invalid pending approval projections (#2106)

Julius Marminge committed 7 months ago

Commit f297e301 parent a7a44d03 files · +225 −0Browse files

Unified · Split

3 files
  1. apps/server/src/persistence/Migrations.ts +2 −0
  2. apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.test.ts +196 −0
  3. apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.ts +27 −0
apps/server/src/persistence/Migrations.ts+2−0
@@ -37,6 +37,7 @@ import Migration0021 from "./Migrations/021_AuthSessionClientMetadata.ts"; · 36 unmodified lines
3737 import Migration0022 from "./Migrations/022_AuthSessionLastConnectedAt.ts";
3838 import Migration0023 from "./Migrations/023_ProjectionThreadShellSummary.ts";
3939 import Migration0024 from "./Migrations/024_BackfillProjectionThreadShellSummary.ts";
40+import Migration0025 from "./Migrations/025_CleanupInvalidProjectionPendingApprovals.ts";
4041
4142 /**
4243 * Migration loader with all migrations defined inline.
@@ -73,6 +74,7 @@ export const migrationEntries = [ · 30 unmodified lines
7374 [22, "AuthSessionLastConnectedAt", Migration0022],
7475 [23, "ProjectionThreadShellSummary", Migration0023],
7576 [24, "BackfillProjectionThreadShellSummary", Migration0024],
77+ [25, "CleanupInvalidProjectionPendingApprovals", Migration0025],
7678 ] as const;
7779
7880 export const makeMigrationLoader = (throughId?: number) =>
apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.test.ts+196−0
@@ -0,0 +1,196 @@
1+import { assert, it } from "@effect/vitest";
2+import { Effect, Layer } from "effect";
3+import * as SqlClient from "effect/unstable/sql/SqlClient";
4+
5+import { runMigrations } from "../Migrations.ts";
6+import * as NodeSqliteClient from "../NodeSqliteClient.ts";
7+
8+const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
9+
10+layer("025_CleanupInvalidProjectionPendingApprovals", (it) => {
11+ it.effect("removes pending-approval rows that do not come from approval requests", () =>
12+ Effect.gen(function* () {
13+ const sql = yield* SqlClient.SqlClient;
14+
15+ yield* runMigrations({ toMigrationInclusive: 24 });
16+
17+ yield* sql`
18+ INSERT INTO projection_threads (
19+ thread_id,
20+ project_id,
21+ title,
22+ model_selection_json,
23+ runtime_mode,
24+ interaction_mode,
25+ branch,
26+ worktree_path,
27+ latest_turn_id,
28+ created_at,
29+ updated_at,
30+ archived_at,
31+ latest_user_message_at,
32+ pending_approval_count,
33+ pending_user_input_count,
34+ has_actionable_proposed_plan,
35+ deleted_at
36+ )
37+ VALUES
38+ (
39+ 'thread-valid',
40+ 'project-1',
41+ 'Valid thread',
42+ '{"provider":"codex","model":"gpt-5-codex"}',
43+ 'approval-required',
44+ 'default',
45+ NULL,
46+ NULL,
47+ 'turn-valid',
48+ '2026-04-13T00:00:00.000Z',
49+ '2026-04-13T00:00:00.000Z',
50+ NULL,
51+ NULL,
52+ 2,
53+ 0,
54+ 0,
55+ NULL
56+ ),
57+ (
58+ 'thread-invalid',
59+ 'project-1',
60+ 'Invalid thread',
61+ '{"provider":"codex","model":"gpt-5-codex"}',
62+ 'approval-required',
63+ 'default',
64+ NULL,
65+ NULL,
66+ 'turn-invalid',
67+ '2026-04-13T00:00:00.000Z',
68+ '2026-04-13T00:00:00.000Z',
69+ NULL,
70+ NULL,
71+ 1,
72+ 0,
73+ 0,
74+ NULL
75+ )
76+ `;
77+
78+ yield* sql`
79+ INSERT INTO projection_thread_activities (
80+ activity_id,
81+ thread_id,
82+ turn_id,
83+ tone,
84+ kind,
85+ summary,
86+ payload_json,
87+ sequence,
88+ created_at
89+ )
90+ VALUES
91+ (
92+ 'activity-approval-requested',
93+ 'thread-valid',
94+ 'turn-valid',
95+ 'approval',
96+ 'approval.requested',
97+ 'Command approval requested',
98+ '{"requestId":"approval-valid","requestKind":"command"}',
99+ NULL,
100+ '2026-04-13T00:01:00.000Z'
101+ ),
102+ (
103+ 'activity-user-input-requested',
104+ 'thread-invalid',
105+ 'turn-invalid',
106+ 'info',
107+ 'user-input.requested',
108+ 'User input requested',
109+ '{"requestId":"input-invalid","questions":[{"id":"scope","header":"Scope","question":"What should I inspect?","options":[{"label":"Server","description":"Inspect server code."}]}]}',
110+ NULL,
111+ '2026-04-13T00:02:00.000Z'
112+ )
113+ `;
114+
115+ yield* sql`
116+ INSERT INTO projection_pending_approvals (
117+ request_id,
118+ thread_id,
119+ turn_id,
120+ status,
121+ decision,
122+ created_at,
123+ resolved_at
124+ )
125+ VALUES
126+ (
127+ 'approval-valid',
128+ 'thread-valid',
129+ 'turn-valid',
130+ 'pending',
131+ NULL,
132+ '2026-04-13T00:01:00.000Z',
133+ NULL
134+ ),
135+ (
136+ 'input-invalid',
137+ 'thread-invalid',
138+ 'turn-invalid',
139+ 'pending',
140+ NULL,
141+ '2026-04-13T00:02:00.000Z',
142+ NULL
143+ ),
144+ (
145+ 'input-invalid-resolved',
146+ 'thread-valid',
147+ 'turn-valid',
148+ 'resolved',
149+ NULL,
150+ '2026-04-13T00:03:00.000Z',
151+ '2026-04-13T00:04:00.000Z'
152+ )
153+ `;
154+
155+ yield* runMigrations({ toMigrationInclusive: 25 });
156+
157+ const approvalRows = yield* sql<{
158+ readonly requestId: string;
159+ readonly status: string;
160+ }>`
161+ SELECT
162+ request_id AS "requestId",
163+ status
164+ FROM projection_pending_approvals
165+ ORDER BY request_id ASC
166+ `;
167+ assert.deepStrictEqual(approvalRows, [
168+ {
169+ requestId: "approval-valid",
170+ status: "pending",
171+ },
172+ ]);
173+
174+ const threadCounts = yield* sql<{
175+ readonly threadId: string;
176+ readonly pendingApprovalCount: number;
177+ }>`
178+ SELECT
179+ thread_id AS "threadId",
180+ pending_approval_count AS "pendingApprovalCount"
181+ FROM projection_threads
182+ ORDER BY thread_id ASC
183+ `;
184+ assert.deepStrictEqual(threadCounts, [
185+ {
186+ threadId: "thread-invalid",
187+ pendingApprovalCount: 0,
188+ },
189+ {
190+ threadId: "thread-valid",
191+ pendingApprovalCount: 1,
192+ },
193+ ]);
194+ }),
195+ );
196+});
apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.ts+27−0
@@ -0,0 +1,27 @@
1+import * as SqlClient from "effect/unstable/sql/SqlClient";
2+import * as Effect from "effect/Effect";
3+
4+export default Effect.gen(function* () {
5+ const sql = yield* SqlClient.SqlClient;
6+
7+ yield* sql`
8+ DELETE FROM projection_pending_approvals
9+ WHERE NOT EXISTS (
10+ SELECT 1
11+ FROM projection_thread_activities AS activity
12+ WHERE activity.kind = 'approval.requested'
13+ AND json_extract(activity.payload_json, '$.requestId')
14+ = projection_pending_approvals.request_id
15+ )
16+ `;
17+
18+ yield* sql`
19+ UPDATE projection_threads
20+ SET pending_approval_count = COALESCE((
21+ SELECT COUNT(*)
22+ FROM projection_pending_approvals
23+ WHERE projection_pending_approvals.thread_id = projection_threads.thread_id
24+ AND projection_pending_approvals.status = 'pending'
25+ ), 0)
26+ `;
27+});