legionio 1.9.39 → 1.9.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7e168c173741e3abdcd5a911fb95506e1c599e8a934befdd8221991ebdae1185
4
- data.tar.gz: 00a1dad9c0234f3ef0e166d9f1844bde95a8c344adb9df28fd5f095bd5fe6b49
3
+ metadata.gz: b24faa2a8eb6db1841c7e6e181b9f1470dc2c3167efd58008d923cdfa25c41f8
4
+ data.tar.gz: f0af7a1459f4edb91751fb71161a02675f4b49c91c194d64100d7d33f7cbccbf
5
5
  SHA512:
6
- metadata.gz: 0b9bda655ae5a8b731feebf9e7bd14539c1ddc13117ad57c799921f2b7eda72a9cf3bfb45c0f64ff7967176c94a35d9471e7a099432423a2ed6e1fd9dcb663ce
7
- data.tar.gz: 05b7947bdf2ab01cb026c0afd1bc2d0b385ec643a3f486b3b749ee676ba9383ce3cc276304abd7f60fb317f7f11a1e75f3578b6f06b4d93f9dcca0b77ed5735d
6
+ metadata.gz: a8a3eb0fdf4fc17f105f282b8b1f4cd8ffface0429f3e74622f8d321b7c29488a0515fe67e8b50dbcc28cee70d151a4d03aa103bc0c78cb6405efdbecc3be6bc
7
+ data.tar.gz: ad3250e699dce50fc0edc7786dae7f2fa9371ab6639fe4d14f113a9c3aa47a04249d7cd7ef6928de0cb922456c82eb8157a09b792608c62972cf9941dbe52329
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Legion Changelog
2
2
 
3
+ ## [1.9.41] - 2026-06-02
4
+
5
+ ### Fixed
6
+ - CLI: `setup proxy-mode` now upserts `[model_providers.legionio]` with `api_key = "legion"` into `~/.codex/config.toml` instead of writing the deprecated `profile = "legionio"` key (removed by Codex)
7
+ - CLI: model catalog format corrected to use `slug`/`display_name`/`supported_reasoning_levels` fields
8
+ - CLI: `model_catalog_json` removed from top-level `config.toml` (breaks Mac app strict schema parsing); kept only in `legionio.config.toml` for `--profile legionio` CLI use
9
+
10
+ ## [1.9.40] - 2026-06-01
11
+
12
+ ### Added
13
+ -
14
+
3
15
  ## [1.9.39] - 2026-05-30
4
16
 
5
17
  ### Fixed
data/exe/replay_ledger ADDED
@@ -0,0 +1,395 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # One-off script to migrate local ledger data to prod database.
5
+ # Handles FK remapping for identity tables by matching on natural keys.
6
+ #
7
+ # Usage:
8
+ # LOCAL_DB_URL="postgres://localhost/legionio" PROD_DB_URL="postgres://user:pass@prod/legionio" bundle exec exe/replay_ledger
9
+ #
10
+ # Optional:
11
+ # REPLAY_DRY_RUN=true (show counts, don't write)
12
+
13
+ require 'sequel'
14
+ require 'logger'
15
+ require 'uri'
16
+ require 'json'
17
+ require 'fileutils'
18
+
19
+ log = Logger.new($stdout)
20
+ log.level = Logger::INFO
21
+
22
+ # --- Signal handling for clean exit ---
23
+ module MigrationState
24
+ @shutdown = false
25
+ class << self
26
+ attr_accessor :shutdown
27
+ end
28
+ end
29
+
30
+ %w[INT TERM HUP].each do |sig|
31
+ Signal.trap(sig) do
32
+ if MigrationState.shutdown
33
+ warn "\nForce quit."
34
+ exit!(1)
35
+ end
36
+ MigrationState.shutdown = true
37
+ warn "\nShutdown requested — finishing current row, then saving manifests and exiting cleanly..."
38
+ end
39
+ end
40
+
41
+ local_url = ENV.fetch('LOCAL_DB_URL', 'postgres://localhost/legionio')
42
+ dry_run = ENV['REPLAY_DRY_RUN'] == 'true'
43
+
44
+ # Read prod creds from ~/.legionio/settings/z_data_override.json
45
+ prod_settings_path = File.expand_path('~/.legionio/settings/z_data_override.json')
46
+ abort "Missing #{prod_settings_path} — create it with host/database/user/password" unless File.exist?(prod_settings_path)
47
+ prod_config = JSON.parse(File.read(prod_settings_path))
48
+ prod_creds = prod_config.dig('data', 'creds') || abort("No data.creds in #{prod_settings_path}")
49
+ prod_url = "postgres://#{prod_creds['user']}:#{URI.encode_www_form_component(prod_creds['password'])}@#{prod_creds['host']}/#{prod_creds['database']}"
50
+
51
+ LOCAL = Sequel.connect(local_url)
52
+ PROD = Sequel.connect(prod_url)
53
+
54
+ log.info "Local: #{local_url.sub(/:[^:@]+@/, ':***@')}"
55
+ log.info "Prod: postgres://#{prod_creds['user']}:***@#{prod_creds['host']}/#{prod_creds['database']}"
56
+ log.info "Mode: #{dry_run ? 'DRY RUN' : 'LIVE'}"
57
+
58
+ # ============================================================
59
+ # PHASE 1: Sync identity_providers (match on name)
60
+ # ============================================================
61
+
62
+ log.info '--- Phase 1: identity_providers ---'
63
+ local_providers = LOCAL[:identity_providers].all
64
+ prod_providers = PROD[:identity_providers].all
65
+ prod_provider_by_name = prod_providers.to_h { |p| [p[:name], p] }
66
+ provider_id_map = {} # local_id → prod_id
67
+
68
+ local_providers.each do |lp|
69
+ prod_match = prod_provider_by_name[lp[:name]]
70
+ if prod_match
71
+ provider_id_map[lp[:id]] = prod_match[:id]
72
+ else
73
+ row = lp.except(:id)
74
+ if dry_run
75
+ log.info " [DRY] Would insert provider: #{lp[:name]}"
76
+ provider_id_map[lp[:id]] = -1
77
+ else
78
+ new_id = PROD[:identity_providers].insert(row)
79
+ provider_id_map[lp[:id]] = new_id
80
+ log.info " Inserted provider: #{lp[:name]} → id=#{new_id}"
81
+ end
82
+ end
83
+ end
84
+ log.info " Provider map: #{provider_id_map.size} entries (#{provider_id_map.count { |_, v| v != -1 }} matched)"
85
+
86
+ # ============================================================
87
+ # PHASE 2: Sync identity_principals (match on canonical_name)
88
+ # ============================================================
89
+
90
+ log.info '--- Phase 2: identity_principals ---'
91
+ local_principals = LOCAL[:identity_principals].all
92
+ prod_principals = PROD[:identity_principals].all
93
+ prod_principal_by_name = prod_principals.to_h { |p| [p[:canonical_name], p] }
94
+ principal_id_map = {} # local_id → prod_id
95
+
96
+ local_principals.each do |lp|
97
+ prod_match = prod_principal_by_name[lp[:canonical_name]]
98
+ if prod_match
99
+ principal_id_map[lp[:id]] = prod_match[:id]
100
+ else
101
+ row = lp.except(:id)
102
+ if dry_run
103
+ log.info " [DRY] Would insert principal: #{lp[:canonical_name]}"
104
+ principal_id_map[lp[:id]] = -1
105
+ else
106
+ new_id = PROD[:identity_principals].insert(row)
107
+ principal_id_map[lp[:id]] = new_id
108
+ log.info " Inserted principal: #{lp[:canonical_name]} → id=#{new_id}"
109
+ end
110
+ end
111
+ end
112
+ log.info " Principal map: #{principal_id_map.size} entries (#{principal_id_map.count { |_, v| v != -1 }} matched)"
113
+
114
+ # ============================================================
115
+ # PHASE 3: Sync identities (match on principal_id + provider_identity_key)
116
+ # ============================================================
117
+
118
+ log.info '--- Phase 3: identities ---'
119
+ local_identities = LOCAL[:identities].all
120
+ prod_identities = PROD[:identities].all
121
+ prod_identity_by_key = prod_identities.to_h { |i| ["#{i[:principal_id]}:#{i[:provider_identity_key]}", i] }
122
+ identity_id_map = {} # local_id → prod_id
123
+
124
+ local_identities.each do |li|
125
+ mapped_principal = principal_id_map[li[:principal_id]]
126
+ mapped_provider = provider_id_map[li[:provider_id]]
127
+ prod_key = "#{mapped_principal}:#{li[:provider_identity_key]}"
128
+ prod_match = prod_identity_by_key[prod_key]
129
+
130
+ if prod_match
131
+ identity_id_map[li[:id]] = prod_match[:id]
132
+ else
133
+ row = li.except(:id)
134
+ row[:principal_id] = mapped_principal
135
+ row[:provider_id] = mapped_provider
136
+ if dry_run
137
+ log.info " [DRY] Would insert identity: #{li[:provider_identity_key]} (principal=#{mapped_principal})"
138
+ identity_id_map[li[:id]] = -1
139
+ else
140
+ new_id = PROD[:identities].insert(row)
141
+ identity_id_map[li[:id]] = new_id
142
+ log.info " Inserted identity: #{li[:provider_identity_key]} → id=#{new_id}"
143
+ end
144
+ end
145
+ end
146
+ log.info " Identity map: #{identity_id_map.size} entries (#{identity_id_map.count { |_, v| v != -1 }} matched)"
147
+
148
+ # ============================================================
149
+ # PHASE 4: LLM tables — remap identity FKs, preserve UUID links
150
+ # ============================================================
151
+
152
+ def remap_identity_columns(row, principal_map, identity_map)
153
+ result = row.dup
154
+ # Different tables use different column names
155
+ %i[principal_id caller_principal_id].each do |col|
156
+ result[col] = principal_map[result[col]] if result.key?(col) && result[col] && principal_map.key?(result[col])
157
+ end
158
+ %i[identity_id caller_identity_id].each do |col|
159
+ result[col] = identity_map[result[col]] if result.key?(col) && result[col] && identity_map.key?(result[col])
160
+ end
161
+ result
162
+ end
163
+
164
+ MANIFEST_DIR = '/tmp/legion_migration_manifests'
165
+
166
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/ParameterLists
167
+ def migrate_table(local_db, prod_db, table, id_map_out, log:, dry_run:, fk_maps: {}, identity_maps: {})
168
+ count = local_db[table].count
169
+ if count.zero?
170
+ log.info " #{table}: 0 rows, skipping"
171
+ return
172
+ end
173
+
174
+ log.info " #{table}: #{count} rows to migrate..."
175
+
176
+ # Check for UUID column to detect duplicates
177
+ columns = local_db[table].columns
178
+ has_uuid = columns.include?(:uuid)
179
+ log.info " #{table}: loading existing UUIDs from prod..." if has_uuid
180
+ prod_uuids = if has_uuid
181
+ PROD[table].select_map(:uuid).to_set
182
+ else
183
+ Set.new
184
+ end
185
+ log.info " #{table}: #{prod_uuids.size} existing UUIDs on prod" if has_uuid
186
+
187
+ FileUtils.mkdir_p(MANIFEST_DIR)
188
+ manifest_path = File.join(MANIFEST_DIR, "#{table}.json")
189
+ ids_path = File.join(MANIFEST_DIR, "#{table}_ids.jsonl")
190
+
191
+ inserted = 0
192
+ skipped = 0
193
+ start_time = Time.now
194
+
195
+ # Use block form to avoid file descriptor leaks
196
+ File.open(ids_path, 'a') do |ids_file|
197
+ local_db[table].order(:id).each do |row|
198
+ if MigrationState.shutdown
199
+ log.warn " #{table}: INTERRUPTED at row #{inserted + skipped}/#{count} — exiting cleanly"
200
+ break
201
+ end
202
+
203
+ local_id = row[:id]
204
+
205
+ # Skip if already exists on prod (by UUID)
206
+ if has_uuid && row[:uuid] && prod_uuids.include?(row[:uuid])
207
+ prod_row = prod_db[table].where(uuid: row[:uuid]).first
208
+ id_map_out[local_id] = prod_row[:id] if prod_row
209
+ skipped += 1
210
+ next
211
+ end
212
+
213
+ new_row = row.except(:id)
214
+
215
+ # Remap FK columns — if map is empty, NULL the column (deferred backfill)
216
+ fk_maps.each do |column, map|
217
+ next unless new_row.key?(column) && new_row[column]
218
+
219
+ new_row[column] = if map.empty?
220
+ nil
221
+ elsif map.key?(new_row[column])
222
+ map[new_row[column]]
223
+ end
224
+ end
225
+
226
+ # Remap identity columns
227
+ new_row = remap_identity_columns(new_row, identity_maps[:principals] || {}, identity_maps[:identities] || {}) unless identity_maps.empty?
228
+
229
+ if dry_run
230
+ inserted += 1
231
+ id_map_out[local_id] = -1
232
+ else
233
+ begin
234
+ new_id = prod_db[table].insert(new_row)
235
+ id_map_out[local_id] = new_id
236
+ ids_file.puts("#{local_id},#{new_id}")
237
+ ids_file.flush
238
+ inserted += 1
239
+ rescue Sequel::ForeignKeyConstraintViolation => e
240
+ log.warn " #{table}: FK violation on local_id=#{local_id} — #{e.message.lines.first.strip} — skipping"
241
+ skipped += 1
242
+ rescue Sequel::UniqueConstraintViolation => e
243
+ log.warn " #{table}: duplicate on local_id=#{local_id} — #{e.message.lines.first.strip} — skipping"
244
+ skipped += 1
245
+ rescue Sequel::DatabaseError => e
246
+ log.error " #{table}: DB error on local_id=#{local_id} — #{e.message.lines.first.strip} — skipping"
247
+ skipped += 1
248
+ end
249
+ end
250
+
251
+ # Progress logging every 100 rows
252
+ next unless ((inserted + skipped) % 100).zero?
253
+
254
+ elapsed = (Time.now - start_time).round(1)
255
+ rate = ((inserted + skipped) / [elapsed, 0.1].max).round(1)
256
+ log.info " #{table}: progress #{inserted + skipped}/#{count} (inserted=#{inserted} skipped=#{skipped}) #{elapsed}s #{rate} rows/s"
257
+ end
258
+ end
259
+
260
+ # Write final summary manifest
261
+ manifest = {
262
+ table: table.to_s,
263
+ started_at: start_time.iso8601,
264
+ completed_at: Time.now.iso8601,
265
+ inserted_count: inserted,
266
+ skipped_count: skipped,
267
+ interrupted: MigrationState.shutdown,
268
+ ids_file: ids_path
269
+ }
270
+ File.write(manifest_path, JSON.pretty_generate(manifest))
271
+
272
+ elapsed = (Time.now - start_time).round(1)
273
+ log.info " #{table}: done inserted=#{inserted} skipped=#{skipped} elapsed=#{elapsed}s"
274
+ log.info " #{table}: manifest → #{manifest_path}"
275
+ log.info " #{table}: IDs → #{ids_path}"
276
+ end
277
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/ParameterLists
278
+
279
+ identity_maps = { principals: principal_id_map, identities: identity_id_map }
280
+
281
+ # --- llm_conversations ---
282
+ conversation_id_map = {}
283
+ unless MigrationState.shutdown
284
+ log.info '--- Phase 4: llm_conversations ---'
285
+ migrate_table(LOCAL, PROD, :llm_conversations, conversation_id_map,
286
+ identity_maps: identity_maps, log: log, dry_run: dry_run)
287
+ end
288
+
289
+ # --- llm_message_inference_requests ---
290
+ request_id_map = {}
291
+ unless MigrationState.shutdown
292
+ log.info '--- Phase 5: llm_message_inference_requests ---'
293
+ migrate_table(LOCAL, PROD, :llm_message_inference_requests, request_id_map,
294
+ fk_maps: { conversation_id: conversation_id_map, latest_message_id: {} },
295
+ identity_maps: identity_maps, log: log, dry_run: dry_run)
296
+ end
297
+
298
+ # --- llm_message_inference_responses ---
299
+ response_id_map = {}
300
+ unless MigrationState.shutdown
301
+ log.info '--- Phase 6: llm_message_inference_responses ---'
302
+ migrate_table(LOCAL, PROD, :llm_message_inference_responses, response_id_map,
303
+ fk_maps: { message_inference_request_id: request_id_map, response_message_id: {} },
304
+ identity_maps: identity_maps, log: log, dry_run: dry_run)
305
+ end
306
+
307
+ # --- llm_message_inference_metrics ---
308
+ metrics_id_map = {}
309
+ unless MigrationState.shutdown
310
+ log.info '--- Phase 7: llm_message_inference_metrics ---'
311
+ migrate_table(LOCAL, PROD, :llm_message_inference_metrics, metrics_id_map,
312
+ fk_maps: { message_inference_request_id: request_id_map,
313
+ message_inference_response_id: response_id_map },
314
+ identity_maps: identity_maps, log: log, dry_run: dry_run)
315
+ end
316
+
317
+ # --- llm_messages ---
318
+ message_id_map = {}
319
+ unless MigrationState.shutdown
320
+ log.info '--- Phase 8: llm_messages ---'
321
+ migrate_table(LOCAL, PROD, :llm_messages, message_id_map,
322
+ fk_maps: { conversation_id: conversation_id_map,
323
+ message_inference_request_id: request_id_map,
324
+ message_inference_response_id: response_id_map,
325
+ parent_message_id: message_id_map },
326
+ identity_maps: identity_maps, log: log, dry_run: dry_run)
327
+ end
328
+
329
+ # --- llm_tool_calls ---
330
+ tool_call_id_map = {}
331
+ unless MigrationState.shutdown
332
+ log.info '--- Phase 9: llm_tool_calls ---'
333
+ migrate_table(LOCAL, PROD, :llm_tool_calls, tool_call_id_map,
334
+ fk_maps: { conversation_id: conversation_id_map,
335
+ message_inference_response_id: response_id_map,
336
+ requested_by_message_id: message_id_map,
337
+ result_message_id: message_id_map },
338
+ identity_maps: identity_maps, log: log, dry_run: dry_run)
339
+ end
340
+
341
+ # --- llm_tool_call_attempts ---
342
+ tool_attempt_id_map = {}
343
+ unless MigrationState.shutdown
344
+ log.info '--- Phase 10: llm_tool_call_attempts ---'
345
+ migrate_table(LOCAL, PROD, :llm_tool_call_attempts, tool_attempt_id_map,
346
+ fk_maps: { tool_call_id: tool_call_id_map },
347
+ identity_maps: identity_maps, log: log, dry_run: dry_run)
348
+ end
349
+
350
+ # --- Update latest_message_id and response_message_id now that messages exist ---
351
+ unless dry_run || MigrationState.shutdown
352
+ log.info '--- Phase 11: Backfill deferred FK columns ---'
353
+
354
+ backfilled = 0
355
+ LOCAL[:llm_message_inference_requests].where(Sequel.~(latest_message_id: nil)).each do |row|
356
+ break if MigrationState.shutdown
357
+
358
+ prod_req_id = request_id_map[row[:id]]
359
+ prod_msg_id = message_id_map[row[:latest_message_id]]
360
+ next unless prod_req_id && prod_msg_id
361
+
362
+ PROD[:llm_message_inference_requests].where(id: prod_req_id).update(latest_message_id: prod_msg_id)
363
+ backfilled += 1
364
+ end
365
+
366
+ LOCAL[:llm_message_inference_responses].where(Sequel.~(response_message_id: nil)).each do |row|
367
+ break if MigrationState.shutdown
368
+
369
+ prod_resp_id = response_id_map[row[:id]]
370
+ prod_msg_id = message_id_map[row[:response_message_id]]
371
+ next unless prod_resp_id && prod_msg_id
372
+
373
+ PROD[:llm_message_inference_responses].where(id: prod_resp_id).update(response_message_id: prod_msg_id)
374
+ backfilled += 1
375
+ end
376
+
377
+ log.info " Deferred FK backfill complete: #{backfilled} updates"
378
+ end
379
+
380
+ # --- Summary ---
381
+ log.info '=== Migration Summary ==='
382
+ log.info " Providers: #{provider_id_map.size}"
383
+ log.info " Principals: #{principal_id_map.size}"
384
+ log.info " Identities: #{identity_id_map.size}"
385
+ log.info " Conversations: #{conversation_id_map.size}"
386
+ log.info " Requests: #{request_id_map.size}"
387
+ log.info " Responses: #{response_id_map.size}"
388
+ log.info " Metrics: #{metrics_id_map.size}"
389
+ log.info " Messages: #{message_id_map.size}"
390
+ log.info " Tool calls: #{tool_call_id_map.size}"
391
+ log.info " Tool attempts: #{tool_attempt_id_map.size}"
392
+ log.info " Manifests: #{MANIFEST_DIR}/"
393
+ log.info dry_run ? '[DRY RUN COMPLETE]' : '[MIGRATION COMPLETE]'
394
+
395
+ log.info ' To rollback: bundle exec exe/rollback_ledger'
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Rollback a ledger migration using the manifests generated by replay_ledger.
5
+ #
6
+ # Usage:
7
+ # bundle exec exe/rollback_ledger
8
+ # ROLLBACK_DRY_RUN=true bundle exec exe/rollback_ledger (show what would be deleted)
9
+
10
+ require 'sequel'
11
+ require 'logger'
12
+ require 'uri'
13
+ require 'json'
14
+
15
+ log = Logger.new($stdout)
16
+ log.level = Logger::INFO
17
+
18
+ dry_run = ENV['ROLLBACK_DRY_RUN'] == 'true'
19
+ manifest_dir = ENV.fetch('MANIFEST_DIR', '/tmp/legion_migration_manifests')
20
+
21
+ abort "No manifest directory at #{manifest_dir} — nothing to rollback" unless File.directory?(manifest_dir)
22
+
23
+ # Read prod creds
24
+ prod_settings_path = File.expand_path('~/.legionio/settings/z_data_override.json')
25
+ abort "Missing #{prod_settings_path}" unless File.exist?(prod_settings_path)
26
+ prod_config = JSON.parse(File.read(prod_settings_path))
27
+ prod_creds = prod_config.dig('data', 'creds') || abort("No data.creds in #{prod_settings_path}")
28
+ prod_url = "postgres://#{prod_creds['user']}:#{URI.encode_www_form_component(prod_creds['password'])}@#{prod_creds['host']}/#{prod_creds['database']}"
29
+
30
+ PROD = Sequel.connect(prod_url)
31
+ log.info "Prod: postgres://#{prod_creds['user']}:***@#{prod_creds['host']}/#{prod_creds['database']}"
32
+ log.info "Mode: #{dry_run ? 'DRY RUN' : 'LIVE ROLLBACK'}"
33
+ log.info "Manifests: #{manifest_dir}"
34
+
35
+ # Delete in reverse FK order
36
+ TABLES_REVERSE = %w[
37
+ llm_tool_call_attempts
38
+ llm_tool_calls
39
+ llm_messages
40
+ llm_message_inference_metrics
41
+ llm_message_inference_responses
42
+ llm_message_inference_requests
43
+ llm_conversations
44
+ ].freeze
45
+
46
+ total_deleted = 0
47
+
48
+ TABLES_REVERSE.each do |table|
49
+ ids_file = File.join(manifest_dir, "#{table}_ids.jsonl")
50
+ unless File.exist?(ids_file)
51
+ log.info " #{table}: no IDs file, skipping"
52
+ next
53
+ end
54
+
55
+ # Read prod IDs (second column in "local_id,prod_id" format)
56
+ ids = File.readlines(ids_file, chomp: true).filter_map do |line|
57
+ next if line.strip.empty?
58
+
59
+ line.split(',')[1]&.to_i
60
+ end
61
+
62
+ if ids.empty?
63
+ log.info " #{table}: 0 inserted IDs, skipping"
64
+ next
65
+ end
66
+
67
+ log.info " #{table}: #{ids.size} rows to delete..."
68
+
69
+ if dry_run
70
+ log.info " [DRY] Would delete #{ids.size} rows from #{table} (first 5 IDs: #{ids.first(5).join(',')})"
71
+ else
72
+ ids.each_slice(500) do |batch|
73
+ deleted = PROD[table.to_sym].where(id: batch).delete
74
+ total_deleted += deleted
75
+ end
76
+ log.info " #{table}: deleted #{ids.size} rows"
77
+ end
78
+ end
79
+
80
+ # Also handle deferred FK backfills (latest_message_id, response_message_id)
81
+ # These were UPDATEs, not INSERTs — set them back to NULL
82
+ unless dry_run
83
+ requests_manifest = File.join(manifest_dir, 'llm_message_inference_requests.json')
84
+ if File.exist?(requests_manifest)
85
+ JSON.parse(File.read(requests_manifest))['inserted_ids'] || []
86
+ # These were inserted rows that got deleted above, so no backfill undo needed
87
+ end
88
+ end
89
+
90
+ log.info "=== Rollback #{dry_run ? 'Preview' : 'Complete'} ==="
91
+ log.info " Total deleted: #{dry_run ? '(dry run)' : total_deleted}"
@@ -166,11 +166,16 @@ module Legion
166
166
  written = []
167
167
  skipped = []
168
168
 
169
+ llm_installed, = partition_gems(PACKS[:llm][:gems])
170
+ out.warn('LLM pack not installed. Run: legionio setup llm') if llm_installed.empty? && !options[:json]
171
+
169
172
  write_codex_config(base_url, written, skipped)
170
- write_claude_code_proxy_config(base_url, written, skipped)
173
+ # write_claude_code_proxy_config(base_url, written, skipped) # too destructive for enterprise users
174
+ write_zsh_legionio(base_url, written, skipped)
175
+ write_pack_marker(:'proxy-mode')
171
176
 
172
177
  if options[:json]
173
- out.json(written: written, skipped: skipped, base_url: base_url)
178
+ out.json(written: written, skipped: skipped, base_url: base_url, profile: 'legionio')
174
179
  else
175
180
  out.spacer
176
181
  out.success("LegionIO proxy mode configured (#{written.size} written, #{skipped.size} skipped)")
@@ -178,8 +183,13 @@ module Legion
178
183
  skipped.each { |f| puts " Skipped (already exists, use --force to overwrite): #{f}" }
179
184
  out.spacer
180
185
  puts " LegionIO API: #{base_url.sub('/v1', '')}"
181
- puts ' Codex CLI: legion llm proxy (uses ~/.codex/config.toml)'
186
+ puts ' Codex CLI: codex --profile legionio'
182
187
  puts ' Claude Code: set ANTHROPIC_BASE_URL in your shell or ~/.claude/settings.json'
188
+ if written.any? { |f| f.end_with?('.zsh_legionio') }
189
+ out.spacer
190
+ puts ' To activate shell functions in this session, run:'
191
+ puts ' source ~/.zsh_legionio'
192
+ end
183
193
  out.spacer
184
194
  end
185
195
  end
@@ -313,6 +323,7 @@ module Legion
313
323
  end
314
324
 
315
325
  write_python_marker(python3, packages)
326
+ write_pack_marker(:python)
316
327
 
317
328
  if options[:json]
318
329
  out.json(venv: PYTHON_VENV_DIR, python: python_version(python3), results: results)
@@ -339,8 +350,15 @@ module Legion
339
350
  missing: missing }
340
351
  end
341
352
 
353
+ python_status = { name: :python, description: 'Python venv + document/data packages',
354
+ installed: File.exist?(PYTHON_MARKER), missing: [] }
355
+ proxy_status = { name: :'proxy-mode', description: 'Codex CLI + shell helper functions for LegionIO proxy',
356
+ installed: File.exist?(File.expand_path('~/.legionio/.packs/proxy-mode')), missing: [] }
357
+
342
358
  if options[:json]
343
- out.json(packs: pack_statuses)
359
+ out.json(packs: pack_statuses,
360
+ python: python_status.slice(:name, :description, :installed),
361
+ proxy_mode: proxy_status.slice(:name, :description, :installed))
344
362
  else
345
363
  out.header('Feature Packs')
346
364
  out.spacer
@@ -355,6 +373,10 @@ module Legion
355
373
  puts " #{out.colorize(g, :muted)} (missing)"
356
374
  end
357
375
  end
376
+ [python_status, proxy_status].each do |ps|
377
+ icon = ps[:installed] ? out.colorize('installed', :success) : out.colorize('not installed', :muted)
378
+ puts " #{out.colorize(ps[:name].to_s.ljust(12), :label)} #{icon} #{ps[:description]}"
379
+ end
358
380
  out.spacer
359
381
  end
360
382
  end
@@ -742,31 +764,148 @@ module Legion
742
764
  end
743
765
 
744
766
  def write_codex_config(base_url, written, skipped)
745
- codex_dir = File.expand_path('~/.codex')
746
- codex_path = File.join(codex_dir, 'config.toml')
767
+ codex_dir = File.expand_path('~/.codex')
768
+ FileUtils.mkdir_p(codex_dir)
769
+
770
+ write_codex_profile(codex_dir, base_url, written, skipped)
771
+ write_codex_catalog(codex_dir, written, skipped)
772
+ write_codex_main_config(codex_dir, base_url, written, skipped)
773
+ end
774
+
775
+ def write_codex_profile(codex_dir, base_url, written, skipped)
776
+ profile_path = File.join(codex_dir, 'legionio.config.toml')
747
777
 
748
- if File.exist?(codex_path) && !options[:force]
749
- skipped << codex_path
778
+ if File.exist?(profile_path) && !options[:force]
779
+ skipped << profile_path
750
780
  return
751
781
  end
752
782
 
753
- FileUtils.mkdir_p(codex_dir)
754
-
783
+ catalog_path = File.join(codex_dir, 'legionio-catalog.json')
755
784
  content = <<~TOML
756
785
  model = "legionio"
757
- model_provider = "legion"
786
+ model_provider = "legionio"
787
+ model_catalog_json = "#{catalog_path}"
788
+
789
+ [model_providers.legionio]
790
+ name = "LegionIO"
791
+ api_key = "legion"
792
+ base_url = "#{base_url}"
793
+ wire_api = "responses"
794
+ TOML
795
+
796
+ File.write(profile_path, content)
797
+ written << profile_path
798
+ rescue StandardError => e
799
+ raise Thor::Error, "Failed to write #{profile_path}: #{e.message}"
800
+ end
758
801
 
759
- [model_providers.legion]
802
+ def write_codex_catalog(codex_dir, written, skipped)
803
+ catalog_path = File.join(codex_dir, 'legionio-catalog.json')
804
+
805
+ if File.exist?(catalog_path) && !options[:force]
806
+ skipped << catalog_path
807
+ return
808
+ end
809
+
810
+ catalog = {
811
+ models: [
812
+ {
813
+ slug: 'legionio',
814
+ display_name: 'LegionIO',
815
+ context_window: 262_144,
816
+ context_size: 262_144
817
+ },
818
+ {
819
+ slug: 'auto',
820
+ display_name: 'LegionIO (auto)',
821
+ context_window: 262_144,
822
+ context_size: 262_144
823
+ }
824
+ ]
825
+ }
826
+
827
+ File.write(catalog_path, ::JSON.pretty_generate(catalog))
828
+ written << catalog_path
829
+ rescue StandardError => e
830
+ raise Thor::Error, "Failed to write #{catalog_path}: #{e.message}"
831
+ end
832
+
833
+ def write_codex_main_config(codex_dir, base_url, written, _skipped)
834
+ config_path = File.join(codex_dir, 'config.toml')
835
+ existing = File.exist?(config_path) ? File.read(config_path) : ''
836
+
837
+ # Upsert [model_providers.legionio] block so the provider appears in the
838
+ # model picker in both the CLI and the Codex Mac app.
839
+ # No profile = line (removed by Codex). No model_catalog_json at top level
840
+ # (Codex enforces a strict schema with required fields that breaks the app).
841
+ provider_block = <<~TOML
842
+
843
+ [model_providers.legionio]
760
844
  name = "LegionIO"
761
- env_key = "LEGION_API_KEY"
845
+ api_key = "legion"
762
846
  base_url = "#{base_url}"
763
847
  wire_api = "responses"
764
848
  TOML
765
849
 
766
- File.write(codex_path, content)
767
- written << codex_path
850
+ updated = existing.dup
851
+
852
+ # Only match uncommented [model_providers.legionio] section headers
853
+ updated = if updated.match?(/^\[model_providers\.legionio\]/)
854
+ updated.gsub(
855
+ /^\[model_providers\.legionio\].*?(?=\n\[|\z)/m,
856
+ provider_block.lstrip
857
+ )
858
+ else
859
+ "#{updated.rstrip}\n#{provider_block}"
860
+ end
861
+
862
+ File.write(config_path, updated)
863
+ written << config_path
864
+ rescue StandardError => e
865
+ raise Thor::Error, "Failed to write #{config_path}: #{e.message}"
866
+ end
867
+
868
+ def write_zsh_legionio(base_url, written, _skipped)
869
+ zshrc_path = File.expand_path('~/.zshrc')
870
+ return unless File.exist?(zshrc_path)
871
+
872
+ host_base = base_url.sub(%r{/v1$}, '')
873
+ zsh_file = File.expand_path('~/.zsh_legionio')
874
+
875
+ content = <<~ZSH
876
+ # LegionIO shell helpers — generated by `legionio setup proxy-mode`
877
+ # Re-run to update; do not edit manually.
878
+
879
+ claude-legionio() {
880
+ export ANTHROPIC_BASE_URL=#{host_base}
881
+ export ANTHROPIC_API_KEY=legion
882
+ export ANTHROPIC_AUTH_TOKEN=
883
+ export CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1
884
+ export CLAUDE_CODE_USE_BEDROCK=
885
+ export AWS_PROFILE=
886
+ export AWS_REGION=
887
+ unset ANTHROPIC_DEFAULT_OPUS_MODEL
888
+ unset ANTHROPIC_DEFAULT_SONNET_MODEL
889
+ unset ANTHROPIC_DEFAULT_HAIKU_MODEL
890
+ claude --model legionio "$@"
891
+ }
892
+
893
+ codex-legionio() {
894
+ codex --profile legionio "$@"
895
+ }
896
+ ZSH
897
+
898
+ File.write(zsh_file, content)
899
+ written << zsh_file
900
+
901
+ source_line = '[ -f ~/.zsh_legionio ] && source ~/.zsh_legionio'
902
+ zshrc = File.read(zshrc_path)
903
+ unless zshrc.include?(source_line)
904
+ File.write(zshrc_path, "#{zshrc.rstrip}\n\n#{source_line}\n")
905
+ written << zshrc_path
906
+ end
768
907
  rescue StandardError => e
769
- raise Thor::Error, "Failed to write #{codex_path}: #{e.message}"
908
+ raise Thor::Error, "Failed to write zsh config: #{e.message}"
770
909
  end
771
910
 
772
911
  def write_claude_code_proxy_config(base_url, written, skipped)
@@ -72,7 +72,7 @@ module Legion
72
72
  )
73
73
  Legion::Logging.error "[TraceSearch] LLM filter generation failed for query: #{query.inspect}" if !result[:valid] && defined?(Legion::Logging)
74
74
  result[:data] if result[:valid]
75
- rescue Legion::LLM::LLMError => e
75
+ rescue StandardError => e
76
76
  handle_exception(e, level: :debug, handled: true, operation: 'trace_search.generate_filter') if respond_to?(:handle_exception)
77
77
  nil
78
78
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Legion
4
- VERSION = '1.9.39'
4
+ VERSION = '1.9.41'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: legionio
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.9.39
4
+ version: 1.9.41
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -431,6 +431,8 @@ files:
431
431
  - docker_deploy.rb
432
432
  - exe/legion
433
433
  - exe/legionio
434
+ - exe/replay_ledger
435
+ - exe/rollback_ledger
434
436
  - extensions-agentic/lex-consent/db/migrations/001_create_consent_maps.rb
435
437
  - extensions-agentic/lex-consent/lex-consent.gemspec
436
438
  - extensions-agentic/lex-consent/lib/legion/extensions/agentic/consent.rb