okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

Aly Raffauffeat(api)!: migrate daemon and clients to protocol 361c43ca

main
6.3 KiB210 linesraw
1/// Legacy schema migrations preserve titles and event timestamps; E2E uses current schemas.
2import albedo/daemon/conversation
3import albedo/daemon/store
4import albedo/daemon/transcript
5import albedo/harness/runtime
6import albedo/openai_api/types
7import gleam/list
8import gleam/option.{None, Some}
9import gleeunit/should
10import sqlight
11
12@external(erlang, "albedo_runtime_test_support", "temporary_database")
13fn temporary_database() -> String
14
15@external(erlang, "albedo_runtime_test_support", "cleanup")
16fn cleanup(path: String) -> Nil
17
18fn session(id: String) -> conversation.Info {
19  conversation.Info(
20    id,
21    "new session",
22    "/tmp",
23    "provider",
24    "model",
25    types.Responses,
26    conversation.Idle,
27    None,
28    None,
29  )
30}
31
32pub fn latest_user_title_survives_restart_and_legacy_migration_test() -> Nil {
33  let path = temporary_database()
34  let assert Ok(host) = runtime.start(path)
35  let ledger = runtime.ledger(host)
36  let assert Ok(_) = conversation.initialise(ledger)
37  let assert Ok(_) = conversation.create(ledger, session("session"))
38  let assert Ok(_) =
39    conversation.commit(
40      ledger,
41      "session",
42      [types.User("earlier title"), types.Assistant("not the title")],
43      conversation.Model,
44    )
45  let assert Ok(_) =
46    conversation.commit(
47      ledger,
48      "session",
49      [types.User("  latest\naccepted 👩‍💻  ")],
50      conversation.Model,
51    )
52  let assert Ok(_) =
53    conversation.commit(
54      ledger,
55      "session",
56      [
57        types.Assistant("must not replace it"),
58        types.ToolOutput("call", "output", []),
59      ],
60      conversation.Idle,
61    )
62  let assert Ok([before]) = conversation.list(ledger)
63  before.title |> should.equal("latest accepted 👩‍💻")
64
65  // Recreate the old schema shape; startup performs this transcript scan once.
66  let assert Ok(_) =
67    store.query(ledger, fn(db) {
68      sqlight.exec(
69        "ALTER TABLE sessions DROP COLUMN title; ALTER TABLE sessions DROP COLUMN last_assistant_at",
70        db,
71      )
72    })
73  runtime.stop(host)
74
75  let assert Ok(restarted) = runtime.start(path)
76  let ledger = runtime.ledger(restarted)
77  let assert Ok(_) = conversation.initialise(ledger)
78  let assert Ok([after]) = conversation.list(ledger)
79  after.title |> should.equal("latest accepted 👩‍💻")
80  after.last_assistant_at |> should.equal(None)
81  runtime.stop(restarted)
82  cleanup(path)
83}
84
85pub fn migration_recovers_placeholder_titles_and_activity_order_test() -> Nil {
86  let path = temporary_database()
87  let assert Ok(host) = runtime.start(path)
88  let ledger = runtime.ledger(host)
89  let assert Ok(_) = conversation.initialise(ledger)
90  let assert Ok(_) = conversation.create(ledger, session("first"))
91  let assert Ok(_) = conversation.create(ledger, session("second"))
92  let assert Ok(_) =
93    conversation.commit(
94      ledger,
95      "second",
96      [types.User("older prompt")],
97      conversation.Idle,
98    )
99  let assert Ok(_) =
100    conversation.commit(
101      ledger,
102      "first",
103      [types.User("newest prompt")],
104      conversation.Idle,
105    )
106  let assert Ok(_) =
107    store.query(ledger, fn(db) {
108      sqlight.exec(
109        "UPDATE sessions SET title='new session',activity_seq=NULL",
110        db,
111      )
112    })
113
114  // The null activity marker makes this a one-time legacy backfill. It also
115  // distinguishes a placeholder title from a real current-session title.
116  let assert Ok(_) = conversation.initialise(ledger)
117  let assert Ok([first, second]) = conversation.list(ledger)
118  first.id |> should.equal("first")
119  first.title |> should.equal("newest prompt")
120  second.id |> should.equal("second")
121  second.title |> should.equal("older prompt")
122  runtime.stop(host)
123  cleanup(path)
124}
125
126pub fn transcript_timestamps_migrate_without_invention_and_roundtrip_test() -> Nil {
127  let path = temporary_database()
128  let assert Ok(host) = runtime.start(path)
129  let ledger = runtime.ledger(host)
130  let assert Ok(_) = conversation.initialise(ledger)
131  let assert Ok(_) = conversation.create(ledger, session("timestamped"))
132  let assert Ok(_) =
133    conversation.commit(
134      ledger,
135      "timestamped",
136      [types.User("legacy")],
137      conversation.Idle,
138    )
139
140  // Recreate the legacy transcript shape. Migration adds a nullable column and
141  // must not claim to know when this row was accepted.
142  let assert Ok(_) =
143    store.query(ledger, fn(db) {
144      sqlight.exec(
145        "ALTER TABLE transcript DROP COLUMN timestamp; ALTER TABLE transcript DROP COLUMN provider; ALTER TABLE transcript DROP COLUMN thought_ms",
146        db,
147      )
148    })
149  let assert Ok(_) = conversation.initialise(ledger)
150  conversation.load_entries(ledger, "timestamped")
151  |> should.equal(
152    Ok([
153      transcript.Entry(
154        types.User("legacy"),
155        None,
156        None,
157        None,
158        Some(transcript.SourceRef("timestamped", 1)),
159      ),
160    ]),
161  )
162
163  let new_inputs = [types.User("current"), types.Assistant("answer")]
164  let assert Ok(timestamp) =
165    conversation.commit(ledger, "timestamped", new_inputs, conversation.Idle)
166  let assert True = timestamp > 1_000_000_000_000
167  let assert Ok(entries) = conversation.load_entries(ledger, "timestamped")
168  entries
169  |> should.equal([
170    transcript.Entry(
171      types.User("legacy"),
172      None,
173      None,
174      None,
175      Some(transcript.SourceRef("timestamped", 1)),
176    ),
177    transcript.Entry(
178      types.User("current"),
179      Some(timestamp),
180      None,
181      None,
182      Some(transcript.SourceRef("timestamped", 2)),
183    ),
184    transcript.Entry(
185      types.Assistant("answer"),
186      Some(timestamp),
187      None,
188      None,
189      Some(transcript.SourceRef("timestamped", 3)),
190    ),
191  ])
192  conversation.load(ledger, "timestamped")
193  |> should.equal(Ok([types.User("legacy"), ..new_inputs]))
194
195  let assert Ok(snapshot) = conversation.snapshot(ledger, "timestamped")
196  let range = conversation.Range(snapshot, conversation.After(0), 100, 0)
197  let assert Ok(page) = conversation.read_range(ledger, range)
198  list.map(page.entries, fn(sourced) { sourced.entry })
199  |> should.equal(entries)
200  page.has_more |> should.be_false
201
202  runtime.stop(host)
203  let assert Ok(restarted) = runtime.start(path)
204  let ledger = runtime.ledger(restarted)
205  let assert Ok(_) = conversation.initialise(ledger)
206  conversation.load_entries(ledger, "timestamped") |> should.equal(Ok(entries))
207  conversation.read_range(ledger, range) |> should.equal(Ok(page))
208  runtime.stop(restarted)
209  cleanup(path)
210}