Skip to content

Commit f297e30

Browse files
Clean up invalid pending approval projections (#2106)
1 parent a7a44d0 commit f297e30

3 files changed

Lines changed: 225 additions & 0 deletions

File tree

apps/server/src/persistence/Migrations.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import Migration0021 from "./Migrations/021_AuthSessionClientMetadata.ts";
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 = [
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) =>
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
});

0 commit comments

Comments
 (0)