| 1 | import { assert, it } from "@effect/vitest"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Layer from "effect/Layer"; |
| 4 | import * as SqlClient from "effect/sql/SqlClient"; |
| 5 | |
| 6 | import { runMigrations } from "../Migrations.ts"; |
| 7 | import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; |
| 8 | |
| 9 | const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layer({ filename: ":memory:" }))); |
| 10 | |
| 11 | const MODEL_SELECTION = '{"instanceId":"codex","model":"gpt-5.6-sol"}'; |
| 12 | |
| 13 | layer("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 | }); |