1import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as SqlClient from "effect/sql/SqlClient";
5
6import { runMigrations } from "../Migrations.ts";
7import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
8
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layer({ filename: ":memory:" })));
10
11const MODEL_SELECTION = '{"instanceId":"codex","model":"gpt-5.6-sol"}';
12
13layer("046_RepairAutomaticSettlementTimestamps", (it) => {
14 it.effect("repairs automatic stamps and leaves manual settlement alone", () =>
15 Effect.gen(function* () {
16 const sql = yield* SqlClient.SqlClient;
17 yield* runMigrations({ toMigrationInclusive: 45 });
18
19 yield* sql`
20 INSERT INTO projection_threads (
21 thread_id,
22 project_id,
23 title,
24 model_selection_json,
25 latest_turn_id,
26 created_at,
27 updated_at,
28 latest_user_message_at,
29 settled_override,
30 settled_at,
31 deleted_at
32 )
33 VALUES
34 (
35 'thread-auto', 'project-1', 'Automatic', ${MODEL_SELECTION}, 'turn-auto',
36 '2026-05-01T00:00:00.000Z', '2026-09-01T00:00:00.000Z', '2026-06-01T00:00:00.000Z',
37 'settled', '2026-09-01T00:00:00.000Z', NULL
38 ),
39 (
40 'thread-auto-no-activity', 'project-1', 'Automatic without activity', ${MODEL_SELECTION}, NULL,
41 '2026-05-02T00:00:00.000Z', '2026-09-01T00:00:00.000Z', NULL,
42 'settled', '2026-09-01T00:00:00.000Z', NULL
43 ),
44 (
45 'thread-auto-later-activity', 'project-1', 'Automatic then active', ${MODEL_SELECTION}, NULL,
46 '2026-05-03T00:00:00.000Z', '2026-09-03T00:00:00.000Z', '2026-09-03T00:00:00.000Z',
47 'settled', '2026-09-01T00:00:00.000Z', NULL
48 ),
49 (
50 'thread-manual', 'project-1', 'Manual', ${MODEL_SELECTION}, NULL,
51 '2026-05-01T00:00:00.000Z', '2026-08-10T00:00:00.000Z', '2026-06-10T00:00:00.000Z',
52 'settled', '2026-08-10T00:00:00.000Z', NULL
53 ),
54 (
55 'thread-resettled', 'project-1', 'Manually re-settled', ${MODEL_SELECTION}, NULL,
56 '2026-05-01T00:00:00.000Z', '2026-09-02T00:00:00.000Z', '2026-06-05T00:00:00.000Z',
57 'settled', '2026-09-02T00:00:00.000Z', NULL
58 )
59 `;
60
61 yield* sql`
62 INSERT INTO projection_thread_messages (
63 message_id, thread_id, turn_id, role, text, is_streaming, created_at, updated_at
64 )
65 VALUES
66 ('message-auto', 'thread-auto', 'turn-auto', 'user', 'Prompt', 0, '2026-06-01T00:00:00.000Z', '2026-06-01T00:00:00.000Z'),
67 ('message-later-old', 'thread-auto-later-activity', NULL, 'user', 'Prompt', 0, '2026-06-20T00:00:00.000Z', '2026-06-20T00:00:00.000Z'),
68 ('message-later-new', 'thread-auto-later-activity', NULL, 'user', 'Prompt', 0, '2026-09-03T00:00:00.000Z', '2026-09-03T00:00:00.000Z'),
69 ('message-manual', 'thread-manual', NULL, 'user', 'Prompt', 0, '2026-06-10T00:00:00.000Z', '2026-06-10T00:00:00.000Z'),
70 ('message-resettled', 'thread-resettled', NULL, 'user', 'Prompt', 0, '2026-06-05T00:00:00.000Z', '2026-06-05T00:00:00.000Z')
71 `;
72
73 yield* sql`
74 INSERT INTO projection_turns (
75 thread_id, turn_id, state, requested_at, started_at, completed_at, checkpoint_files_json
76 )
77 VALUES (
78 'thread-auto', 'turn-auto', 'completed',
79 '2026-06-02T00:00:00.000Z', '2026-06-02T00:01:00.000Z', '2026-06-03T00:00:00.000Z', '[]'
80 )
81 `;
82
83 const settledEvent = (
84 eventId: string,
85 threadId: string,
86 version: number,
87 occurredAt: string,
88 commandId: string,
89 settledAt: string,
90 ) =>
91 sql`
92 INSERT INTO orchestration_events (
93 event_id, aggregate_kind, stream_id, stream_version, event_type, occurred_at,
94 command_id, causation_event_id, correlation_id, actor_kind, payload_json, metadata_json
95 )
96 VALUES (
97 ${eventId}, 'thread', ${threadId}, ${version}, 'thread.settled', ${occurredAt},
98 ${commandId}, NULL, ${commandId},
99 ${commandId.startsWith("server:") ? "server" : "client"},
100 ${JSON.stringify({ threadId, settledAt, updatedAt: occurredAt })}, '{}'
101 )
102 `;
103 const automatic = (threadId: string) => `server:auto-settle:${threadId}:uuid`;
104 const sweptAt = "2026-09-01T00:00:00.000Z";
105
106 yield* settledEvent(
107 "event-auto",
108 "thread-auto",
109 0,
110 sweptAt,
111 automatic("thread-auto"),
112 sweptAt,
113 );
114 // A later manual settle re-emits the bad stamp; only the projection matters.
115 yield* settledEvent(
116 "event-auto-repeat",
117 "thread-auto",
118 1,
119 "2026-09-01T00:00:05.000Z",
120 "command-repeat",
121 sweptAt,
122 );
123 yield* settledEvent(
124 "event-auto-no-activity",
125 "thread-auto-no-activity",
126 0,
127 sweptAt,
128 automatic("thread-auto-no-activity"),
129 sweptAt,
130 );
131 yield* settledEvent(
132 "event-auto-later-activity",
133 "thread-auto-later-activity",
134 0,
135 sweptAt,
136 automatic("thread-auto-later-activity"),
137 sweptAt,
138 );
139 yield* settledEvent(
140 "event-manual",
141 "thread-manual",
142 0,
143 "2026-08-10T00:00:00.000Z",
144 "command-manual",
145 "2026-08-10T00:00:00.000Z",
146 );
147 yield* settledEvent(
148 "event-resettled-auto",
149 "thread-resettled",
150 0,
151 sweptAt,
152 automatic("thread-resettled"),
153 sweptAt,
154 );
155 yield* settledEvent(
156 "event-resettled-manual",
157 "thread-resettled",
158 1,
159 "2026-09-02T00:00:00.000Z",
160 "command-resettled-manual",
161 "2026-09-02T00:00:00.000Z",
162 );
163
164 const eventsBefore =
165 yield* sql`SELECT payload_json FROM orchestration_events ORDER BY event_id`;
166
167 yield* runMigrations({ toMigrationInclusive: 46 });
168
169 const threads = yield* sql<{
170 readonly threadId: string;
171 readonly settledAt: string;
172 readonly updatedAt: string;
173 }>`
174 SELECT
175 thread_id AS "threadId",
176 settled_at AS "settledAt",
177 updated_at AS "updatedAt"
178 FROM projection_threads
179 ORDER BY thread_id
180 `;
181 assert.deepStrictEqual(threads, [
182 // Latest activity at or before the sweep: the turn completion.
183 {
184 threadId: "thread-auto",
185 settledAt: "2026-06-03T00:00:00.000Z",
186 updatedAt: "2026-09-01T00:00:00.000Z",
187 },
188 // Activity after the sweep is ignored; the older message wins.
189 {
190 threadId: "thread-auto-later-activity",
191 settledAt: "2026-06-20T00:00:00.000Z",
192 updatedAt: "2026-09-03T00:00:00.000Z",
193 },
194 // No messages or turns: fall back to creation, matching the reactor.
195 {
196 threadId: "thread-auto-no-activity",
197 settledAt: "2026-05-02T00:00:00.000Z",
198 updatedAt: "2026-09-01T00:00:00.000Z",
199 },
200 {
201 threadId: "thread-manual",
202 settledAt: "2026-08-10T00:00:00.000Z",
203 updatedAt: "2026-08-10T00:00:00.000Z",
204 },
205 // Manually re-settled after the sweep keeps the manual stamp.
206 {
207 threadId: "thread-resettled",
208 settledAt: "2026-09-02T00:00:00.000Z",
209 updatedAt: "2026-09-02T00:00:00.000Z",
210 },
211 ]);
212
213 const eventsAfter =
214 yield* sql`SELECT payload_json FROM orchestration_events ORDER BY event_id`;
215 assert.deepStrictEqual(eventsAfter, eventsBefore);
216 }),
217 );
218});