M0006_Advisor.vala 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145
  1. using InvercargillSql.Migrations;
  2. using Sqlite;
  3. namespace ClankerSaddle.Data.Migrations {
  4. /**
  5. * Goal 27 (the advisor): the AI Chat loop's storage — chats,
  6. * messages and suggestions.
  7. *
  8. * advisor_chats is one conversation between a user and the
  9. * read-only advisor LLM loop; `status` is the loop's coarse state
  10. * ('idle' — no turn in flight, 'running' — a turn is executing,
  11. * 'awaiting_answer' — the loop parked on an ask_question and waits
  12. * for the human's answer).
  13. *
  14. * advisor_messages is the conversation stored IN THE WIRE FORMAT:
  15. * user / assistant / tool rows reconstruct the OpenAI messages
  16. * array on every turn (tool rows carry the call's name, arguments
  17. * and result in the JSON `payload`), so a restart loses nothing —
  18. * there is no separate transcript cache. 'error' rows carry a
  19. * failed turn's message; 'system' rows carry advisor-external
  20. * notices (task completion callbacks). A question is an assistant
  21. * row whose payload holds the options array and whose `answer`
  22. * column is filled when the human replies.
  23. *
  24. * advisor_suggestions is a suggested task or goal: INERT until the
  25. * chat's user approves it in the UI ('pending' → 'approved' or
  26. * 'declined'); an approved row keeps the created task_id / goal_id
  27. * so the completion callback (task 3 of the goal) can find the chat
  28. * again. delivered_at marks the callback delivered.
  29. *
  30. * IDEMPOTENCE: same contract as M0002–M0005 — up() runs before the
  31. * apply transaction opens (the builder only queues), so it
  32. * introspects sqlite_master through a separate read-only handle and
  33. * queues the tables only when the schema still lacks them.
  34. */
  35. public class M0006_Advisor : Migration {
  36. public override string name { get { return "Advisor chats"; } }
  37. public override uint64 serial { get { return 6; } }
  38. public override string migration_namespace { get { return "clanker-saddle"; } }
  39. // Same resolution as M0002–M0005: the runner never hands up()
  40. // a connection, so the schema is read through a read-only
  41. // handle on the SPRY_DB_PATH main.vala connects to.
  42. private string db_path() {
  43. var path = Environment.get_variable("SPRY_DB_PATH") ?? "";
  44. if (path.strip().length == 0) {
  45. return "./clanker-saddle.sqlite";
  46. }
  47. return path;
  48. }
  49. private bool table_exists(string name) {
  50. Database db;
  51. int rc = Database.open_v2(db_path(), out db, Sqlite.OPEN_READONLY);
  52. if (rc != Sqlite.OK) {
  53. return false;
  54. }
  55. Statement statement;
  56. rc = db.prepare_v2(
  57. "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?",
  58. -1, out statement);
  59. if (rc != Sqlite.OK) {
  60. return false;
  61. }
  62. statement.bind_text(1, name, -1);
  63. if (statement.step() != Sqlite.ROW) {
  64. return false;
  65. }
  66. return statement.column_int64(0) > 0;
  67. }
  68. public override void up(MigrationBuilder b) {
  69. if (table_exists("advisor_chats")) {
  70. return;
  71. }
  72. b.create_table("advisor_chats", (t) => {
  73. t.column<int64?>("id")
  74. .auto_increment()
  75. .primary_key();
  76. t.column<int64?>("user_id")
  77. .not_null();
  78. t.column<string>("title")
  79. .not_null()
  80. .default_value<string>("");
  81. t.column<string>("status")
  82. .not_null()
  83. .default_value<string>("idle");
  84. t.column<DateTime?>("created_at");
  85. t.column<DateTime?>("updated_at");
  86. });
  87. b.create_table("advisor_messages", (t) => {
  88. t.column<int64?>("id")
  89. .auto_increment()
  90. .primary_key();
  91. t.column<int64?>("chat_id")
  92. .not_null();
  93. t.column<string>("kind")
  94. .not_null()
  95. .default_value<string>("user");
  96. t.column<string>("content")
  97. .not_null()
  98. .default_value<string>("");
  99. t.column<string>("payload");
  100. t.column<string>("answer");
  101. t.column<DateTime?>("answered_at");
  102. t.column<DateTime?>("created_at");
  103. });
  104. b.create_table("advisor_suggestions", (t) => {
  105. t.column<int64?>("id")
  106. .auto_increment()
  107. .primary_key();
  108. t.column<int64?>("chat_id")
  109. .not_null();
  110. t.column<int64?>("user_id")
  111. .not_null();
  112. t.column<string>("kind")
  113. .not_null()
  114. .default_value<string>("task");
  115. t.column<string>("payload")
  116. .not_null()
  117. .default_value<string>("{}");
  118. t.column<string>("status")
  119. .not_null()
  120. .default_value<string>("pending");
  121. t.column<int64?>("task_id")
  122. .not_null()
  123. .default_value<int64?>(0);
  124. t.column<int64?>("goal_id")
  125. .not_null()
  126. .default_value<int64?>(0);
  127. t.column<DateTime?>("created_at");
  128. t.column<DateTime?>("decided_at");
  129. t.column<DateTime?>("delivered_at");
  130. });
  131. }
  132. public override void down(MigrationBuilder b) {
  133. b.drop_table("advisor_suggestions");
  134. b.drop_table("advisor_messages");
  135. b.drop_table("advisor_chats");
  136. }
  137. }
  138. }