1use anyhow::{Result, anyhow, ensure};
2use rusqlite::{Connection, OptionalExtension, Transaction, TransactionBehavior, params};
3use serde_json::Value;
4use sha2::{Digest, Sha256};
5use std::collections::{BTreeMap, BTreeSet};
6use std::fs;
7use std::path::{Path, PathBuf};
8use uuid::Uuid;
9
10use super::{
11 BindingCandidate, BindingValue, STRATEGY_SCHEMA_VERSION, StrategyAuthorization,
12 StrategyDefinition, StrategyDefinitionSummary, StrategyDiagnostic, StrategyProjection,
13};
14use crate::ASSISTANT_TEMPORARY_DEFINITION_PREFIX;
15use licoup_foundation::core::sqlite_contract::ColumnVariant;
16use licoup_workflow::{
17 BindingKind, CommandStatus, FailureClass, GraphState, GraphStateKind, ReducerEvent, RunCommand,
18 RunSnapshot, StrategyRunStatus, Transition, TransitionEvent, TransitionMode,
19 WorkflowDefinition, compile_workflow, reduce,
20};
21
22const DATABASE_FILE: &str = "strategies.sqlite3";
23
24#[derive(Clone, Debug)]
25pub struct StrategyStore {
26 db_path: PathBuf,
27 package_revisions_root: Option<PathBuf>,
28}
29
30impl StrategyStore {
31 pub fn open(portable_root: &Path) -> Result<Self> {
32 let root = portable_root.join("client-state").join("adaptive-flywheel");
33 let db_path = root.join(DATABASE_FILE);
34 preflight_existing_store(&db_path)?;
35 licoup_foundation::platform::file_security::ensure_private_dir(
36 &portable_root.join("client-state"),
37 )?;
38 licoup_foundation::platform::file_security::ensure_private_dir(&root)?;
39 let existed = db_path.exists();
40 let store = Self {
41 db_path,
42 package_revisions_root: Some(root.join("strategy-packages").join("revisions")),
43 };
44 let retired = store.with_connection(|connection| {
45 if existed {
46 validate_current_schema(connection).map(|()| Vec::new())
47 } else {
48 initialize_schema(connection)
49 }
50 })?;
51 licoup_foundation::platform::file_security::harden_private_path(&store.db_path)?;
52 store.remove_retired_revision_trees(&retired);
53 Ok(store)
54 }
55
56 pub fn open_for_migration(portable_root: &Path) -> Result<Self> {
57 let root = portable_root.join("client-state").join("adaptive-flywheel");
58 licoup_foundation::platform::file_security::ensure_private_dir(
62 &portable_root.join("client-state"),
63 )?;
64 licoup_foundation::platform::file_security::ensure_private_dir(&root)?;
65 let store = Self {
66 db_path: root.join(DATABASE_FILE),
67 package_revisions_root: Some(root.join("strategy-packages").join("revisions")),
68 };
69 let retired = store.with_connection(initialize_schema)?;
70 licoup_foundation::platform::file_security::harden_private_path(&store.db_path)?;
71 store.remove_retired_revision_trees(&retired);
72 Ok(store)
73 }
74
75 pub fn open_in_memory() -> Result<Self> {
76 let path =
77 std::env::temp_dir().join(format!("lico-adaptive-flywheel-{}.sqlite3", Uuid::new_v4()));
78 let store = Self {
79 db_path: path,
80 package_revisions_root: None,
81 };
82 store.with_connection(initialize_schema)?;
83 Ok(store)
84 }
85
86 pub fn db_path(&self) -> &Path {
87 &self.db_path
88 }
89
90 pub fn durable_queue(&self) -> super::DurableQueue {
91 super::DurableQueue::from_store(self.clone())
92 }
93
94 pub fn durable_subscriptions(&self) -> super::DurableSubscriptionStore {
95 super::DurableSubscriptionStore::from_store(self.clone())
96 }
97
98 pub fn durable_control(&self) -> super::DurableControlledStore {
99 super::DurableControlledStore::from_store(self.clone())
100 }
101
102 pub fn with_connection<T>(
103 &self,
104 operation: impl FnOnce(&mut Connection) -> Result<T>,
105 ) -> Result<T> {
106 self.with_connection_typed(operation)
107 }
108
109 pub(crate) fn with_connection_typed<T, E>(
110 &self,
111 operation: impl FnOnce(&mut Connection) -> std::result::Result<T, E>,
112 ) -> std::result::Result<T, E>
113 where
114 E: From<anyhow::Error>,
115 {
116 let mut connection = Connection::open(&self.db_path)
117 .map_err(|_| E::from(anyhow!("strategy_database_open_failed")))?;
118 configure_connection(&connection).map_err(E::from)?;
119 operation(&mut connection)
120 }
121
122 pub fn register_definition(
123 &self,
124 revision_digest: &str,
125 semantics_digest: &str,
126 workflow: &WorkflowDefinition,
127 asset_count: usize,
128 imported_at_unix_ms: i64,
129 ) -> Result<StrategyDefinition> {
130 let compiled = compile_workflow(workflow.clone())?;
131 let workflow_json = serde_json::to_string(compiled.definition())?;
132 self.with_connection(|connection| {
133 let transaction =
134 connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
135 transaction.execute(
136 "INSERT INTO strategy_definitions(
137 definition_id, revision_digest, semantics_digest, name, version,
138 workflow_json, asset_count, imported_at
139 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
140 ON CONFLICT(revision_digest) DO NOTHING",
141 params![
142 compiled.definition().metadata.id,
143 revision_digest,
144 semantics_digest,
145 compiled.definition().metadata.name,
146 compiled.definition().metadata.version,
147 workflow_json,
148 asset_count as i64,
149 imported_at_unix_ms,
150 ],
151 )?;
152 let existing: Option<(String, String)> = transaction
153 .query_row(
154 "SELECT definition_id, semantics_digest FROM strategy_definitions
155 WHERE revision_digest=?1",
156 params![revision_digest],
157 |row| Ok((row.get(0)?, row.get(1)?)),
158 )
159 .optional()?;
160 ensure!(
161 existing
162 == Some((
163 compiled.definition().metadata.id.clone(),
164 semantics_digest.to_owned()
165 )),
166 "strategy_revision_conflict"
167 );
168 transaction.commit()?;
169 self.definition_by_revision(revision_digest)
170 })
171 }
172
173 pub fn list_definitions(&self) -> Result<Vec<StrategyDefinitionSummary>> {
174 self.with_connection(|connection| {
175 let mut statement = connection.prepare(
176 "SELECT definition_id, revision_digest, semantics_digest, name, version, imported_at,
177 EXISTS(
178 SELECT 1 FROM strategy_authorizations
179 WHERE revision_digest=strategy_definitions.revision_digest AND active=1
180 )
181 FROM strategy_definitions
182 WHERE definition_id NOT LIKE 'assistant-temporary%'
183 ORDER BY imported_at DESC, revision_digest ASC",
184 )?;
185 let rows = statement.query_map([], summary_from_row)?;
186 rows.collect::<rusqlite::Result<Vec<_>>>()
187 .map_err(Into::into)
188 })
189 }
190
191 pub fn definition_by_revision(&self, revision_digest: &str) -> Result<StrategyDefinition> {
192 self.with_connection(|connection| definition_by_revision(connection, revision_digest))
193 }
194
195 pub fn latest_definition(&self, definition_id: &str) -> Result<StrategyDefinition> {
196 self.with_connection(|connection| {
197 let revision: Option<String> = connection
198 .query_row(
199 "SELECT revision_digest FROM strategy_definitions
200 WHERE definition_id=?1 ORDER BY imported_at DESC, revision_digest ASC LIMIT 1",
201 params![definition_id],
202 |row| row.get(0),
203 )
204 .optional()?;
205 definition_by_revision(
206 connection,
207 revision
208 .as_deref()
209 .ok_or_else(|| anyhow!("strategy_definition_not_found"))?,
210 )
211 })
212 }
213
214 pub fn update_binding(
215 &self,
216 revision_digest: &str,
217 slot_id: &str,
218 value_id: &str,
219 model: &str,
220 reasoning_effort: &str,
221 expected_revision: Option<u64>,
222 ) -> Result<BindingValue> {
223 self.replace_slot_bindings(
224 revision_digest,
225 slot_id,
226 &[BindingCandidate {
227 value_id: value_id.to_owned(),
228 model: model.to_owned(),
229 reasoning_effort: reasoning_effort.to_owned(),
230 }],
231 expected_revision,
232 )?
233 .into_iter()
234 .next()
235 .ok_or_else(|| anyhow!("strategy_binding_incomplete"))
236 }
237
238 pub fn replace_slot_bindings(
239 &self,
240 revision_digest: &str,
241 slot_id: &str,
242 candidates: &[BindingCandidate],
243 expected_revision: Option<u64>,
244 ) -> Result<Vec<BindingValue>> {
245 validate_opaque_id(slot_id, "strategy_binding_slot_invalid")?;
246 ensure!(candidates.len() <= 16, "strategy_binding_limit");
247 for candidate in candidates {
248 validate_opaque_id(&candidate.value_id, "strategy_binding_value_invalid")?;
249 validate_optional_text(&candidate.model, "strategy_binding_model_invalid")?;
250 validate_optional_text(
251 &candidate.reasoning_effort,
252 "strategy_binding_reasoning_effort_invalid",
253 )?;
254 }
255 let definition = self.definition_by_revision(revision_digest)?;
256 ensure!(
257 definition
258 .workflow
259 .actor_slots
260 .iter()
261 .any(|slot| slot.id == slot_id),
262 "strategy_binding_slot_unknown"
263 );
264 self.with_connection(|connection| {
265 let transaction =
266 connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
267 let current: Option<u64> = transaction
268 .query_row(
269 "SELECT MAX(revision) FROM strategy_bindings
270 WHERE revision_digest=?1 AND slot_id=?2",
271 params![revision_digest, slot_id],
272 |row| row.get::<_, Option<i64>>(0),
273 )
274 .optional()?
275 .flatten()
276 .map(|value| value as u64);
277 if let Some(expected) = expected_revision {
278 ensure!(current.unwrap_or(0) == expected, "strategy_revision_conflict");
279 }
280 let revision = current.unwrap_or(0) + 1;
281 transaction.execute(
282 "DELETE FROM strategy_bindings WHERE revision_digest=?1 AND slot_id=?2",
283 params![revision_digest, slot_id],
284 )?;
285 let mut values = Vec::with_capacity(candidates.len());
286 for (ordinal, candidate) in candidates.iter().enumerate() {
287 let ordinal = u8::try_from(ordinal).map_err(|_| anyhow!("strategy_binding_limit"))?;
288 transaction.execute(
289 "INSERT INTO strategy_bindings(
290 revision_digest, slot_id, ordinal, value_id, model, reasoning_effort, revision
291 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
292 params![
293 revision_digest,
294 slot_id,
295 ordinal as i64,
296 candidate.value_id,
297 candidate.model,
298 candidate.reasoning_effort,
299 revision as i64
300 ],
301 )?;
302 values.push(BindingValue {
303 slot_id: slot_id.to_owned(),
304 ordinal,
305 value_id: candidate.value_id.clone(),
306 model: candidate.model.clone(),
307 reasoning_effort: candidate.reasoning_effort.clone(),
308 revision,
309 });
310 }
311 transaction.execute(
312 "UPDATE strategy_authorizations SET active=0 WHERE revision_digest=?1 AND active=1",
313 params![revision_digest],
314 )?;
315 transaction.commit()?;
316 Ok(values)
317 })
318 }
319
320 pub fn remove_binding(
321 &self,
322 revision_digest: &str,
323 slot_id: &str,
324 expected_revision: Option<u64>,
325 ) -> Result<()> {
326 validate_opaque_id(slot_id, "strategy_binding_slot_invalid")?;
327 self.with_connection(|connection| {
328 let transaction =
329 connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
330 let current: Option<u64> = transaction
331 .query_row(
332 "SELECT MAX(revision) FROM strategy_bindings
333 WHERE revision_digest=?1 AND slot_id=?2",
334 params![revision_digest, slot_id],
335 |row| row.get::<_, Option<i64>>(0),
336 )
337 .optional()?
338 .flatten()
339 .map(|value| value as u64);
340 if let Some(expected) = expected_revision {
341 ensure!(
342 current.unwrap_or(0) == expected,
343 "strategy_revision_conflict"
344 );
345 }
346 transaction.execute(
347 "DELETE FROM strategy_bindings WHERE revision_digest=?1 AND slot_id=?2",
348 params![revision_digest, slot_id],
349 )?;
350 transaction.execute(
351 "UPDATE strategy_authorizations SET active=0
352 WHERE revision_digest=?1 AND active=1",
353 params![revision_digest],
354 )?;
355 transaction.commit()?;
356 Ok(())
357 })
358 }
359
360 pub fn authorization_preview(&self, revision_digest: &str) -> Result<StrategyAuthorization> {
361 let definition = self.definition_by_revision(revision_digest)?;
362 ensure!(
363 bindings_complete(&definition),
364 "strategy_binding_incomplete"
365 );
366 let binding_digest = binding_digest(&definition.bindings)?;
367 let next_revision = definition
368 .authorization
369 .as_ref()
370 .map_or(1, |authorization| authorization.revision + 1);
371 let authorization_digest = authorization_digest(
372 revision_digest,
373 &definition.summary.semantics_digest,
374 &binding_digest,
375 next_revision,
376 );
377 Ok(StrategyAuthorization {
378 definition_digest: revision_digest.to_owned(),
379 semantics_digest: definition.summary.semantics_digest,
380 binding_digest,
381 authorization_digest,
382 revision: next_revision,
383 active: false,
384 })
385 }
386
387 pub fn grant_authorization(
388 &self,
389 revision_digest: &str,
390 expected_authorization_digest: &str,
391 ) -> Result<StrategyAuthorization> {
392 self.with_connection(|connection| {
393 let transaction =
394 connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
395 let definition = definition_by_revision(&transaction, revision_digest)?;
396 ensure!(
397 bindings_complete(&definition),
398 "strategy_binding_incomplete"
399 );
400 let binding_digest = binding_digest(&definition.bindings)?;
401 let next_revision = definition
402 .authorization
403 .as_ref()
404 .map_or(1, |authorization| authorization.revision + 1);
405 let authorization_digest = authorization_digest(
406 revision_digest,
407 &definition.summary.semantics_digest,
408 &binding_digest,
409 next_revision,
410 );
411 ensure!(
412 authorization_digest == expected_authorization_digest,
413 "strategy_authorization_stale"
414 );
415 transaction.execute(
416 "UPDATE strategy_authorizations SET active=0 WHERE revision_digest=?1 AND active=1",
417 params![revision_digest],
418 )?;
419 transaction.execute(
420 "INSERT INTO strategy_authorizations(
421 revision_digest, revision, semantics_digest, binding_digest,
422 authorization_digest, active, created_at
423 ) VALUES (?1, ?2, ?3, ?4, ?5, 1, ?6)",
424 params![
425 revision_digest,
426 next_revision as i64,
427 definition.summary.semantics_digest,
428 binding_digest,
429 authorization_digest,
430 now_ms(),
431 ],
432 )?;
433 transaction.commit()?;
434 Ok(StrategyAuthorization {
435 definition_digest: revision_digest.to_owned(),
436 semantics_digest: definition.summary.semantics_digest,
437 binding_digest,
438 authorization_digest,
439 revision: next_revision,
440 active: true,
441 })
442 })
443 }
444
445 pub fn revoke_authorization(&self, revision_digest: &str) -> Result<()> {
446 self.with_connection(|connection| {
447 connection.execute(
448 "UPDATE strategy_authorizations SET active=0 WHERE revision_digest=?1 AND active=1",
449 params![revision_digest],
450 )?;
451 Ok(())
452 })
453 }
454
455 pub fn start_run(
456 &self,
457 revision_digest: &str,
458 input: Value,
459 idempotency_key: &str,
460 conversation_id: Option<&str>,
461 cwd: Option<String>,
462 ) -> Result<RunSnapshot> {
463 self.start_run_with_assistant_context(
464 revision_digest,
465 input,
466 idempotency_key,
467 conversation_id,
468 cwd,
469 None,
470 None,
471 )
472 }
473
474 pub(crate) fn start_run_with_assistant_context(
475 &self,
476 revision_digest: &str,
477 input: Value,
478 idempotency_key: &str,
479 conversation_id: Option<&str>,
480 cwd: Option<String>,
481 assistant_membership_id: Option<&str>,
482 route_receipt: Option<Value>,
483 ) -> Result<RunSnapshot> {
484 validate_opaque_id(idempotency_key, "strategy_idempotency_key_invalid")?;
485 if let Some(conversation_id) = conversation_id {
486 validate_opaque_id(conversation_id, "strategy_conversation_id_invalid")?;
487 }
488 if let Some(membership_id) = assistant_membership_id {
489 validate_opaque_id(membership_id, "strategy_membership_id_invalid")?;
490 }
491 let input_bytes = serde_json::to_vec(&input)?;
492 let mut digest_source = Vec::from(revision_digest.as_bytes());
493 digest_source.push(0);
494 digest_source.extend_from_slice(&input_bytes);
495 digest_source.push(0);
496 digest_source.extend_from_slice(conversation_id.unwrap_or("").as_bytes());
497 digest_source.push(0);
498 digest_source.extend_from_slice(cwd.as_deref().unwrap_or("").as_bytes());
499 digest_source.push(0);
500 digest_source.extend_from_slice(assistant_membership_id.unwrap_or("").as_bytes());
501 digest_source.push(0);
502 digest_source.extend_from_slice(&serde_json::to_vec(&route_receipt)?);
503 let request_digest = sha256_hex(&digest_source);
504 self.with_connection(|connection| {
505 let transaction =
506 connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
507 let existing: Option<(String, String)> = transaction
508 .query_row(
509 "SELECT run_id, request_digest FROM strategy_runs WHERE idempotency_key=?1",
510 params![idempotency_key],
511 |row| Ok((row.get(0)?, row.get(1)?)),
512 )
513 .optional()?;
514 if let Some((run_id, existing_digest)) = existing {
515 ensure!(
516 existing_digest == request_digest,
517 "strategy_idempotency_conflict"
518 );
519 let snapshot = load_run(&transaction, &run_id)?;
520 transaction.commit()?;
521 return Ok(snapshot);
522 }
523
524 let definition = definition_by_revision(&transaction, revision_digest)?;
528 ensure!(
529 bindings_complete(&definition),
530 "strategy_binding_incomplete"
531 );
532 let authorization = current_authorization(&definition)?;
533 ensure!(
534 authorization.binding_digest == binding_digest(&definition.bindings)?,
535 "strategy_authorization_stale"
536 );
537 let slot_candidate_counts = slot_candidate_counts(&definition.bindings);
538 let semantics_digest = definition.summary.semantics_digest.clone();
539 let compiled = compile_workflow(definition.workflow)?;
540 let run_id = format!("run-{}", Uuid::new_v4());
541 let empty = RunSnapshot::empty(&run_id, revision_digest, &semantics_digest);
542 let event = ReducerEvent::Start { input };
543 let output = reduce(&compiled, &empty, event.clone())?;
544 let mut snapshot = output.snapshot;
545 snapshot.conversation_id = conversation_id.map(str::to_owned);
546 snapshot.assistant_membership_id = assistant_membership_id.map(str::to_owned);
547 snapshot.route_receipt = route_receipt;
548 snapshot.cwd = cwd;
549 snapshot.slot_candidate_counts = slot_candidate_counts;
550 let now = now_ms();
551 transaction.execute(
552 "INSERT INTO strategy_runs(
553 run_id, revision_digest, semantics_digest, idempotency_key,
554 request_digest, snapshot_json, conversation_id, terminal,
555 created_at, updated_at
556 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9)",
557 params![
558 run_id,
559 revision_digest,
560 semantics_digest,
561 idempotency_key,
562 request_digest,
563 serde_json::to_string(&snapshot)?,
564 snapshot.conversation_id,
565 i64::from(run_is_terminal(snapshot.status)),
566 now,
567 ],
568 )?;
569 persist_event_and_commands(
570 &transaction,
571 &empty,
572 &snapshot,
573 &event,
574 &output.emitted_commands,
575 now,
576 )?;
577 transaction.commit()?;
578 Ok(snapshot)
579 })
580 }
581
582 #[allow(clippy::too_many_arguments)]
587 pub fn admit_assistant_run(
588 &self,
589 revision_digest: &str,
590 semantics_digest: &str,
591 workflow: &WorkflowDefinition,
592 bindings: &[BindingValue],
593 input: Value,
594 idempotency_key: &str,
595 conversation_id: &str,
596 assistant_membership_id: &str,
597 route_receipt: Value,
598 ) -> Result<(RunSnapshot, bool)> {
599 validate_opaque_id(idempotency_key, "strategy_idempotency_key_invalid")?;
600 validate_opaque_id(conversation_id, "strategy_conversation_id_invalid")?;
601 validate_opaque_id(assistant_membership_id, "strategy_membership_id_invalid")?;
602 ensure!(
603 workflow
604 .metadata
605 .id
606 .starts_with(ASSISTANT_TEMPORARY_DEFINITION_PREFIX),
607 "graph_identity_rejected"
608 );
609 let compiled = compile_workflow(workflow.clone())?;
610 let workflow_json = serde_json::to_string(compiled.definition())?;
611 let mut expected_bindings = bindings.to_vec();
612 expected_bindings.sort_by(|left, right| {
613 left.slot_id
614 .cmp(&right.slot_id)
615 .then_with(|| left.ordinal.cmp(&right.ordinal))
616 });
617 for binding in &expected_bindings {
618 validate_opaque_id(&binding.slot_id, "strategy_binding_slot_invalid")?;
619 validate_opaque_id(&binding.value_id, "strategy_binding_value_invalid")?;
620 ensure!(binding.ordinal < 16, "strategy_binding_limit");
621 validate_optional_text(&binding.model, "strategy_binding_model_invalid")?;
622 validate_optional_text(
623 &binding.reasoning_effort,
624 "strategy_binding_reasoning_effort_invalid",
625 )?;
626 }
627 let mut request_source = Vec::from(revision_digest.as_bytes());
628 request_source.push(0);
629 request_source.extend_from_slice(&serde_json::to_vec(&input)?);
630 request_source.push(0);
631 request_source.extend_from_slice(conversation_id.as_bytes());
632 request_source.push(0);
633 request_source.extend_from_slice(assistant_membership_id.as_bytes());
634 request_source.push(0);
635 request_source.extend_from_slice(&serde_json::to_vec(&route_receipt)?);
636 let request_digest = sha256_hex(&request_source);
637 self.with_connection(|connection| {
638 let transaction =
639 connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
640 let existing: Option<(String, String)> = transaction
641 .query_row(
642 "SELECT run_id, request_digest FROM strategy_runs WHERE idempotency_key=?1",
643 params![idempotency_key],
644 |row| Ok((row.get(0)?, row.get(1)?)),
645 )
646 .optional()?;
647 if let Some((run_id, existing_digest)) = existing {
648 ensure!(
649 existing_digest == request_digest,
650 "strategy_idempotency_conflict"
651 );
652 let snapshot = load_run(&transaction, &run_id)?;
653 transaction.commit()?;
654 return Ok((snapshot, false));
655 }
656
657 transaction.execute(
658 "INSERT INTO strategy_definitions(
659 definition_id, revision_digest, semantics_digest, name, version,
660 workflow_json, asset_count, imported_at
661 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0, ?7)
662 ON CONFLICT(revision_digest) DO NOTHING",
663 params![
664 compiled.definition().metadata.id,
665 revision_digest,
666 semantics_digest,
667 compiled.definition().metadata.name,
668 compiled.definition().metadata.version,
669 workflow_json,
670 now_ms(),
671 ],
672 )?;
673 let stored_identity: (String, String, String) = transaction.query_row(
674 "SELECT definition_id, semantics_digest, workflow_json
675 FROM strategy_definitions WHERE revision_digest=?1",
676 params![revision_digest],
677 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
678 )?;
679 ensure!(
680 stored_identity
681 == (
682 compiled.definition().metadata.id.clone(),
683 semantics_digest.to_owned(),
684 serde_json::to_string(compiled.definition())?,
685 ),
686 "strategy_revision_conflict"
687 );
688 for binding in &expected_bindings {
689 transaction.execute(
690 "INSERT OR IGNORE INTO strategy_bindings(
691 revision_digest, slot_id, ordinal, value_id, model,
692 reasoning_effort, revision
693 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1)",
694 params![
695 revision_digest,
696 binding.slot_id,
697 binding.ordinal as i64,
698 binding.value_id,
699 binding.model,
700 binding.reasoning_effort,
701 ],
702 )?;
703 }
704 let definition = definition_by_revision(&transaction, revision_digest)?;
705 let mut stored_bindings = definition.bindings.clone();
706 stored_bindings.sort_by(|left, right| {
707 left.slot_id
708 .cmp(&right.slot_id)
709 .then_with(|| left.ordinal.cmp(&right.ordinal))
710 });
711 ensure!(
712 stored_bindings.len() == expected_bindings.len()
713 && stored_bindings
714 .iter()
715 .zip(&expected_bindings)
716 .all(|(stored, expected)| {
717 stored.slot_id == expected.slot_id
718 && stored.ordinal == expected.ordinal
719 && stored.value_id == expected.value_id
720 && stored.model == expected.model
721 && stored.reasoning_effort == expected.reasoning_effort
722 })
723 && bindings_complete(&definition),
724 "strategy_revision_conflict"
725 );
726 let frozen_binding_digest = binding_digest(&stored_bindings)?;
727 let frozen_authorization_digest =
728 authorization_digest(revision_digest, semantics_digest, &frozen_binding_digest, 1);
729 transaction.execute(
730 "INSERT OR IGNORE INTO strategy_authorizations(
731 revision_digest, revision, semantics_digest, binding_digest,
732 authorization_digest, active, created_at
733 ) VALUES (?1, 1, ?2, ?3, ?4, 1, ?5)",
734 params![
735 revision_digest,
736 semantics_digest,
737 frozen_binding_digest,
738 frozen_authorization_digest,
739 now_ms(),
740 ],
741 )?;
742 let authorization = load_authorization(&transaction, revision_digest)?
743 .ok_or_else(|| anyhow!("strategy_authorization_stale"))?;
744 ensure!(
745 authorization.active
746 && authorization.revision == 1
747 && authorization.semantics_digest == semantics_digest
748 && authorization.binding_digest == frozen_binding_digest
749 && authorization.authorization_digest == frozen_authorization_digest,
750 "strategy_authorization_stale"
751 );
752
753 let slot_candidate_counts = slot_candidate_counts(&stored_bindings);
754 let run_id = format!("run-{}", Uuid::new_v4());
755 let empty = RunSnapshot::empty(&run_id, revision_digest, semantics_digest);
756 let event = ReducerEvent::Start { input };
757 let output = reduce(&compiled, &empty, event.clone())?;
758 let mut snapshot = output.snapshot;
759 snapshot.conversation_id = Some(conversation_id.to_owned());
760 snapshot.assistant_membership_id = Some(assistant_membership_id.to_owned());
761 snapshot.route_receipt = Some(route_receipt);
762 snapshot.slot_candidate_counts = slot_candidate_counts;
763 let now = now_ms();
764 transaction.execute(
765 "INSERT INTO strategy_runs(
766 run_id, revision_digest, semantics_digest, idempotency_key,
767 request_digest, snapshot_json, conversation_id, terminal,
768 created_at, updated_at
769 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9)",
770 params![
771 run_id,
772 revision_digest,
773 semantics_digest,
774 idempotency_key,
775 request_digest,
776 serde_json::to_string(&snapshot)?,
777 conversation_id,
778 i64::from(run_is_terminal(snapshot.status)),
779 now,
780 ],
781 )?;
782 persist_event_and_commands(
783 &transaction,
784 &empty,
785 &snapshot,
786 &event,
787 &output.emitted_commands,
788 now,
789 )?;
790 transaction.commit()?;
791 Ok((snapshot, true))
792 })
793 }
794
795 pub fn run_id_by_idempotency_key(&self, idempotency_key: &str) -> Result<Option<String>> {
796 validate_opaque_id(idempotency_key, "strategy_idempotency_key_invalid")?;
797 self.with_connection(|connection| {
798 connection
799 .query_row(
800 "SELECT run_id FROM strategy_runs WHERE idempotency_key=?1",
801 params![idempotency_key],
802 |row| row.get(0),
803 )
804 .optional()
805 .map_err(Into::into)
806 })
807 }
808
809 pub fn run(&self, run_id: &str) -> Result<RunSnapshot> {
810 self.with_connection(|connection| load_run(connection, run_id))
811 }
812
813 pub fn apply_event(&self, run_id: &str, event: ReducerEvent) -> Result<RunSnapshot> {
814 let committed = self.apply_event_with_commit(run_id, event)?;
815 match committed {
816 Some(committed) => Ok(committed.after),
817 None => self.run(run_id),
818 }
819 }
820
821 pub(crate) fn apply_event_with_commit(
822 &self,
823 run_id: &str,
824 event: ReducerEvent,
825 ) -> Result<Option<super::CommittedTransition>> {
826 self.with_connection(|connection| {
827 let transaction =
828 connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
829 let previous = load_run(&transaction, run_id)?;
830 let workflow = workflow_for_revision(&transaction, &previous.definition_digest)?;
831 let compiled = compile_workflow(workflow)?;
832 let output = reduce(&compiled, &previous, event.clone())?;
833 if output.applied {
834 let now = now_ms();
835 persist_event_and_commands(
836 &transaction,
837 &previous,
838 &output.snapshot,
839 &event,
840 &output.emitted_commands,
841 now,
842 )?;
843 transaction.execute(
844 "UPDATE strategy_runs SET snapshot_json=?2, conversation_id=?3,
845 terminal=?4, updated_at=?5 WHERE run_id=?1",
846 params![
847 run_id,
848 serde_json::to_string(&output.snapshot)?,
849 output.snapshot.conversation_id,
850 i64::from(run_is_terminal(output.snapshot.status)),
851 now
852 ],
853 )?;
854 }
855 transaction.commit()?;
856 if output.applied {
857 Ok(Some(super::CommittedTransition {
858 run_id: run_id.to_owned(),
859 sequence: output.snapshot.sequence,
860 event,
861 before: previous,
862 after: output.snapshot,
863 created_at_unix_ms: now_ms(),
864 }))
865 } else {
866 Ok(None)
867 }
868 })
869 }
870
871 pub fn claim_next_command(
872 &self,
873 run_id: &str,
874 claimant: &str,
875 lease_until_unix_ms: i64,
876 ) -> Result<Option<RunCommand>> {
877 validate_opaque_id(claimant, "strategy_claimant_invalid")?;
878 let now = now_ms();
879 ensure!(lease_until_unix_ms > now, "strategy_lease_invalid");
880 self.with_connection(|connection| {
881 let transaction =
882 connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
883 let active: i64 = transaction.query_row(
884 "SELECT COUNT(*) FROM strategy_commands
885 WHERE status IN ('claimed', 'running') AND lease_until>?1",
886 params![now],
887 |row| row.get(0),
888 )?;
889 if active >= licoup_workflow::MAX_ACTIVE_EFFECTS as i64 {
890 transaction.commit()?;
891 return Ok(None);
892 }
893 let previous = load_run(&transaction, run_id)?;
894 if !matches!(
895 previous.status,
896 StrategyRunStatus::Running
897 | StrategyRunStatus::Waiting
898 | StrategyRunStatus::Retryable
899 ) {
900 transaction.commit()?;
901 return Ok(None);
902 }
903 let workflow = workflow_for_revision(&transaction, &previous.definition_digest)?;
904 let compiled = compile_workflow(workflow)?;
905 let run_active: i64 = transaction.query_row(
906 "SELECT COUNT(*) FROM strategy_commands
907 WHERE run_id=?1 AND status IN ('claimed', 'running') AND lease_until>?2",
908 params![run_id, now],
909 |row| row.get(0),
910 )?;
911 if run_active >= compiled.definition().limits.max_parallelism as i64 {
912 transaction.commit()?;
913 return Ok(None);
914 }
915 let value: Option<String> = transaction
916 .query_row(
917 "SELECT command_json FROM strategy_commands
918 WHERE run_id=?1 AND status='pending' AND kind!='authorization'
919 ORDER BY command_id ASC LIMIT 1",
920 params![run_id],
921 |row| row.get(0),
922 )
923 .optional()?;
924 let Some(value) = value else {
925 transaction.commit()?;
926 return Ok(None);
927 };
928 let command: RunCommand = serde_json::from_str(&value)?;
929 let event = ReducerEvent::CommandClaimed {
930 command_id: command.id.clone(),
931 attempt_token: command.attempt_token.clone(),
932 };
933 let output = reduce(&compiled, &previous, event.clone())?;
934 ensure!(output.applied, "strategy_command_not_claimable");
935 persist_event_and_commands(
936 &transaction,
937 &previous,
938 &output.snapshot,
939 &event,
940 &output.emitted_commands,
941 now,
942 )?;
943 transaction.execute(
944 "UPDATE strategy_runs SET snapshot_json=?2, conversation_id=?3,
945 terminal=?4, updated_at=?5 WHERE run_id=?1",
946 params![
947 run_id,
948 serde_json::to_string(&output.snapshot)?,
949 output.snapshot.conversation_id,
950 i64::from(run_is_terminal(output.snapshot.status)),
951 now
952 ],
953 )?;
954 transaction.execute(
955 "UPDATE strategy_commands SET status='claimed', lease_owner=?2,
956 lease_until=?3, command_json=?4, updated_at=?5 WHERE command_id=?1",
957 params![
958 command.id,
959 claimant,
960 lease_until_unix_ms,
961 serde_json::to_string(&output.snapshot.commands[&command.id])?,
962 now,
963 ],
964 )?;
965 let claimed = output.snapshot.commands.get(&command.id).cloned();
966 transaction.commit()?;
967 Ok(claimed)
968 })
969 }
970
971 pub fn renew_command_lease(
972 &self,
973 command_id: &str,
974 claimant: &str,
975 lease_until_unix_ms: i64,
976 ) -> Result<()> {
977 validate_opaque_id(claimant, "strategy_claimant_invalid")?;
978 ensure!(lease_until_unix_ms > now_ms(), "strategy_lease_invalid");
979 self.with_connection(|connection| {
980 let changed = connection.execute(
981 "UPDATE strategy_commands SET lease_until=?3, updated_at=?4
982 WHERE command_id=?1 AND lease_owner=?2
983 AND status IN ('claimed', 'running')",
984 params![command_id, claimant, lease_until_unix_ms, now_ms()],
985 )?;
986 ensure!(changed == 1, "strategy_lease_lost");
987 Ok(())
988 })
989 }
990
991 pub fn authorize_effect(
996 &self,
997 run_id: &str,
998 command_id: &str,
999 attempt_token: &str,
1000 expected_authorization_digest: &str,
1001 claimant: &str,
1002 lease_until_unix_ms: i64,
1003 ) -> Result<()> {
1004 validate_opaque_id(claimant, "strategy_claimant_invalid")?;
1005 let now = now_ms();
1006 ensure!(lease_until_unix_ms > now, "strategy_lease_invalid");
1007 self.with_connection(|connection| {
1008 let transaction =
1009 connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
1010 let lease: Option<(String, String, Option<String>, Option<i64>)> = transaction
1011 .query_row(
1012 "SELECT status, attempt_token, lease_owner, lease_until
1013 FROM strategy_commands WHERE command_id=?1 AND run_id=?2",
1014 params![command_id, run_id],
1015 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
1016 )
1017 .optional()?;
1018 ensure!(
1019 lease.is_some_and(|(status, token, owner, lease_until)| {
1020 status == "running"
1021 && token == attempt_token
1022 && owner.as_deref() == Some(claimant)
1023 && lease_until.is_some_and(|value| value > now)
1024 }),
1025 "strategy_lease_lost"
1026 );
1027 let snapshot = load_run(&transaction, run_id)?;
1028 let command = snapshot
1029 .commands
1030 .get(command_id)
1031 .filter(|command| {
1032 command.status == CommandStatus::Running
1033 && command.attempt_token == attempt_token
1034 })
1035 .ok_or_else(|| anyhow!("strategy_callback_stale"))?;
1036 ensure!(command.id == command_id, "strategy_callback_stale");
1037 let definition = definition_by_revision(&transaction, &snapshot.definition_digest)?;
1038 ensure!(
1039 definition.summary.semantics_digest == snapshot.semantics_digest,
1040 "strategy_authorization_stale"
1041 );
1042 ensure!(
1043 bindings_complete(&definition),
1044 "strategy_binding_incomplete"
1045 );
1046 let authorization = current_authorization(&definition)?;
1047 ensure!(
1048 authorization.binding_digest == binding_digest(&definition.bindings)?
1049 && authorization.authorization_digest == expected_authorization_digest,
1050 "strategy_authorization_stale"
1051 );
1052 let renewed = transaction.execute(
1053 "UPDATE strategy_commands SET lease_until=?4, updated_at=?5
1054 WHERE command_id=?1 AND run_id=?2 AND lease_owner=?3
1055 AND status='running' AND lease_until>?5",
1056 params![command_id, run_id, claimant, lease_until_unix_ms, now],
1057 )?;
1058 ensure!(renewed == 1, "strategy_lease_lost");
1059 transaction.commit()?;
1060 Ok(())
1061 })
1062 }
1063
1064 pub fn recover_next_expired_command(&self, run_id: &str) -> Result<bool> {
1080 let Some(command_id) = self.next_expired_leased_command_id(run_id)? else {
1081 return Ok(false);
1082 };
1083 self.recover_leased_command(run_id, &command_id)?;
1084 Ok(true)
1085 }
1086
1087 fn next_expired_leased_command_id(&self, run_id: &str) -> Result<Option<String>> {
1088 self.with_connection(|connection| {
1089 connection
1090 .query_row(
1091 "SELECT command_id FROM strategy_commands
1092 WHERE run_id=?1 AND status IN ('claimed', 'running')
1093 AND lease_until IS NOT NULL AND lease_until<=?2
1094 ORDER BY command_id ASC LIMIT 1",
1095 params![run_id, now_ms()],
1096 |row| row.get(0),
1097 )
1098 .optional()
1099 .map_err(Into::into)
1100 })
1101 }
1102
1103 fn recover_leased_command(&self, run_id: &str, command_id: &str) -> Result<()> {
1104 self.with_connection(|connection| {
1105 let transaction =
1106 connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
1107 let value: Option<(String, String)> = transaction
1108 .query_row(
1109 "SELECT command_json, status FROM strategy_commands
1110 WHERE run_id=?1 AND command_id=?2 AND status IN ('claimed', 'running')",
1111 params![run_id, command_id],
1112 |row| Ok((row.get(0)?, row.get(1)?)),
1113 )
1114 .optional()?;
1115 let Some((command_json, persisted_status)) = value else {
1116 transaction.commit()?;
1117 return Ok(());
1118 };
1119 let command: RunCommand = serde_json::from_str(&command_json)?;
1120 let expected_status = enum_wire(command.status)?;
1121 ensure!(
1122 expected_status == persisted_status
1123 && matches!(
1124 command.status,
1125 CommandStatus::Claimed | CommandStatus::Running
1126 ),
1127 "strategy_recovery_state_conflict"
1128 );
1129 let previous = load_run(&transaction, run_id)?;
1130 ensure!(
1131 previous.commands.get(&command.id).is_some_and(|current| {
1132 current.status == command.status
1133 && current.attempt_token == command.attempt_token
1134 }),
1135 "strategy_recovery_state_conflict"
1136 );
1137 let workflow = workflow_for_revision(&transaction, &previous.definition_digest)?;
1138 let compiled = compile_workflow(workflow)?;
1139 let (class, code) = match command.status {
1140 CommandStatus::Claimed => (FailureClass::Transient, "lease_expired_before_start"),
1141 _ => (FailureClass::InDoubt, "effect_outcome_unknown"),
1142 };
1143 let failure_event = ReducerEvent::CommandFailed {
1144 command_id: command.id.clone(),
1145 attempt_token: command.attempt_token.clone(),
1146 class,
1147 code: code.to_owned(),
1148 };
1149 let failure = reduce(&compiled, &previous, failure_event.clone())?;
1150 let now = now_ms();
1151 persist_event_and_commands(
1152 &transaction,
1153 &previous,
1154 &failure.snapshot,
1155 &failure_event,
1156 &failure.emitted_commands,
1157 now,
1158 )?;
1159 let final_snapshot = if failure
1160 .snapshot
1161 .commands
1162 .get(&command.id)
1163 .is_some_and(|current| current.status == CommandStatus::Retryable)
1164 {
1165 let retry_event = ReducerEvent::RetryRequested {
1166 command_id: command.id.clone(),
1167 };
1168 let retry = reduce(&compiled, &failure.snapshot, retry_event.clone())?;
1169 persist_event_and_commands(
1170 &transaction,
1171 &failure.snapshot,
1172 &retry.snapshot,
1173 &retry_event,
1174 &retry.emitted_commands,
1175 now,
1176 )?;
1177 retry.snapshot
1178 } else {
1179 failure.snapshot
1180 };
1181 transaction.execute(
1182 "UPDATE strategy_runs SET snapshot_json=?2, conversation_id=?3,
1183 terminal=?4, updated_at=?5 WHERE run_id=?1",
1184 params![
1185 run_id,
1186 serde_json::to_string(&final_snapshot)?,
1187 final_snapshot.conversation_id,
1188 i64::from(run_is_terminal(final_snapshot.status)),
1189 now
1190 ],
1191 )?;
1192 transaction.execute(
1193 "UPDATE strategy_commands SET lease_owner=NULL, lease_until=NULL
1194 WHERE command_id=?1",
1195 params![&command.id],
1196 )?;
1197 transaction.commit()?;
1198 Ok(())
1199 })
1200 }
1201
1202 pub fn projection_for_definition(&self, revision_digest: &str) -> Result<StrategyProjection> {
1203 let definition = self.definition_by_revision(revision_digest)?;
1204 let complete = bindings_complete(&definition);
1205 let mut allowed = BTreeSet::from([
1206 "strategy.definition.inspect".into(),
1207 "strategy.binding.update".into(),
1208 "strategy.binding.replace".into(),
1209 "strategy.authorization.preview".into(),
1210 ]);
1211 let status = if complete {
1212 if definition
1213 .authorization
1214 .as_ref()
1215 .is_some_and(|value| value.active)
1216 {
1217 allowed.extend([
1218 "strategy.authorization.revoke".into(),
1219 "strategy.run.start".into(),
1220 ]);
1221 StrategyRunStatus::Pending
1222 } else {
1223 allowed.insert("strategy.authorization.grant".into());
1224 StrategyRunStatus::AuthorizationRequired
1225 }
1226 } else {
1227 StrategyRunStatus::Pending
1228 };
1229 Ok(StrategyProjection {
1230 schema: STRATEGY_SCHEMA_VERSION.into(),
1231 definition: definition.summary,
1232 run_id: None,
1233 status,
1234 current_states: BTreeSet::new(),
1235 neighbor_states: BTreeSet::new(),
1236 allowed_operations: allowed,
1237 bindings: definition.bindings,
1238 diagnostic: if complete {
1239 None
1240 } else {
1241 Some(StrategyDiagnostic {
1242 code: "binding_incomplete".into(),
1243 component: "strategy_binding".into(),
1244 retryable: true,
1245 recovery: "Bind every required actor and runtime slot.".into(),
1246 arguments: BTreeMap::new(),
1247 })
1248 },
1249 history_count: 0,
1250 fallbacks: Vec::new(),
1251 pending_callbacks: Vec::new(),
1252 needs_human_input: false,
1253 entry_session_id: None,
1254 })
1255 }
1256
1257 pub fn projection_for_run(&self, run_id: &str) -> Result<StrategyProjection> {
1258 let snapshot = self.run(run_id)?;
1259 let definition = self.definition_by_revision(&snapshot.definition_digest)?;
1260 let compiled = compile_workflow(definition.workflow.clone())?;
1261 let neighbors = snapshot
1262 .active_states
1263 .iter()
1264 .flat_map(|state| {
1265 compiled
1266 .outgoing(state)
1267 .map(|transition| transition.to.clone())
1268 })
1269 .collect();
1270 let mut allowed = BTreeSet::from(["strategy.run.inspect".into()]);
1271 match snapshot.status {
1272 StrategyRunStatus::AuthorizationRequired
1273 | StrategyRunStatus::RuntimeMissing
1274 | StrategyRunStatus::Waiting => {
1275 allowed.insert("strategy.run.resume".into());
1276 allowed.insert("strategy.run.cancel".into());
1277 }
1278 StrategyRunStatus::Running => {
1279 allowed.insert("strategy.run.resume".into());
1280 allowed.insert("strategy.run.cancel".into());
1281 }
1282 StrategyRunStatus::Retryable => {
1283 allowed.insert("strategy.run.retry".into());
1284 allowed.insert("strategy.run.cancel".into());
1285 }
1286 StrategyRunStatus::CancelRequested => {
1287 allowed.insert("strategy.run.inspect".into());
1288 }
1289 _ => {}
1290 }
1291 let history_count = self.with_connection(|connection| -> Result<u64> {
1292 connection
1293 .query_row(
1294 "SELECT COUNT(*) FROM strategy_run_events WHERE run_id=?1",
1295 params![run_id],
1296 |row| row.get::<_, i64>(0).map(|value| value as u64),
1297 )
1298 .map_err(Into::into)
1299 })?;
1300 let pending_callbacks = snapshot.pending_callbacks.clone();
1301 Ok(StrategyProjection {
1302 schema: STRATEGY_SCHEMA_VERSION.into(),
1303 definition: definition.summary,
1304 run_id: Some(run_id.to_owned()),
1305 status: snapshot.status,
1306 current_states: snapshot.active_states,
1307 neighbor_states: neighbors,
1308 allowed_operations: allowed,
1309 bindings: definition.bindings,
1310 diagnostic: snapshot.diagnostic_code.map(|code| StrategyDiagnostic {
1311 component: "strategy_runtime".into(),
1312 retryable: matches!(
1313 snapshot.status,
1314 StrategyRunStatus::Retryable
1315 | StrategyRunStatus::RuntimeMissing
1316 | StrategyRunStatus::AuthorizationRequired
1317 ),
1318 recovery: recovery_for_status(snapshot.status).into(),
1319 code,
1320 arguments: BTreeMap::new(),
1321 }),
1322 history_count,
1323 fallbacks: snapshot.fallbacks,
1324 pending_callbacks: pending_callbacks.clone(),
1325 needs_human_input: !pending_callbacks.is_empty()
1326 || matches!(
1327 snapshot.status,
1328 StrategyRunStatus::AuthorizationRequired
1329 | StrategyRunStatus::RuntimeMissing
1330 | StrategyRunStatus::Waiting
1331 | StrategyRunStatus::Retryable
1332 ),
1333 entry_session_id: definition
1334 .workflow
1335 .actor_slots
1336 .iter()
1337 .find(|slot| slot.kind == licoup_workflow::BindingKind::Actor && slot.entry)
1338 .and_then(|slot| {
1339 let prefix = format!("{}\0", slot.id);
1340 snapshot
1341 .actor_sessions
1342 .iter()
1343 .filter(|(key, _)| key.starts_with(&prefix))
1344 .filter_map(|(key, session)| {
1345 snapshot
1346 .merge_sources
1347 .get(&format!("session\0{key}"))
1348 .map(|source| (source, key, session))
1349 })
1350 .max_by(|left, right| left.0.cmp(right.0).then(left.1.cmp(right.1)))
1351 .map(|(_, _, session)| session.clone())
1352 }),
1353 })
1354 }
1355
1356 pub fn active_run_for_conversation(
1357 &self,
1358 revision_digest: &str,
1359 conversation_id: &str,
1360 ) -> Result<Option<RunSnapshot>> {
1361 validate_opaque_id(conversation_id, "strategy_conversation_id_invalid")?;
1362 self.with_connection(|connection| {
1363 connection
1364 .query_row(
1365 "SELECT snapshot_json FROM strategy_runs
1366 WHERE revision_digest=?1 AND conversation_id=?2 AND terminal=0
1367 ORDER BY updated_at DESC LIMIT 1",
1368 params![revision_digest, conversation_id],
1369 |row| row.get::<_, String>(0),
1370 )
1371 .optional()?
1372 .map(|snapshot_json| serde_json::from_str(&snapshot_json).map_err(Into::into))
1373 .transpose()
1374 })
1375 }
1376
1377 pub fn bind_conversation_if_absent(&self, run_id: &str, conversation_id: &str) -> Result<()> {
1378 validate_opaque_id(conversation_id, "strategy_conversation_id_invalid")?;
1379 self.with_connection(|connection| {
1380 let transaction =
1381 connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
1382 let mut snapshot = load_run(&transaction, run_id)?;
1383 if snapshot
1384 .conversation_id
1385 .as_deref()
1386 .is_some_and(|value| !value.is_empty())
1387 {
1388 transaction.commit()?;
1389 return Ok(());
1390 }
1391 snapshot.conversation_id = Some(conversation_id.to_owned());
1392 transaction.execute(
1393 "UPDATE strategy_runs SET snapshot_json=?2, conversation_id=?3, updated_at=?4
1394 WHERE run_id=?1",
1395 params![
1396 run_id,
1397 serde_json::to_string(&snapshot)?,
1398 snapshot.conversation_id,
1399 now_ms()
1400 ],
1401 )?;
1402 transaction.commit()?;
1403 Ok(())
1404 })
1405 }
1406
1407 fn remove_retired_revision_trees(&self, digests: &[String]) {
1410 for digest in digests {
1411 let Some(root) = &self.package_revisions_root else {
1412 return;
1413 };
1414 if digest.len() != 64
1415 || !digest
1416 .chars()
1417 .all(|character| character.is_ascii_hexdigit())
1418 {
1419 continue;
1420 }
1421 let path = root.join(digest);
1422 let Ok(metadata) = fs::symlink_metadata(&path) else {
1423 continue;
1424 };
1425 if metadata.file_type().is_symlink() {
1426 continue;
1427 }
1428 remove_directory_tree(&path);
1429 }
1430 }
1431}
1432
1433fn initialize_schema(connection: &mut Connection) -> Result<Vec<String>> {
1434 let retired = initialize_schema_through(connection)?;
1435 let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
1436 migrate_legacy_workflow_definitions(&transaction)?;
1437 transaction.execute("UPDATE strategy_meta SET value='3' WHERE key='version'", [])?;
1438 transaction.commit()?;
1439 super::queue::initialize_schema(connection)?;
1440 super::subscriptions::initialize_schema(connection)?;
1441 super::commit::initialize_schema(connection)?;
1442 super::controlled::initialize_schema(connection)?;
1443 Ok(retired)
1444}
1445
1446fn initialize_schema_through(connection: &mut Connection) -> Result<Vec<String>> {
1447 connection.execute_batch(
1448 "CREATE TABLE IF NOT EXISTS strategy_meta(
1449 key TEXT PRIMARY KEY, value TEXT NOT NULL
1450 );
1451 INSERT INTO strategy_meta(key, value) VALUES ('version', '3')
1452 ON CONFLICT(key) DO NOTHING;
1453 CREATE TABLE IF NOT EXISTS strategy_definitions(
1454 definition_id TEXT NOT NULL,
1455 revision_digest TEXT PRIMARY KEY,
1456 semantics_digest TEXT NOT NULL,
1457 name TEXT NOT NULL,
1458 version TEXT NOT NULL,
1459 workflow_json TEXT NOT NULL,
1460 asset_count INTEGER NOT NULL,
1461 imported_at INTEGER NOT NULL
1462 );
1463 CREATE INDEX IF NOT EXISTS strategy_definitions_id_idx
1464 ON strategy_definitions(definition_id, imported_at DESC);
1465 CREATE TABLE IF NOT EXISTS strategy_bindings(
1466 revision_digest TEXT NOT NULL REFERENCES strategy_definitions(revision_digest) ON DELETE CASCADE,
1467 slot_id TEXT NOT NULL,
1468 ordinal INTEGER NOT NULL DEFAULT 0,
1469 value_id TEXT NOT NULL,
1470 model TEXT NOT NULL DEFAULT '',
1471 reasoning_effort TEXT NOT NULL DEFAULT '',
1472 revision INTEGER NOT NULL,
1473 PRIMARY KEY(revision_digest, slot_id, ordinal)
1474 );
1475 CREATE TABLE IF NOT EXISTS strategy_authorizations(
1476 revision_digest TEXT NOT NULL REFERENCES strategy_definitions(revision_digest) ON DELETE CASCADE,
1477 revision INTEGER NOT NULL,
1478 semantics_digest TEXT NOT NULL,
1479 binding_digest TEXT NOT NULL,
1480 authorization_digest TEXT NOT NULL,
1481 active INTEGER NOT NULL,
1482 created_at INTEGER NOT NULL,
1483 PRIMARY KEY(revision_digest, revision)
1484 );
1485 CREATE UNIQUE INDEX IF NOT EXISTS strategy_authorization_active_idx
1486 ON strategy_authorizations(revision_digest) WHERE active=1;
1487 CREATE TABLE IF NOT EXISTS strategy_runs(
1488 run_id TEXT PRIMARY KEY,
1489 revision_digest TEXT NOT NULL REFERENCES strategy_definitions(revision_digest),
1490 semantics_digest TEXT NOT NULL,
1491 idempotency_key TEXT NOT NULL UNIQUE,
1492 request_digest TEXT NOT NULL,
1493 snapshot_json TEXT NOT NULL,
1494 conversation_id TEXT,
1495 terminal INTEGER NOT NULL,
1496 created_at INTEGER NOT NULL,
1497 updated_at INTEGER NOT NULL
1498 );
1499 CREATE INDEX IF NOT EXISTS strategy_runs_revision_idx
1500 ON strategy_runs(revision_digest, updated_at DESC);
1501 CREATE TABLE IF NOT EXISTS strategy_run_events(
1502 run_id TEXT NOT NULL REFERENCES strategy_runs(run_id) ON DELETE CASCADE,
1503 sequence INTEGER NOT NULL,
1504 event_type TEXT NOT NULL,
1505 event_json TEXT NOT NULL,
1506 created_at INTEGER NOT NULL,
1507 PRIMARY KEY(run_id, sequence)
1508 );
1509 CREATE TABLE IF NOT EXISTS strategy_commands(
1510 command_id TEXT PRIMARY KEY,
1511 run_id TEXT NOT NULL REFERENCES strategy_runs(run_id) ON DELETE CASCADE,
1512 state_id TEXT NOT NULL,
1513 kind TEXT NOT NULL,
1514 status TEXT NOT NULL,
1515 attempt INTEGER NOT NULL,
1516 attempt_token TEXT NOT NULL,
1517 command_json TEXT NOT NULL,
1518 lease_owner TEXT,
1519 lease_until INTEGER,
1520 updated_at INTEGER NOT NULL
1521 );
1522 CREATE INDEX IF NOT EXISTS strategy_commands_ready_idx
1523 ON strategy_commands(status, command_id);
1524 CREATE INDEX IF NOT EXISTS strategy_commands_lease_idx
1525 ON strategy_commands(lease_until) WHERE status IN ('claimed', 'running');",
1526 )?;
1527 ensure_column(
1528 connection,
1529 "strategy_bindings",
1530 "model",
1531 "TEXT NOT NULL DEFAULT ''",
1532 )?;
1533 ensure_column(
1534 connection,
1535 "strategy_bindings",
1536 "reasoning_effort",
1537 "TEXT NOT NULL DEFAULT ''",
1538 )?;
1539 ensure_column(connection, "strategy_runs", "conversation_id", "TEXT")?;
1540 ensure_column(connection, "strategy_runs", "terminal", "INTEGER")?;
1541 let retired = migrate_retired_builtin_definition(connection)?;
1542 backfill_run_query_columns(connection)?;
1543 connection.execute_batch(
1544 "CREATE INDEX IF NOT EXISTS strategy_runs_active_conversation_idx
1545 ON strategy_runs(revision_digest, conversation_id, terminal, updated_at DESC);",
1546 )?;
1547 migrate_bindings_ordinal_primary_key(connection)?;
1548 Ok(retired)
1549}
1550
1551fn migrate_retired_builtin_definition(connection: &mut Connection) -> Result<Vec<String>> {
1557 const RETIRED_DEFINITION_ID: &str = "licoup-basic";
1558 const RETIRED_DEFINITION_NAME: &str = "LicoUp Basic Strategy";
1559 let mut statement = connection.prepare(
1560 "SELECT revision_digest FROM strategy_definitions
1561 WHERE definition_id=?1 OR name=?2",
1562 )?;
1563 let digests = statement
1564 .query_map(
1565 params![RETIRED_DEFINITION_ID, RETIRED_DEFINITION_NAME],
1566 |row| row.get::<_, String>(0),
1567 )?
1568 .collect::<rusqlite::Result<Vec<_>>>()?;
1569 drop(statement);
1570 for digest in &digests {
1571 connection.execute(
1572 "DELETE FROM strategy_runs WHERE revision_digest=?1",
1573 params![digest],
1574 )?;
1575 connection.execute(
1576 "DELETE FROM strategy_definitions WHERE revision_digest=?1",
1577 params![digest],
1578 )?;
1579 }
1580 Ok(digests)
1581}
1582
1583fn validate_current_schema(connection: &mut Connection) -> Result<()> {
1584 configure_connection(connection)?;
1585 let version: String = connection.query_row(
1586 "SELECT value FROM strategy_meta WHERE key='version'",
1587 [],
1588 |row| row.get(0),
1589 )?;
1590 ensure!(version == "3", "strategy_schema_migration_required");
1591 super::queue::initialize_schema(connection)?;
1592 super::subscriptions::initialize_schema(connection)?;
1593 super::commit::initialize_schema(connection)?;
1594 super::controlled::initialize_schema(connection)?;
1595 Ok(())
1596}
1597
1598pub(crate) fn preflight_existing_store(path: &Path) -> Result<()> {
1599 let metadata = match fs::symlink_metadata(path) {
1600 Ok(metadata) => metadata,
1601 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
1602 Err(error) => return Err(error.into()),
1603 };
1604 ensure!(
1605 metadata.is_file() && !metadata.file_type().is_symlink(),
1606 "unsupported_state_shape"
1607 );
1608 let connection = Connection::open_with_flags(path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)?;
1609 let version: String = connection.query_row(
1610 "SELECT value FROM strategy_meta WHERE key='version'",
1611 [],
1612 |row| row.get(0),
1613 )?;
1614 ensure!(
1615 matches!(version.as_str(), "2" | "3"),
1616 "unsupported_state_shape"
1617 );
1618 ensure!(version == "3", "strategy_schema_migration_required");
1619 validate_published_core_layout(&connection, &version)
1620}
1621
1622pub fn validate_published_core_layout(
1631 connection: &Connection,
1632 expected_meta_version: &str,
1633) -> Result<()> {
1634 let version: Option<String> = connection
1635 .query_row(
1636 "SELECT value FROM strategy_meta WHERE key='version'",
1637 [],
1638 |row| row.get(0),
1639 )
1640 .optional()?;
1641 ensure!(
1642 version.as_deref() == Some(expected_meta_version),
1643 "unsupported_state_shape"
1644 );
1645 let mut reference = Connection::open_in_memory()?;
1649 initialize_schema_through(&mut reference)?;
1650 let required = licoup_foundation::core::sqlite_contract::tables(&reference)?;
1651 super::queue::initialize_schema(&reference)?;
1652 super::subscriptions::initialize_schema(&reference)?;
1653 super::commit::initialize_schema(&reference)?;
1654 super::controlled::initialize_schema(&reference)?;
1655 let existing = licoup_foundation::core::sqlite_contract::tables(connection)?;
1656 for table in licoup_foundation::core::sqlite_contract::tables(&reference)? {
1657 if !existing.contains(&table) && !required.contains(&table) {
1658 continue;
1659 }
1660 licoup_foundation::core::sqlite_contract::validate_table(
1661 connection,
1662 &reference,
1663 &table,
1664 match table.as_str() {
1665 "strategy_runs" => Some(ColumnVariant::Nullable("terminal")),
1666 "strategy_bindings" => Some(ColumnVariant::MissingZeroDefault("ordinal")),
1669 _ => None,
1670 },
1671 )?;
1672 }
1673 Ok(())
1674}
1675
1676fn backfill_run_query_columns(connection: &mut Connection) -> Result<()> {
1677 let mut statement = connection
1678 .prepare("SELECT run_id, snapshot_json FROM strategy_runs WHERE terminal IS NULL")?;
1679 let runs = statement
1680 .query_map([], |row| {
1681 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1682 })?
1683 .collect::<rusqlite::Result<Vec<_>>>()?;
1684 drop(statement);
1685 for (run_id, snapshot_json) in runs {
1686 let snapshot: RunSnapshot = serde_json::from_str(&snapshot_json)?;
1687 connection.execute(
1688 "UPDATE strategy_runs SET conversation_id=?2, terminal=?3 WHERE run_id=?1",
1689 params![
1690 run_id,
1691 snapshot.conversation_id,
1692 i64::from(run_is_terminal(snapshot.status))
1693 ],
1694 )?;
1695 }
1696 Ok(())
1697}
1698
1699fn run_is_terminal(status: StrategyRunStatus) -> bool {
1700 matches!(
1701 status,
1702 StrategyRunStatus::Completed
1703 | StrategyRunStatus::Failed
1704 | StrategyRunStatus::Cancelled
1705 | StrategyRunStatus::Blocked
1706 | StrategyRunStatus::CancelInDoubt
1707 )
1708}
1709
1710fn migrate_bindings_ordinal_primary_key(connection: &mut Connection) -> Result<()> {
1711 let mut statement = connection.prepare("PRAGMA table_info(strategy_bindings)")?;
1712 let columns = statement
1713 .query_map([], |row| row.get::<_, String>(1))?
1714 .collect::<rusqlite::Result<Vec<_>>>()?;
1715 if columns.iter().any(|column| column == "ordinal") {
1716 return Ok(());
1717 }
1718 connection.execute_batch(
1719 "CREATE TABLE strategy_bindings_v2 (
1720 revision_digest TEXT NOT NULL REFERENCES strategy_definitions(revision_digest) ON DELETE CASCADE,
1721 slot_id TEXT NOT NULL,
1722 ordinal INTEGER NOT NULL,
1723 value_id TEXT NOT NULL,
1724 model TEXT NOT NULL DEFAULT '',
1725 reasoning_effort TEXT NOT NULL DEFAULT '',
1726 revision INTEGER NOT NULL,
1727 PRIMARY KEY(revision_digest, slot_id, ordinal)
1728 );
1729 INSERT INTO strategy_bindings_v2(
1730 revision_digest, slot_id, ordinal, value_id, model, reasoning_effort, revision
1731 )
1732 SELECT revision_digest, slot_id, 0, value_id, model, reasoning_effort, revision
1733 FROM strategy_bindings;
1734 DROP TABLE strategy_bindings;
1735 ALTER TABLE strategy_bindings_v2 RENAME TO strategy_bindings;
1736 UPDATE strategy_authorizations SET active=0 WHERE active=1;
1737 UPDATE strategy_meta SET value='2' WHERE key='version';",
1738 )?;
1739 Ok(())
1740}
1741
1742fn migrate_legacy_workflow_definitions(connection: &Connection) -> Result<()> {
1745 let mut statement = connection.prepare(
1746 "SELECT revision_digest, workflow_json FROM strategy_definitions ORDER BY revision_digest",
1747 )?;
1748 let definitions = statement
1749 .query_map([], |row| {
1750 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1751 })?
1752 .collect::<rusqlite::Result<Vec<_>>>()?;
1753 drop(statement);
1754
1755 for (revision, workflow_json) in definitions {
1756 let Ok(workflow) = serde_json::from_str::<WorkflowDefinition>(&workflow_json) else {
1757 continue;
1761 };
1762 let Some(workflow) = normalize_legacy_workflow(workflow) else {
1763 continue;
1764 };
1765 let compiled = compile_workflow(workflow)?;
1766 let canonical = serde_json::to_string(compiled.definition())?;
1767 connection.execute(
1768 "UPDATE strategy_definitions
1769 SET workflow_json=?2
1770 WHERE revision_digest=?1",
1771 params![revision, canonical],
1772 )?;
1773 }
1774 Ok(())
1775}
1776
1777pub fn normalize_legacy_workflow(mut workflow: WorkflowDefinition) -> Option<WorkflowDefinition> {
1781 let mut changed = false;
1782 if !workflow
1783 .actor_slots
1784 .iter()
1785 .any(|slot| slot.kind == BindingKind::Actor && slot.entry)
1786 && let Some(entry) = workflow
1787 .actor_slots
1788 .iter_mut()
1789 .find(|slot| slot.kind == BindingKind::Actor)
1790 {
1791 entry.entry = true;
1792 changed = true;
1793 }
1794
1795 let effect_ids = workflow
1796 .states
1797 .iter()
1798 .filter(|state| {
1799 matches!(
1800 state.kind,
1801 GraphStateKind::Authorization
1802 | GraphStateKind::Actor
1803 | GraphStateKind::Script
1804 | GraphStateKind::Workset
1805 )
1806 })
1807 .map(|state| state.id.clone())
1808 .collect::<BTreeSet<_>>();
1809 let mut success = BTreeSet::new();
1810 let mut failure = BTreeSet::new();
1811 for transition in &workflow.transitions {
1812 if effect_ids.contains(&transition.from) {
1813 match transition.event {
1814 TransitionEvent::Success => _ = success.insert(transition.from.clone()),
1815 TransitionEvent::Failure => _ = failure.insert(transition.from.clone()),
1816 TransitionEvent::Complete => {}
1817 }
1818 }
1819 }
1820 for transition in &mut workflow.transitions {
1821 if !effect_ids.contains(&transition.from) || transition.event != TransitionEvent::Complete {
1822 continue;
1823 }
1824 changed = true;
1825 if success.insert(transition.from.clone()) {
1826 transition.event = TransitionEvent::Success;
1827 } else if failure.insert(transition.from.clone()) {
1828 transition.event = TransitionEvent::Failure;
1829 }
1830 }
1831 let before = workflow.transitions.len();
1832 workflow.transitions.retain(|transition| {
1833 !(effect_ids.contains(&transition.from) && transition.event == TransitionEvent::Complete)
1834 });
1835 changed |= workflow.transitions.len() != before;
1836
1837 let missing_failure = effect_ids
1838 .iter()
1839 .filter(|state| success.contains(*state) && !failure.contains(*state))
1840 .cloned()
1841 .collect::<Vec<_>>();
1842 if !missing_failure.is_empty() {
1843 changed = true;
1844 let terminal = workflow
1845 .states
1846 .iter()
1847 .find(|state| matches!(state.kind, GraphStateKind::Blocked | GraphStateKind::Fail))
1848 .map(|state| state.id.clone())
1849 .unwrap_or_else(|| {
1850 let mut id = "blocked".to_owned();
1851 let mut suffix = 0u32;
1852 while workflow.states.iter().any(|state| state.id == id) {
1853 suffix += 1;
1854 id = format!("blocked-{suffix}");
1855 }
1856 workflow.states.push(GraphState {
1857 id: id.clone(),
1858 kind: GraphStateKind::Blocked,
1859 label: "Blocked".into(),
1860 instruction: String::new(),
1861 binding: None,
1862 runtime: None,
1863 entry: None,
1864 workset: None,
1865 retry: Default::default(),
1866 });
1867 id
1868 });
1869 let mut transition_ids = workflow
1870 .transitions
1871 .iter()
1872 .map(|transition| transition.id.clone())
1873 .collect::<BTreeSet<_>>();
1874 for state in missing_failure {
1875 let mut id = format!("{state}-legacy-failure");
1876 let mut suffix = 0u32;
1877 while !transition_ids.insert(id.clone()) {
1878 suffix += 1;
1879 id = format!("{state}-legacy-failure-{suffix}");
1880 }
1881 workflow.transitions.push(Transition {
1882 id,
1883 from: state,
1884 to: terminal.clone(),
1885 event: TransitionEvent::Failure,
1886 mode: TransitionMode::Flow,
1887 guard: None,
1888 });
1889 }
1890 }
1891 changed.then_some(workflow)
1892}
1893
1894fn configure_connection(connection: &Connection) -> Result<()> {
1895 connection.execute_batch(
1896 "PRAGMA foreign_keys=ON;
1897 PRAGMA journal_mode=WAL;
1898 PRAGMA synchronous=FULL;
1899 PRAGMA busy_timeout=5000;",
1900 )?;
1901 Ok(())
1902}
1903
1904fn definition_by_revision(connection: &Connection, revision: &str) -> Result<StrategyDefinition> {
1905 let base: Option<(String, String, String, String, String, String, i64, i64)> = connection
1906 .query_row(
1907 "SELECT definition_id, revision_digest, semantics_digest, name, version,
1908 workflow_json, asset_count, imported_at FROM strategy_definitions
1909 WHERE revision_digest=?1",
1910 params![revision],
1911 |row| {
1912 Ok((
1913 row.get(0)?,
1914 row.get(1)?,
1915 row.get(2)?,
1916 row.get(3)?,
1917 row.get(4)?,
1918 row.get(5)?,
1919 row.get(6)?,
1920 row.get(7)?,
1921 ))
1922 },
1923 )
1924 .optional()?;
1925 let Some((
1926 definition_id,
1927 revision_digest,
1928 semantics_digest,
1929 name,
1930 version,
1931 workflow_json,
1932 asset_count,
1933 imported_at,
1934 )) = base
1935 else {
1936 return Err(anyhow!("strategy_definition_not_found"));
1937 };
1938 let workflow: WorkflowDefinition = serde_json::from_str(&workflow_json)?;
1939 let bindings = load_bindings(connection, &revision_digest)?;
1940 let authorization = load_authorization(connection, &revision_digest)?;
1941 Ok(StrategyDefinition {
1942 summary: StrategyDefinitionSummary {
1943 definition_id,
1944 revision_digest,
1945 semantics_digest,
1946 name,
1947 version,
1948 imported_at_unix_ms: imported_at,
1949 authorized: authorization.as_ref().is_some_and(|value| value.active),
1950 },
1951 workflow,
1952 asset_count: asset_count as usize,
1953 bindings,
1954 authorization,
1955 })
1956}
1957
1958fn summary_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StrategyDefinitionSummary> {
1959 Ok(StrategyDefinitionSummary {
1960 definition_id: row.get(0)?,
1961 revision_digest: row.get(1)?,
1962 semantics_digest: row.get(2)?,
1963 name: row.get(3)?,
1964 version: row.get(4)?,
1965 imported_at_unix_ms: row.get(5)?,
1966 authorized: row
1967 .get::<_, i64>(6)
1968 .map(|value| value != 0)
1969 .unwrap_or(false),
1970 })
1971}
1972
1973fn load_bindings(connection: &Connection, revision: &str) -> Result<Vec<BindingValue>> {
1974 let mut statement = connection.prepare(
1975 "SELECT slot_id, ordinal, value_id, model, reasoning_effort, revision
1976 FROM strategy_bindings
1977 WHERE revision_digest=?1 ORDER BY slot_id ASC, ordinal ASC",
1978 )?;
1979 let rows = statement.query_map(params![revision], |row| {
1980 Ok(BindingValue {
1981 slot_id: row.get(0)?,
1982 ordinal: row.get::<_, i64>(1)? as u8,
1983 value_id: row.get(2)?,
1984 model: row.get(3)?,
1985 reasoning_effort: row.get(4)?,
1986 revision: row.get::<_, i64>(5)? as u64,
1987 })
1988 })?;
1989 rows.collect::<rusqlite::Result<Vec<_>>>()
1990 .map_err(Into::into)
1991}
1992
1993fn load_authorization(
1994 connection: &Connection,
1995 revision: &str,
1996) -> Result<Option<StrategyAuthorization>> {
1997 connection
1998 .query_row(
1999 "SELECT revision, semantics_digest, binding_digest, authorization_digest, active
2000 FROM strategy_authorizations WHERE revision_digest=?1
2001 ORDER BY revision DESC LIMIT 1",
2002 params![revision],
2003 |row| {
2004 Ok(StrategyAuthorization {
2005 definition_digest: revision.to_owned(),
2006 revision: row.get::<_, i64>(0)? as u64,
2007 semantics_digest: row.get(1)?,
2008 binding_digest: row.get(2)?,
2009 authorization_digest: row.get(3)?,
2010 active: row.get::<_, i64>(4)? != 0,
2011 })
2012 },
2013 )
2014 .optional()
2015 .map_err(Into::into)
2016}
2017
2018fn current_authorization(definition: &StrategyDefinition) -> Result<&StrategyAuthorization> {
2019 definition
2020 .authorization
2021 .as_ref()
2022 .filter(|authorization| {
2023 authorization.active
2024 && authorization.definition_digest == definition.summary.revision_digest
2025 && authorization.semantics_digest == definition.summary.semantics_digest
2026 })
2027 .ok_or_else(|| anyhow!("strategy_authorization_required"))
2028}
2029
2030fn bindings_complete(definition: &StrategyDefinition) -> bool {
2031 let bound = definition
2032 .bindings
2033 .iter()
2034 .filter(|binding| binding.ordinal == 0)
2035 .map(|binding| binding.slot_id.as_str())
2036 .collect::<BTreeSet<_>>();
2037 definition
2038 .workflow
2039 .actor_slots
2040 .iter()
2041 .filter(|slot| slot.required)
2042 .all(|slot| bound.contains(slot.id.as_str()))
2043}
2044
2045fn binding_digest(bindings: &[BindingValue]) -> Result<String> {
2046 let mut sorted = bindings.to_vec();
2047 sorted.sort_by(|left, right| {
2048 left.slot_id
2049 .cmp(&right.slot_id)
2050 .then(left.ordinal.cmp(&right.ordinal))
2051 });
2052 Ok(sha256_hex(&serde_json::to_vec(&sorted)?))
2053}
2054
2055fn slot_candidate_counts(bindings: &[BindingValue]) -> BTreeMap<String, u8> {
2056 let mut counts = BTreeMap::new();
2057 for binding in bindings {
2058 let entry = counts.entry(binding.slot_id.clone()).or_insert(0);
2059 *entry = (*entry).max(binding.ordinal.saturating_add(1));
2060 }
2061 counts
2062}
2063
2064fn authorization_digest(
2065 revision: &str,
2066 semantics: &str,
2067 bindings: &str,
2068 authorization_revision: u64,
2069) -> String {
2070 sha256_hex(
2071 format!(
2072 "licoup-strategy-authorization-v1\0{revision}\0{semantics}\0{bindings}\0{authorization_revision}"
2073 )
2074 .as_bytes(),
2075 )
2076}
2077
2078fn workflow_for_revision(connection: &Connection, revision: &str) -> Result<WorkflowDefinition> {
2079 let value: String = connection
2080 .query_row(
2081 "SELECT workflow_json FROM strategy_definitions WHERE revision_digest=?1",
2082 params![revision],
2083 |row| row.get(0),
2084 )
2085 .optional()?
2086 .ok_or_else(|| anyhow!("strategy_definition_not_found"))?;
2087 serde_json::from_str(&value).map_err(Into::into)
2088}
2089
2090fn load_run(connection: &Connection, run_id: &str) -> Result<RunSnapshot> {
2091 let value: Option<String> = connection
2092 .query_row(
2093 "SELECT snapshot_json FROM strategy_runs WHERE run_id=?1",
2094 params![run_id],
2095 |row| row.get(0),
2096 )
2097 .optional()?;
2098 serde_json::from_str(
2099 value
2100 .as_deref()
2101 .ok_or_else(|| anyhow!("strategy_run_not_found"))?,
2102 )
2103 .map_err(Into::into)
2104}
2105
2106fn persist_event_and_commands(
2107 transaction: &Transaction<'_>,
2108 before: &RunSnapshot,
2109 snapshot: &RunSnapshot,
2110 event: &ReducerEvent,
2111 emitted: &[RunCommand],
2112 now: i64,
2113) -> Result<()> {
2114 let event_json = serde_json::to_string(event)?;
2115 let event_type = event_json.split('"').nth(3).unwrap_or("event").to_owned();
2116 transaction.execute(
2117 "INSERT INTO strategy_run_events(run_id, sequence, event_type, event_json, created_at)
2118 VALUES (?1, ?2, ?3, ?4, ?5)",
2119 params![
2120 snapshot.run_id,
2121 snapshot.sequence as i64,
2122 event_type,
2123 event_json,
2124 now
2125 ],
2126 )?;
2127 super::commit::insert_intent(
2128 transaction,
2129 &snapshot.run_id,
2130 snapshot.sequence,
2131 event,
2132 before,
2133 snapshot,
2134 now,
2135 )?;
2136 for command in emitted {
2137 transaction.execute(
2138 "INSERT INTO strategy_commands(
2139 command_id, run_id, state_id, kind, status, attempt,
2140 attempt_token, command_json, updated_at
2141 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
2142 params![
2143 command.id,
2144 snapshot.run_id,
2145 command.state_id,
2146 enum_wire(command.kind)?,
2147 enum_wire(command.status)?,
2148 command.attempt as i64,
2149 command.attempt_token,
2150 serde_json::to_string(command)?,
2151 now,
2152 ],
2153 )?;
2154 }
2155 for command in snapshot.commands.values() {
2156 transaction.execute(
2157 "UPDATE strategy_commands SET status=?2, command_json=?3, updated_at=?4
2158 WHERE command_id=?1",
2159 params![
2160 command.id,
2161 enum_wire(command.status)?,
2162 serde_json::to_string(command)?,
2163 now,
2164 ],
2165 )?;
2166 }
2167 Ok(())
2168}
2169
2170fn enum_wire(value: impl serde::Serialize) -> Result<String> {
2171 serde_json::to_value(value)?
2172 .as_str()
2173 .map(str::to_owned)
2174 .ok_or_else(|| anyhow!("strategy_enum_invalid"))
2175}
2176
2177fn validate_opaque_id(value: &str, code: &'static str) -> Result<()> {
2178 ensure!(
2179 value == value.trim()
2180 && !value.is_empty()
2181 && value.len() <= 160
2182 && !value.chars().any(char::is_control),
2183 code
2184 );
2185 Ok(())
2186}
2187
2188fn validate_optional_text(value: &str, code: &'static str) -> Result<()> {
2189 ensure!(
2190 value == value.trim() && value.len() <= 160 && !value.chars().any(char::is_control),
2191 code
2192 );
2193 Ok(())
2194}
2195
2196fn ensure_column(
2197 connection: &Connection,
2198 table: &str,
2199 column: &str,
2200 declaration: &str,
2201) -> Result<()> {
2202 let mut statement = connection.prepare(&format!("PRAGMA table_info({table})"))?;
2203 let existing = statement
2204 .query_map([], |row| row.get::<_, String>(1))?
2205 .collect::<rusqlite::Result<Vec<_>>>()?;
2206 if !existing.iter().any(|value| value == column) {
2207 connection.execute_batch(&format!(
2208 "ALTER TABLE {table} ADD COLUMN {column} {declaration}"
2209 ))?;
2210 }
2211 Ok(())
2212}
2213
2214fn recovery_for_status(status: StrategyRunStatus) -> &'static str {
2215 match status {
2216 StrategyRunStatus::AuthorizationRequired => "Review and authorize the current semantics.",
2217 StrategyRunStatus::RuntimeMissing => "Bind an available verified local runtime.",
2218 StrategyRunStatus::Retryable => "Retry the failed command.",
2219 StrategyRunStatus::CancelInDoubt => "Inspect the external effect before continuing.",
2220 _ => "Inspect the run history.",
2221 }
2222}
2223
2224fn sha256_hex(bytes: &[u8]) -> String {
2225 Sha256::digest(bytes)
2226 .iter()
2227 .map(|byte| format!("{byte:02x}"))
2228 .collect()
2229}
2230
2231fn now_ms() -> i64 {
2232 std::time::SystemTime::now()
2233 .duration_since(std::time::UNIX_EPOCH)
2234 .map(|duration| duration.as_millis() as i64)
2235 .unwrap_or(0)
2236}
2237
2238fn remove_directory_tree(path: &Path) {
2239 let mut stack = vec![path.to_path_buf()];
2240 while let Some(current) = stack.pop() {
2241 if let Ok(metadata) = fs::symlink_metadata(¤t) {
2242 make_path_writable(¤t, metadata.permissions());
2243 if metadata.is_dir()
2244 && let Ok(entries) = fs::read_dir(¤t)
2245 {
2246 stack.extend(entries.flatten().map(|entry| entry.path()));
2247 }
2248 }
2249 }
2250 let _ = fs::remove_dir_all(path);
2251}
2252
2253fn make_path_writable(path: &Path, mut permissions: fs::Permissions) {
2254 #[cfg(unix)]
2255 {
2256 use std::os::unix::fs::PermissionsExt;
2257 permissions.set_mode(permissions.mode() | 0o200);
2258 }
2259 #[cfg(not(unix))]
2260 permissions.set_readonly(false);
2261 let _ = fs::set_permissions(path, permissions);
2262}
2263
2264#[cfg(test)]
2265mod tests {
2266 use super::*;
2267 use licoup_workflow::{
2268 ActorSlot, GraphState, GraphStateKind, RetryPolicy, Transition, TransitionEvent,
2269 TransitionMode, WorkflowLimits, WorkflowMetadata,
2270 };
2271 use serde_json::json;
2272
2273 fn workflow() -> WorkflowDefinition {
2274 WorkflowDefinition {
2275 schema: licoup_workflow::WORKFLOW_SCHEMA_VERSION.into(),
2276 metadata: WorkflowMetadata {
2277 id: "test".into(),
2278 name: "Test".into(),
2279 version: "1".into(),
2280 description: String::new(),
2281 },
2282 limits: WorkflowLimits::default(),
2283 actor_slots: vec![ActorSlot::required_actor("worker", "Worker")],
2284 runtimes: vec![],
2285 worksets: vec![],
2286 initial: "work".into(),
2287 states: vec![
2288 GraphState {
2289 id: "work".into(),
2290 kind: GraphStateKind::Actor,
2291 label: "Work".into(),
2292 instruction: String::new(),
2293 binding: Some("worker".into()),
2294 runtime: None,
2295 entry: None,
2296 workset: None,
2297 retry: RetryPolicy {
2298 max_attempts: 2,
2299 transient_only: true,
2300 },
2301 },
2302 GraphState {
2303 id: "done".into(),
2304 kind: GraphStateKind::Succeed,
2305 label: "Done".into(),
2306 instruction: String::new(),
2307 binding: None,
2308 runtime: None,
2309 entry: None,
2310 workset: None,
2311 retry: RetryPolicy::default(),
2312 },
2313 GraphState {
2314 id: "fail".into(),
2315 kind: GraphStateKind::Fail,
2316 label: "Fail".into(),
2317 instruction: String::new(),
2318 binding: None,
2319 runtime: None,
2320 entry: None,
2321 workset: None,
2322 retry: RetryPolicy::default(),
2323 },
2324 ],
2325 transitions: vec![
2326 Transition {
2327 id: "done".into(),
2328 from: "work".into(),
2329 to: "done".into(),
2330 event: TransitionEvent::Success,
2331 mode: TransitionMode::Flow,
2332 guard: None,
2333 },
2334 Transition {
2335 id: "failed".into(),
2336 from: "work".into(),
2337 to: "fail".into(),
2338 event: TransitionEvent::Failure,
2339 mode: TransitionMode::Flow,
2340 guard: None,
2341 },
2342 ],
2343 }
2344 }
2345
2346 #[test]
2347 fn binding_ordinal_migration_invalidates_existing_authorizations() {
2348 let mut connection = Connection::open_in_memory().unwrap();
2349 connection
2350 .execute_batch(
2351 "CREATE TABLE strategy_meta(key TEXT PRIMARY KEY, value TEXT NOT NULL);
2352 INSERT INTO strategy_meta(key, value) VALUES ('version', '1');
2353 CREATE TABLE strategy_definitions(
2354 definition_id TEXT NOT NULL,
2355 revision_digest TEXT PRIMARY KEY,
2356 semantics_digest TEXT NOT NULL,
2357 name TEXT NOT NULL,
2358 version TEXT NOT NULL,
2359 workflow_json TEXT NOT NULL,
2360 asset_count INTEGER NOT NULL,
2361 imported_at INTEGER NOT NULL
2362 );
2363 CREATE TABLE strategy_bindings(
2364 revision_digest TEXT NOT NULL REFERENCES strategy_definitions(revision_digest) ON DELETE CASCADE,
2365 slot_id TEXT NOT NULL,
2366 value_id TEXT NOT NULL,
2367 model TEXT NOT NULL DEFAULT '',
2368 reasoning_effort TEXT NOT NULL DEFAULT '',
2369 revision INTEGER NOT NULL,
2370 PRIMARY KEY(revision_digest, slot_id)
2371 );
2372 CREATE TABLE strategy_authorizations(
2373 revision_digest TEXT NOT NULL REFERENCES strategy_definitions(revision_digest) ON DELETE CASCADE,
2374 revision INTEGER NOT NULL,
2375 semantics_digest TEXT NOT NULL,
2376 binding_digest TEXT NOT NULL,
2377 authorization_digest TEXT NOT NULL,
2378 active INTEGER NOT NULL,
2379 created_at INTEGER NOT NULL,
2380 PRIMARY KEY(revision_digest, revision)
2381 );
2382 INSERT INTO strategy_definitions VALUES (
2383 'strategy', 'revision', 'semantics', 'Strategy', '1', '{}', 0, 1
2384 );
2385 INSERT INTO strategy_bindings VALUES (
2386 'revision', 'worker', 'agent:test', '', '', 1
2387 );
2388 INSERT INTO strategy_authorizations VALUES (
2389 'revision', 1, 'semantics', 'old-bindings', 'authorization', 1, 1
2390 );",
2391 )
2392 .unwrap();
2393
2394 let retired = initialize_schema(&mut connection).unwrap();
2395 assert!(retired.is_empty());
2396
2397 let (ordinal, active, version): (i64, i64, String) = connection
2398 .query_row(
2399 "SELECT b.ordinal, a.active, m.value
2400 FROM strategy_bindings b
2401 JOIN strategy_authorizations a USING (revision_digest)
2402 JOIN strategy_meta m ON m.key='version'",
2403 [],
2404 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
2405 )
2406 .unwrap();
2407 assert_eq!(ordinal, 0);
2408 assert_eq!(active, 0);
2409 assert_eq!(version, "3");
2410 validate_published_core_layout(&connection, "3").unwrap();
2411 }
2412
2413 #[test]
2414 fn ordinary_open_rejects_a_legacy_strategy_schema() {
2415 let root = std::env::temp_dir().join(format!("lico-strategy-legacy-{}", Uuid::new_v4()));
2416 let database = root.join("client-state/adaptive-flywheel/strategies.sqlite3");
2417 fs::create_dir_all(database.parent().unwrap()).unwrap();
2418 let connection = Connection::open(&database).unwrap();
2419 connection
2420 .execute_batch(
2421 "CREATE TABLE strategy_meta(key TEXT PRIMARY KEY, value TEXT NOT NULL);
2422 INSERT INTO strategy_meta(key, value) VALUES ('version', '1');",
2423 )
2424 .unwrap();
2425 drop(connection);
2426 assert!(StrategyStore::open(&root).is_err());
2427 StrategyStore::open_for_migration(&root).unwrap();
2428 StrategyStore::open(&root).unwrap();
2429 let _ = fs::remove_dir_all(root);
2430 }
2431
2432 #[test]
2433 fn typed_migration_materializes_legacy_workflow_routing_once() {
2434 let root =
2435 std::env::temp_dir().join(format!("lico-strategy-workflow-routing-{}", Uuid::new_v4()));
2436 let database = root.join("client-state/adaptive-flywheel/strategies.sqlite3");
2437 fs::create_dir_all(database.parent().unwrap()).unwrap();
2438 let connection = Connection::open(&database).unwrap();
2439 connection
2440 .execute_batch(
2441 "CREATE TABLE strategy_meta(key TEXT PRIMARY KEY, value TEXT NOT NULL);
2442 INSERT INTO strategy_meta(key, value) VALUES ('version', '2');
2443 CREATE TABLE strategy_definitions(
2444 definition_id TEXT NOT NULL,
2445 revision_digest TEXT PRIMARY KEY,
2446 semantics_digest TEXT NOT NULL,
2447 name TEXT NOT NULL,
2448 version TEXT NOT NULL,
2449 workflow_json TEXT NOT NULL,
2450 asset_count INTEGER NOT NULL,
2451 imported_at INTEGER NOT NULL
2452 );",
2453 )
2454 .unwrap();
2455
2456 let mut success_only = workflow();
2457 success_only.actor_slots[0].entry = false;
2458 success_only
2459 .transitions
2460 .retain(|transition| transition.event != TransitionEvent::Failure);
2461 let mut complete_as_failure = success_only.clone();
2462 complete_as_failure.transitions.push(Transition {
2463 id: "legacy-complete".into(),
2464 from: "work".into(),
2465 to: "fail".into(),
2466 event: TransitionEvent::Complete,
2467 mode: TransitionMode::Flow,
2468 guard: None,
2469 });
2470 let mut redundant_complete = workflow();
2471 redundant_complete.actor_slots[0].entry = false;
2472 redundant_complete.transitions.push(Transition {
2473 id: "legacy-complete".into(),
2474 from: "work".into(),
2475 to: "fail".into(),
2476 event: TransitionEvent::Complete,
2477 mode: TransitionMode::Flow,
2478 guard: None,
2479 });
2480 for (revision, definition) in [
2481 ("legacy-success", success_only),
2482 ("legacy-convert", complete_as_failure),
2483 ("legacy-redundant", redundant_complete),
2484 ] {
2485 connection
2486 .execute(
2487 "INSERT INTO strategy_definitions VALUES (?1, ?1, 'legacy', ?1, '1', ?2, 0, 1)",
2488 params![revision, serde_json::to_string(&definition).unwrap()],
2489 )
2490 .unwrap();
2491 }
2492 drop(connection);
2493
2494 assert!(StrategyStore::open(&root).is_err());
2495 StrategyStore::open_for_migration(&root).unwrap();
2496 let store = StrategyStore::open(&root).unwrap();
2497 for revision in ["legacy-success", "legacy-convert", "legacy-redundant"] {
2498 let definition = store.definition_by_revision(revision).unwrap();
2499 assert!(definition.workflow.actor_slots[0].entry);
2500 assert_eq!(
2501 definition
2502 .workflow
2503 .transitions
2504 .iter()
2505 .filter(|transition| {
2506 transition.from == "work" && transition.event == TransitionEvent::Failure
2507 })
2508 .count(),
2509 1
2510 );
2511 assert!(definition.workflow.transitions.iter().all(|transition| {
2512 transition.from != "work" || transition.event != TransitionEvent::Complete
2513 }));
2514 assert!(compile_workflow(definition.workflow).is_ok());
2515 }
2516 let _ = fs::remove_dir_all(root);
2517 }
2518
2519 #[test]
2520 fn workflow_migration_preserves_live_run_and_authorization_identity() {
2521 let store = StrategyStore::open_in_memory().unwrap();
2522 let revision = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd";
2523 let semantics = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee";
2524 let current = workflow();
2525 store
2526 .register_definition(revision, semantics, ¤t, 1, 1)
2527 .unwrap();
2528 store
2529 .update_binding(revision, "worker", "agent:test", "", "", None)
2530 .unwrap();
2531 let preview = store.authorization_preview(revision).unwrap();
2532 let authorization = store
2533 .grant_authorization(revision, &preview.authorization_digest)
2534 .unwrap();
2535 let run = store
2536 .start_run(revision, json!({}), "migration-live-run", None, None)
2537 .unwrap();
2538 let claimed = store
2539 .claim_next_command(&run.run_id, "migration-host", now_ms() + 60_000)
2540 .unwrap()
2541 .unwrap();
2542 store
2543 .apply_event(
2544 &run.run_id,
2545 ReducerEvent::CommandStarted {
2546 command_id: claimed.id.clone(),
2547 attempt_token: claimed.attempt_token.clone(),
2548 },
2549 )
2550 .unwrap();
2551
2552 let mut legacy = current;
2553 legacy.actor_slots[0].entry = false;
2554 legacy
2555 .transitions
2556 .retain(|transition| transition.event != TransitionEvent::Failure);
2557 let before = store
2558 .with_connection(|connection| {
2559 connection.execute(
2560 "UPDATE strategy_definitions SET workflow_json=?2 WHERE revision_digest=?1",
2561 params![revision, serde_json::to_string(&legacy)?],
2562 )?;
2563 connection.execute("UPDATE strategy_meta SET value='2' WHERE key='version'", [])?;
2564 let run_row = connection.query_row(
2565 "SELECT semantics_digest, snapshot_json FROM strategy_runs WHERE run_id=?1",
2566 params![run.run_id],
2567 |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
2568 )?;
2569 let command_row = connection.query_row(
2570 "SELECT status, attempt, attempt_token, command_json, lease_owner, lease_until
2571 FROM strategy_commands WHERE command_id=?1",
2572 params![claimed.id],
2573 |row| {
2574 Ok((
2575 row.get::<_, String>(0)?,
2576 row.get::<_, i64>(1)?,
2577 row.get::<_, String>(2)?,
2578 row.get::<_, String>(3)?,
2579 row.get::<_, Option<String>>(4)?,
2580 row.get::<_, Option<i64>>(5)?,
2581 ))
2582 },
2583 )?;
2584 let authorization_row = connection.query_row(
2585 "SELECT semantics_digest, binding_digest, authorization_digest, active
2586 FROM strategy_authorizations WHERE revision_digest=?1",
2587 params![revision],
2588 |row| {
2589 Ok((
2590 row.get::<_, String>(0)?,
2591 row.get::<_, String>(1)?,
2592 row.get::<_, String>(2)?,
2593 row.get::<_, i64>(3)?,
2594 ))
2595 },
2596 )?;
2597 Ok((run_row, command_row, authorization_row))
2598 })
2599 .unwrap();
2600
2601 store.with_connection(initialize_schema).unwrap();
2602
2603 let after = store
2604 .with_connection(|connection| {
2605 let run_row = connection.query_row(
2606 "SELECT semantics_digest, snapshot_json FROM strategy_runs WHERE run_id=?1",
2607 params![run.run_id],
2608 |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
2609 )?;
2610 let command_row = connection.query_row(
2611 "SELECT status, attempt, attempt_token, command_json, lease_owner, lease_until
2612 FROM strategy_commands WHERE command_id=?1",
2613 params![claimed.id],
2614 |row| {
2615 Ok((
2616 row.get::<_, String>(0)?,
2617 row.get::<_, i64>(1)?,
2618 row.get::<_, String>(2)?,
2619 row.get::<_, String>(3)?,
2620 row.get::<_, Option<String>>(4)?,
2621 row.get::<_, Option<i64>>(5)?,
2622 ))
2623 },
2624 )?;
2625 let authorization_row = connection.query_row(
2626 "SELECT semantics_digest, binding_digest, authorization_digest, active
2627 FROM strategy_authorizations WHERE revision_digest=?1",
2628 params![revision],
2629 |row| {
2630 Ok((
2631 row.get::<_, String>(0)?,
2632 row.get::<_, String>(1)?,
2633 row.get::<_, String>(2)?,
2634 row.get::<_, i64>(3)?,
2635 ))
2636 },
2637 )?;
2638 Ok((run_row, command_row, authorization_row))
2639 })
2640 .unwrap();
2641 assert_eq!(after, before);
2642 assert_eq!(after.0.0, semantics);
2643 assert_eq!(after.1.0, "running");
2644 assert_eq!(after.1.2, claimed.attempt_token);
2645 assert_eq!(after.2.2, authorization.authorization_digest);
2646 assert_eq!(after.2.3, 1);
2647
2648 let migrated = store.definition_by_revision(revision).unwrap();
2649 assert_eq!(migrated.summary.revision_digest, revision);
2650 assert_eq!(migrated.summary.semantics_digest, semantics);
2651 assert!(migrated.workflow.actor_slots[0].entry);
2652 assert!(compile_workflow(migrated.workflow).is_ok());
2653 }
2654
2655 #[test]
2656 fn workflow_routing_migration_rolls_back_all_rows_when_a_later_row_is_invalid() {
2657 let store = StrategyStore::open_in_memory().unwrap();
2658 let mut legacy = workflow();
2659 legacy.actor_slots[0].entry = false;
2660 legacy
2661 .transitions
2662 .retain(|transition| transition.event != TransitionEvent::Failure);
2663 let legacy_json = serde_json::to_string(&legacy).unwrap();
2664 let mut invalid = legacy.clone();
2665 invalid.initial = "missing-state".into();
2666 let invalid_json = serde_json::to_string(&invalid).unwrap();
2667 store
2668 .with_connection(|connection| {
2669 connection.execute("UPDATE strategy_meta SET value='2' WHERE key='version'", [])?;
2670 for (definition, revision, workflow_json) in [
2671 ("first", "a-valid-legacy", &legacy_json),
2672 ("later", "z-invalid-legacy", &invalid_json),
2673 ] {
2674 connection.execute(
2675 "INSERT INTO strategy_definitions(
2676 definition_id, revision_digest, semantics_digest, name, version,
2677 workflow_json, asset_count, imported_at
2678 ) VALUES (?1, ?2, 'legacy-semantics', ?1, '1', ?3, 0, 1)",
2679 params![definition, revision, workflow_json],
2680 )?;
2681 }
2682 Ok(())
2683 })
2684 .unwrap();
2685
2686 assert!(store.with_connection(initialize_schema).is_err());
2687 store
2688 .with_connection(|connection| {
2689 let version: String = connection.query_row(
2690 "SELECT value FROM strategy_meta WHERE key='version'",
2691 [],
2692 |row| row.get(0),
2693 )?;
2694 let first: String = connection.query_row(
2695 "SELECT workflow_json FROM strategy_definitions WHERE revision_digest='a-valid-legacy'",
2696 [],
2697 |row| row.get(0),
2698 )?;
2699 assert_eq!(version, "2");
2700 assert_eq!(first, legacy_json);
2701 Ok(())
2702 })
2703 .unwrap();
2704 }
2705
2706 #[test]
2707 fn event_snapshot_and_outbox_commit_together() {
2708 let store = StrategyStore::open_in_memory().unwrap();
2709 store
2710 .register_definition(
2711 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
2712 "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
2713 &workflow(),
2714 1,
2715 1,
2716 )
2717 .unwrap();
2718 let binding = store
2719 .update_binding(
2720 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
2721 "worker",
2722 "agent:test",
2723 "gpt-5",
2724 "high",
2725 None,
2726 )
2727 .unwrap();
2728 assert_eq!(binding.model, "gpt-5");
2729 assert_eq!(binding.reasoning_effort, "high");
2730 let persisted = store
2731 .definition_by_revision(
2732 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
2733 )
2734 .unwrap();
2735 assert_eq!(persisted.bindings[0].ordinal, 0);
2736 assert_eq!(persisted.bindings[0].model, "gpt-5");
2737 assert_eq!(persisted.bindings[0].reasoning_effort, "high");
2738 let chain = store
2739 .replace_slot_bindings(
2740 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
2741 "worker",
2742 &[
2743 BindingCandidate {
2744 value_id: "agent:primary".into(),
2745 model: "model-a".into(),
2746 reasoning_effort: "high".into(),
2747 },
2748 BindingCandidate {
2749 value_id: "agent:fallback".into(),
2750 model: "model-b".into(),
2751 reasoning_effort: String::new(),
2752 },
2753 ],
2754 Some(binding.revision),
2755 )
2756 .unwrap();
2757 assert_eq!(chain.len(), 2);
2758 assert_eq!(chain[0].ordinal, 0);
2759 assert_eq!(chain[1].ordinal, 1);
2760 assert_eq!(chain[1].value_id, "agent:fallback");
2761 let preview = store
2762 .authorization_preview(
2763 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
2764 )
2765 .unwrap();
2766 store
2767 .grant_authorization(
2768 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
2769 &preview.authorization_digest,
2770 )
2771 .unwrap();
2772 let run = store
2773 .start_run(
2774 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
2775 json!({}),
2776 "idempotency-test",
2777 Some("conversation:test"),
2778 None,
2779 )
2780 .unwrap();
2781 assert_eq!(run.commands.len(), 1);
2782 assert_eq!(
2783 store
2784 .active_run_for_conversation(
2785 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
2786 "conversation:test",
2787 )
2788 .unwrap()
2789 .map(|snapshot| snapshot.run_id),
2790 Some(run.run_id.clone())
2791 );
2792 let indexed: (String, i64) = store
2793 .with_connection(|connection| {
2794 connection
2795 .query_row(
2796 "SELECT conversation_id, terminal FROM strategy_runs WHERE run_id=?1",
2797 params![run.run_id],
2798 |row| Ok((row.get(0)?, row.get(1)?)),
2799 )
2800 .map_err(Into::into)
2801 })
2802 .unwrap();
2803 assert_eq!(indexed, ("conversation:test".to_owned(), 0));
2804 let replay = store
2805 .start_run(
2806 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
2807 json!({}),
2808 "idempotency-test",
2809 Some("conversation:test"),
2810 None,
2811 )
2812 .unwrap();
2813 assert_eq!(run, replay);
2814 }
2815
2816 #[test]
2817 fn effect_authorization_revalidates_digest_owner_and_live_lease() {
2818 let store = StrategyStore::open_in_memory().unwrap();
2819 let revision = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee";
2820 store
2821 .register_definition(revision, revision, &workflow(), 1, 1)
2822 .unwrap();
2823 store
2824 .update_binding(revision, "worker", "agent:test", "", "", None)
2825 .unwrap();
2826 let preview = store.authorization_preview(revision).unwrap();
2827 let authorization = store
2828 .grant_authorization(revision, &preview.authorization_digest)
2829 .unwrap();
2830 let run = store
2831 .start_run(revision, json!({}), "authorization-fence", None, None)
2832 .unwrap();
2833 let command = store
2834 .claim_next_command(&run.run_id, "claimant", now_ms() + 60_000)
2835 .unwrap()
2836 .unwrap();
2837 store
2838 .apply_event(
2839 &run.run_id,
2840 ReducerEvent::CommandStarted {
2841 command_id: command.id.clone(),
2842 attempt_token: command.attempt_token.clone(),
2843 },
2844 )
2845 .unwrap();
2846 store
2847 .authorize_effect(
2848 &run.run_id,
2849 &command.id,
2850 &command.attempt_token,
2851 &authorization.authorization_digest,
2852 "claimant",
2853 now_ms() + 60_000,
2854 )
2855 .unwrap();
2856 assert!(
2857 store
2858 .authorize_effect(
2859 &run.run_id,
2860 &command.id,
2861 &command.attempt_token,
2862 &authorization.authorization_digest,
2863 "other-claimant",
2864 now_ms() + 60_000,
2865 )
2866 .is_err()
2867 );
2868 store.revoke_authorization(revision).unwrap();
2869 assert!(
2870 store
2871 .authorize_effect(
2872 &run.run_id,
2873 &command.id,
2874 &command.attempt_token,
2875 &authorization.authorization_digest,
2876 "claimant",
2877 now_ms() + 60_000,
2878 )
2879 .is_err()
2880 );
2881 let next = store.authorization_preview(revision).unwrap();
2882 let next = store
2883 .grant_authorization(revision, &next.authorization_digest)
2884 .unwrap();
2885 assert!(
2886 store
2887 .authorize_effect(
2888 &run.run_id,
2889 &command.id,
2890 &command.attempt_token,
2891 &authorization.authorization_digest,
2892 "claimant",
2893 now_ms() + 60_000,
2894 )
2895 .is_err()
2896 );
2897 store
2898 .authorize_effect(
2899 &run.run_id,
2900 &command.id,
2901 &command.attempt_token,
2902 &next.authorization_digest,
2903 "claimant",
2904 now_ms() + 60_000,
2905 )
2906 .unwrap();
2907 let connection = Connection::open(store.db_path()).unwrap();
2908 connection
2909 .execute(
2910 "UPDATE strategy_commands SET lease_until=0 WHERE command_id=?1",
2911 params![&command.id],
2912 )
2913 .unwrap();
2914 assert!(
2915 store
2916 .authorize_effect(
2917 &run.run_id,
2918 &command.id,
2919 &command.attempt_token,
2920 &next.authorization_digest,
2921 "claimant",
2922 now_ms() + 60_000,
2923 )
2924 .is_err(),
2925 "an expired running lease must never issue an effect permit"
2926 );
2927 }
2928
2929 #[test]
2930 fn expired_claim_recovery_is_atomic_and_retries_before_start() {
2931 let store = StrategyStore::open_in_memory().unwrap();
2932 let revision = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
2933 store
2934 .register_definition(
2935 revision,
2936 "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
2937 &workflow(),
2938 1,
2939 1,
2940 )
2941 .unwrap();
2942 store
2943 .update_binding(revision, "worker", "agent:test", "", "", None)
2944 .unwrap();
2945 let preview = store.authorization_preview(revision).unwrap();
2946 store
2947 .grant_authorization(revision, &preview.authorization_digest)
2948 .unwrap();
2949 let run = store
2950 .start_run(revision, json!({}), "recovery-test", None, None)
2951 .unwrap();
2952 let claimed = store
2953 .claim_next_command(&run.run_id, "claimant", now_ms() + 60_000)
2954 .unwrap()
2955 .unwrap();
2956 let connection = Connection::open(store.db_path()).unwrap();
2957 connection
2958 .execute(
2959 "UPDATE strategy_commands SET lease_until=0 WHERE command_id=?1",
2960 params![claimed.id],
2961 )
2962 .unwrap();
2963
2964 assert!(store.recover_next_expired_command(&run.run_id).unwrap());
2965 assert!(!store.recover_next_expired_command(&run.run_id).unwrap());
2966 assert!(
2967 store
2968 .renew_command_lease(&claimed.id, "claimant", now_ms() + 60_000)
2969 .is_err()
2970 );
2971 let recovered = store.run(&run.run_id).unwrap();
2972 assert_eq!(
2973 recovered.commands[&claimed.id].status,
2974 CommandStatus::Cancelled
2975 );
2976 assert!(
2977 recovered.commands.values().any(|command| {
2978 command.attempt == 2 && command.status == CommandStatus::Pending
2979 })
2980 );
2981 }
2982
2983 #[test]
2984 fn expired_running_effect_is_fenced_in_doubt_without_retry() {
2985 let store = StrategyStore::open_in_memory().unwrap();
2986 let revision = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";
2987 store
2988 .register_definition(
2989 revision,
2990 "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
2991 &workflow(),
2992 1,
2993 1,
2994 )
2995 .unwrap();
2996 store
2997 .update_binding(revision, "worker", "agent:test", "", "", None)
2998 .unwrap();
2999 let preview = store.authorization_preview(revision).unwrap();
3000 store
3001 .grant_authorization(revision, &preview.authorization_digest)
3002 .unwrap();
3003 let run = store
3004 .start_run(revision, json!({}), "running-recovery-test", None, None)
3005 .unwrap();
3006 let claimed = store
3007 .claim_next_command(&run.run_id, "claimant", now_ms() + 60_000)
3008 .unwrap()
3009 .unwrap();
3010 store
3011 .apply_event(
3012 &run.run_id,
3013 ReducerEvent::CommandStarted {
3014 command_id: claimed.id.clone(),
3015 attempt_token: claimed.attempt_token.clone(),
3016 },
3017 )
3018 .unwrap();
3019 let connection = Connection::open(store.db_path()).unwrap();
3020 connection
3021 .execute(
3022 "UPDATE strategy_commands SET lease_until=0 WHERE command_id=?1",
3023 params![claimed.id],
3024 )
3025 .unwrap();
3026
3027 assert!(store.recover_next_expired_command(&run.run_id).unwrap());
3028 let recovered = store.run(&run.run_id).unwrap();
3029 assert_eq!(recovered.status, StrategyRunStatus::CancelInDoubt);
3030 assert_eq!(
3031 recovered.commands[&claimed.id].status,
3032 CommandStatus::InDoubt
3033 );
3034 assert!(
3035 !recovered
3036 .commands
3037 .values()
3038 .any(|command| command.attempt == 2)
3039 );
3040 }
3041
3042 #[test]
3043 fn an_unexpired_claim_stays_held_when_this_host_becomes_the_driver() {
3044 let store = StrategyStore::open_in_memory().unwrap();
3045 let revision = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee";
3046 store
3047 .register_definition(
3048 revision,
3049 "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
3050 &workflow(),
3051 1,
3052 1,
3053 )
3054 .unwrap();
3055 store
3056 .update_binding(revision, "worker", "agent:test", "", "", None)
3057 .unwrap();
3058 let preview = store.authorization_preview(revision).unwrap();
3059 store
3060 .grant_authorization(revision, &preview.authorization_digest)
3061 .unwrap();
3062 let run = store
3063 .start_run(revision, json!({}), "abandoned-host-recovery", None, None)
3064 .unwrap();
3065 let claimed = store
3066 .claim_next_command(&run.run_id, "claimant", now_ms() + 60_000)
3067 .unwrap()
3068 .unwrap();
3069 store
3070 .apply_event(
3071 &run.run_id,
3072 ReducerEvent::CommandStarted {
3073 command_id: claimed.id.clone(),
3074 attempt_token: claimed.attempt_token.clone(),
3075 },
3076 )
3077 .unwrap();
3078
3079 assert!(!store.recover_next_expired_command(&run.run_id).unwrap());
3082 let held = store.run(&run.run_id).unwrap();
3083 assert_eq!(held.commands[&claimed.id].status, CommandStatus::Running);
3084 assert!(held.commands[&claimed.id].failure_code.is_none());
3085 assert!(
3086 !held.commands.values().any(|command| command.attempt == 2),
3087 "an unexpired in-flight effect is never repeated"
3088 );
3089 assert_eq!(held.status, StrategyRunStatus::Running);
3090 }
3091
3092 #[test]
3093 fn a_released_unexpired_claim_never_becomes_retryable() {
3094 let store = StrategyStore::open_in_memory().unwrap();
3095 let revision = "5656565656565656565656565656565656565656565656565656565656565656";
3096 store
3097 .register_definition(
3098 revision,
3099 "7878787878787878787878787878787878787878787878787878787878787878",
3100 &workflow(),
3101 1,
3102 1,
3103 )
3104 .unwrap();
3105 store
3106 .update_binding(revision, "worker", "agent:test", "", "", None)
3107 .unwrap();
3108 let preview = store.authorization_preview(revision).unwrap();
3109 store
3110 .grant_authorization(revision, &preview.authorization_digest)
3111 .unwrap();
3112 let run = store
3113 .start_run(revision, json!({}), "held-claim", None, None)
3114 .unwrap();
3115 let claimed = store
3116 .claim_next_command(&run.run_id, "claimant", now_ms() + 60_000)
3117 .unwrap()
3118 .unwrap();
3119 store
3120 .apply_event(
3121 &run.run_id,
3122 ReducerEvent::CommandStarted {
3123 command_id: claimed.id.clone(),
3124 attempt_token: claimed.attempt_token.clone(),
3125 },
3126 )
3127 .unwrap();
3128
3129 assert!(
3131 store
3132 .apply_event(
3133 &run.run_id,
3134 ReducerEvent::CommandSucceeded {
3135 command_id: claimed.id.clone(),
3136 attempt_token: String::from("attempt:someone-else"),
3137 output: json!({ "ok": true }),
3138 },
3139 )
3140 .is_err(),
3141 "a stale owner result cannot settle an attempt it does not own"
3142 );
3143 let unchanged = store.run(&run.run_id).unwrap();
3144 assert_eq!(
3145 unchanged.commands[&claimed.id].status,
3146 CommandStatus::Running
3147 );
3148 assert!(unchanged.commands[&claimed.id].output_digest.is_none());
3149 }
3150
3151 #[test]
3152 fn bind_conversation_if_absent_fills_an_unbound_run() {
3153 let store = StrategyStore::open_in_memory().unwrap();
3154 let revision = "1111111111111111111111111111111111111111111111111111111111111111";
3155 store
3156 .register_definition(
3157 revision,
3158 "2222222222222222222222222222222222222222222222222222222222222222",
3159 &workflow(),
3160 1,
3161 1,
3162 )
3163 .unwrap();
3164 store
3165 .update_binding(revision, "worker", "agent:test", "", "", None)
3166 .unwrap();
3167 let preview = store.authorization_preview(revision).unwrap();
3168 store
3169 .grant_authorization(revision, &preview.authorization_digest)
3170 .unwrap();
3171 let run = store
3172 .start_run(revision, json!({}), "bind-conversation", None, None)
3173 .unwrap();
3174 assert!(run.conversation_id.is_none());
3175 store
3176 .bind_conversation_if_absent(&run.run_id, "conversation:group")
3177 .unwrap();
3178 store
3179 .bind_conversation_if_absent(&run.run_id, "conversation:other")
3180 .unwrap();
3181 let bound = store.run(&run.run_id).unwrap();
3182 assert_eq!(bound.conversation_id.as_deref(), Some("conversation:group"));
3183 }
3184
3185 fn digest(fill: char) -> String {
3186 fill.to_string().repeat(64)
3187 }
3188
3189 #[test]
3190 fn retired_builtin_definition_is_deleted_by_the_typed_migration() {
3191 let root =
3192 std::env::temp_dir().join(format!("lico-adaptive-flywheel-retire-{}", Uuid::new_v4()));
3193 let database = root.join("client-state/adaptive-flywheel/strategies.sqlite3");
3194 fs::create_dir_all(database.parent().unwrap()).unwrap();
3195 let retired_id_digest = digest('a');
3196 let retired_name_digest = digest('b');
3197 let kept_digest = digest('c');
3198 let connection = Connection::open(&database).unwrap();
3199 connection
3200 .execute_batch(
3201 "CREATE TABLE strategy_meta(key TEXT PRIMARY KEY, value TEXT NOT NULL);
3202 INSERT INTO strategy_meta(key, value) VALUES ('version', '1');
3203 CREATE TABLE strategy_definitions(
3204 definition_id TEXT NOT NULL,
3205 revision_digest TEXT PRIMARY KEY,
3206 semantics_digest TEXT NOT NULL,
3207 name TEXT NOT NULL,
3208 version TEXT NOT NULL,
3209 workflow_json TEXT NOT NULL,
3210 asset_count INTEGER NOT NULL,
3211 imported_at INTEGER NOT NULL
3212 );
3213 CREATE TABLE strategy_runs(
3214 run_id TEXT PRIMARY KEY,
3215 revision_digest TEXT NOT NULL REFERENCES strategy_definitions(revision_digest),
3216 semantics_digest TEXT NOT NULL,
3217 idempotency_key TEXT NOT NULL UNIQUE,
3218 request_digest TEXT NOT NULL,
3219 snapshot_json TEXT NOT NULL,
3220 created_at INTEGER NOT NULL,
3221 updated_at INTEGER NOT NULL
3222 );",
3223 )
3224 .unwrap();
3225 let workflow_json = serde_json::to_string(&workflow()).unwrap();
3226 for (definition_id, revision, name) in [
3227 (
3228 "licoup-basic",
3229 retired_id_digest.clone(),
3230 "LicoUp Basic Strategy",
3231 ),
3232 (
3233 "other-id",
3234 retired_name_digest.clone(),
3235 "LicoUp Basic Strategy",
3236 ),
3237 ("imported-graph", kept_digest.clone(), "Imported Graph"),
3238 ] {
3239 connection
3240 .execute(
3241 "INSERT INTO strategy_definitions(
3242 definition_id, revision_digest, semantics_digest, name, version,
3243 workflow_json, asset_count, imported_at
3244 ) VALUES (?1, ?2, ?3, ?4, '1', ?5, 1, 1)",
3245 params![definition_id, revision, digest('d'), name, workflow_json],
3246 )
3247 .unwrap();
3248 }
3249 let snapshot_json = serde_json::to_string(&RunSnapshot::empty(
3250 "run-retired",
3251 &retired_id_digest,
3252 "semantics",
3253 ))
3254 .unwrap();
3255 connection
3256 .execute(
3257 "INSERT INTO strategy_runs(
3258 run_id, revision_digest, semantics_digest, idempotency_key,
3259 request_digest, snapshot_json, created_at, updated_at
3260 ) VALUES ('run-retired', ?1, 'semantics', 'key-retired', 'request', ?2, 1, 1)",
3261 params![retired_id_digest, snapshot_json],
3262 )
3263 .unwrap();
3264 drop(connection);
3265 let revisions = root
3266 .join("client-state")
3267 .join("adaptive-flywheel")
3268 .join("strategy-packages")
3269 .join("revisions");
3270 let retired_tree = revisions.join(&retired_id_digest);
3271 fs::create_dir_all(retired_tree.join("content")).unwrap();
3272 fs::write(retired_tree.join("content").join("marker.txt"), "retired").unwrap();
3273 let kept_tree = revisions.join(&kept_digest);
3274 fs::create_dir_all(kept_tree.join("content")).unwrap();
3275 fs::write(kept_tree.join("content").join("marker.txt"), "kept").unwrap();
3276
3277 assert!(
3278 StrategyStore::open(&root).is_err(),
3279 "a legacy schema must pass through the migration path first"
3280 );
3281 StrategyStore::open_for_migration(&root).unwrap();
3282
3283 let store = StrategyStore::open(&root).unwrap();
3284 let listed = store.list_definitions().unwrap();
3285 assert_eq!(listed.len(), 1);
3286 assert_eq!(listed[0].definition_id, "imported-graph");
3287 assert_eq!(listed[0].name, "Imported Graph");
3288 assert_eq!(listed[0].revision_digest, kept_digest);
3289 assert!(store.definition_by_revision(&retired_id_digest).is_err());
3290 assert!(store.definition_by_revision(&retired_name_digest).is_err());
3291 assert!(!retired_tree.exists());
3292 assert!(kept_tree.join("content").join("marker.txt").exists());
3293 remove_directory_tree(&root);
3294 }
3295}