authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-04-16 23:26:28-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-16 23:26:28-07:00
logf297e30e4c0fe044ba5cbed7679bfcba134526fe
tree3e00737e2d58acc199ac85e7080aebeaa5534ed4
parenta7a44d0662fb026009e8a5b5d58a5012e2e8665d
signature Signed by PGP key B5690EEEBB952194

Clean up invalid pending approval projections (#2106)


3 files changed, 225 insertions(+), 0 deletions(-)

apps/server/src/persistence/Migrations.ts+2
...@@ -37,6 +37,7 @@ import Migration0021 from "./Migrations/021_AuthSessionClientMetadata.ts";...@@ -37,6 +37,7 @@ import Migration0021 from "./Migrations/021_AuthSessionClientMetadata.ts";
37import Migration0022 from "./Migrations/022_AuthSessionLastConnectedAt.ts";37import Migration0022 from "./Migrations/022_AuthSessionLastConnectedAt.ts";
38import Migration0023 from "./Migrations/023_ProjectionThreadShellSummary.ts";38import Migration0023 from "./Migrations/023_ProjectionThreadShellSummary.ts";
39import Migration0024 from "./Migrations/024_BackfillProjectionThreadShellSummary.ts";39import Migration0024 from "./Migrations/024_BackfillProjectionThreadShellSummary.ts";
40import Migration0025 from "./Migrations/025_CleanupInvalidProjectionPendingApprovals.ts";
4041
41/**42/**
42 * Migration loader with all migrations defined inline.43 * Migration loader with all migrations defined inline.
...@@ -73,6 +74,7 @@ export const migrationEntries = [...@@ -73,6 +74,7 @@ export const migrationEntries = [
73 [22, "AuthSessionLastConnectedAt", Migration0022],74 [22, "AuthSessionLastConnectedAt", Migration0022],
74 [23, "ProjectionThreadShellSummary", Migration0023],75 [23, "ProjectionThreadShellSummary", Migration0023],
75 [24, "BackfillProjectionThreadShellSummary", Migration0024],76 [24, "BackfillProjectionThreadShellSummary", Migration0024],
77 [25, "CleanupInvalidProjectionPendingApprovals", Migration0025],
76] as const;78] as const;
7779
78export const makeMigrationLoader = (throughId?: number) =>80export const makeMigrationLoader = (throughId?: number) =>
apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.test.ts created+196
...@@ -0,0 +1,196 @@
1import { assert, it } from "@effect/vitest";
2import { Effect, Layer } from "effect";
3import * as SqlClient from "effect/unstable/sql/SqlClient";
4
5import { runMigrations } from "../Migrations.ts";
6import * as NodeSqliteClient from "../NodeSqliteClient.ts";
7
8const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
9
10layer("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 created+27
...@@ -0,0 +1,27 @@
1import * as SqlClient from "effect/unstable/sql/SqlClient";
2import * as Effect from "effect/Effect";
3
4export 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});