EventService.vala 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363
  1. using Invercargill;
  2. using Invercargill.DataStructures;
  3. using Invercargill.Expressions;
  4. using InvercargillSql.Orm;
  5. using Inversion;
  6. using ClankerSaddle.Data;
  7. namespace ClankerSaddle.Services {
  8. /** Event kinds (the `events.kind` values, plan 17 §3.1/§7.2). */
  9. public const string EVENT_TURN_TEXT = "turn_text";
  10. public const string EVENT_TOOL_CALL = "tool_call";
  11. public const string EVENT_TOOL_RESULT = "tool_result";
  12. public const string EVENT_COMMAND_OUTPUT = "command_output";
  13. public const string EVENT_PROGRESS = "progress";
  14. public const string EVENT_COMMENT = "comment";
  15. public const string EVENT_STATE_CHANGE = "state_change";
  16. public const string EVENT_LIFECYCLE = "lifecycle";
  17. public const string EVENT_PLAN_SUBMITTED = "plan_submitted";
  18. public const string EVENT_VERDICT = "verdict";
  19. public const string EVENT_FORCE_WAKE = "force_wake";
  20. public const string EVENT_USAGE = "usage";
  21. public const string EVENT_ANSWER = "answer";
  22. public const string EVENT_CANCEL = "cancel";
  23. /** Event actors (the `events.actor` values). */
  24. public const string EVENT_ACTOR_MODEL = "model";
  25. public const string EVENT_ACTOR_HARNESS = "harness";
  26. public const string EVENT_ACTOR_USER = "user";
  27. public const string EVENT_ACTOR_SYSTEM = "system";
  28. /** Event scopes (the `events.scope_type` values). */
  29. public const string EVENT_SCOPE_PROJECT = "project";
  30. public const string EVENT_SCOPE_GOAL = "goal";
  31. public const string EVENT_SCOPE_TASK = "task";
  32. public const string EVENT_SCOPE_SESSION = "session";
  33. /** The event log page/SSE window (plan 17 §3.2). */
  34. public const int EVENT_PAGE_SIZE = 30;
  35. /**
  36. * The plan-17 unified event ledger's writer and read model: daemon
  37. * event ingestion from `POST /api/saddle/chunks` (idempotent on
  38. * UNIQUE(session_id, seq)), web-originated appends from the domain
  39. * mutations, and the paged/newer-than queries the event log page,
  40. * the timeline, the goal narrative and the resume machinery read.
  41. */
  42. public class EventService : Object {
  43. private OrmSession db = inject<OrmSession>();
  44. // ------------------------------------------------------------------
  45. // Writers
  46. // ------------------------------------------------------------------
  47. /**
  48. * Appends one web-originated event: session_id 0 and seq NULL
  49. * (the unique constraint never bites — SQLite treats NULLs as
  50. * distinct), `created_at` stamped now. `payload` may be null
  51. * (an empty object stores).
  52. */
  53. public async EventRecord append(string kind, string actor, string scope_type,
  54. int64 scope_id, Json.Object? payload) throws Error {
  55. var record = new EventRecord() {
  56. kind = kind,
  57. actor = actor,
  58. scope_type = scope_type,
  59. scope_id = scope_id,
  60. payload = payload != null ? json_to_text((!)payload) : "{}",
  61. created_at = new DateTime.now_local()
  62. };
  63. yield db.insert_async<EventRecord>(record);
  64. return record;
  65. }
  66. /**
  67. * Stores a pushed batch of daemon events (plan 17 §7.2: the
  68. * `events[]` of `POST /api/saddle/chunks`) as ledger rows
  69. * scoped to the session's task. Idempotent on the unique
  70. * (session_id, seq) pair: a redelivered seq is consumed, not
  71. * an error; an event without a positive seq is refused (the
  72. * daemon contract makes seq mandatory). The daemon's `ts` is
  73. * authoritative for `created_at` (fallback: now). Returns the
  74. * count of rows newly stored.
  75. */
  76. public async int64 store_pushed_events(int64 session_id, int64 task_id,
  77. Json.Array events) throws Error {
  78. int64 stored = 0;
  79. var staged = new Vector<EventRecord>();
  80. foreach (var element in events.get_elements()) {
  81. if (element.get_node_type() != Json.NodeType.OBJECT) {
  82. continue;
  83. }
  84. var incoming = element.get_object();
  85. int64 seq = incoming.has_member("seq")
  86. && incoming.get_member("seq").get_value_type() == typeof(int64)
  87. ? incoming.get_int_member("seq") : 0;
  88. if (seq <= 0) {
  89. throw new Error(Quark.from_string("clanker-saddle"), 90,
  90. "Every pushed event needs a positive per-session seq");
  91. }
  92. string kind = EntryJson.string_member(incoming, "kind");
  93. if (kind.length == 0) {
  94. throw new Error(Quark.from_string("clanker-saddle"), 91,
  95. "Every pushed event needs a kind");
  96. }
  97. string actor = EntryJson.string_member(incoming, "actor");
  98. var payload_node = incoming.has_member("payload")
  99. && incoming.get_member("payload").get_node_type() == Json.NodeType.OBJECT
  100. ? incoming.get_member("payload") : null;
  101. staged.add(new EventRecord() {
  102. session_id = session_id,
  103. seq = seq,
  104. kind = kind,
  105. actor = actor.length > 0 ? actor : EVENT_ACTOR_HARNESS,
  106. scope_type = EVENT_SCOPE_TASK,
  107. scope_id = task_id,
  108. payload = payload_node != null
  109. ? json_to_text((!)payload_node.get_object()) : "{}",
  110. created_at = EntryJson.parse_iso(incoming, "ts")
  111. ?? new DateTime.now_local()
  112. });
  113. }
  114. foreach (var record in staged) {
  115. try {
  116. yield db.insert_async<EventRecord>(record);
  117. stored++;
  118. } catch (Error insert_error) {
  119. var existing = yield db.query<EventRecord>()
  120. .where(expr("session_id == $0 && seq == $1",
  121. elem<int64?>(session_id), elem<int64?>(record.seq)))
  122. .first_async();
  123. if (existing == null) {
  124. throw insert_error;
  125. }
  126. }
  127. }
  128. return stored;
  129. }
  130. // ------------------------------------------------------------------
  131. // Readers
  132. // ------------------------------------------------------------------
  133. /**
  134. * One newest-first page of a task's ledger (plan 17 §7.4):
  135. * events with id below `before` (0 = from the top), `limit`
  136. * rows, newest first.
  137. */
  138. public async ImmutableLot<EventRecord> page_for_task(int64 task_id,
  139. int64 before, int64 limit) throws Error {
  140. var query = db.query<EventRecord>()
  141. .where(expr("scope_type == $0 && scope_id == $1",
  142. elem(EVENT_SCOPE_TASK), elem<int64?>(task_id)));
  143. if (before > 0) {
  144. query.where(expr("id < $0", elem<int64?>(before)));
  145. }
  146. return yield query
  147. .order_by_desc(expr("id"))
  148. .limit(limit)
  149. .materialise_async();
  150. }
  151. /**
  152. * The task's events strictly newer than `after_id`, oldest
  153. * first — the SSE stream's live-append cursor walk.
  154. */
  155. public async ImmutableLot<EventRecord> newer_than(int64 task_id, int64 after_id)
  156. throws Error {
  157. return yield db.query<EventRecord>()
  158. .where(expr("scope_type == $0 && scope_id == $1 && id > $2",
  159. elem(EVENT_SCOPE_TASK), elem<int64?>(task_id), elem<int64?>(after_id)))
  160. .order_by(expr("id"))
  161. .materialise_async();
  162. }
  163. /**
  164. * The task's events of the given kinds, newest first — the
  165. * progress feed and the goal narrative's filtered projections.
  166. */
  167. public async ImmutableLot<EventRecord> list_for_task(int64 task_id,
  168. string[] kinds) throws Error {
  169. var query = db.query<EventRecord>()
  170. .where(expr("scope_type == $0 && scope_id == $1",
  171. elem(EVENT_SCOPE_TASK), elem<int64?>(task_id)));
  172. if (kinds.length > 0) {
  173. query.where(kind_in(kinds));
  174. }
  175. return yield query
  176. .order_by_desc(expr("id"))
  177. .materialise_async();
  178. }
  179. /** The goal's own events (scope goal), newest first. */
  180. public async ImmutableLot<EventRecord> list_for_goal(int64 goal_id) throws Error {
  181. return yield db.query<EventRecord>()
  182. .where(expr("scope_type == $0 && scope_id == $1",
  183. elem(EVENT_SCOPE_GOAL), elem<int64?>(goal_id)))
  184. .order_by_desc(expr("id"))
  185. .materialise_async();
  186. }
  187. /**
  188. * The newest terminal state_change (to done/failed) among the
  189. * tasks' events that is newer than `watermark` — the resume
  190. * coalescer's oldest-pending-transition finder. Null when no
  191. * unconsumed terminal transition exists.
  192. */
  193. public async DateTime? oldest_terminal_transition_since(
  194. Invercargill.DataStructures.HashSet<string> task_ids,
  195. DateTime? watermark) throws Error {
  196. var filter = TaskService.id_in("scope_id", task_ids);
  197. if (filter == null) {
  198. return null;
  199. }
  200. DateTime? oldest = null;
  201. foreach (var record in yield db.query<EventRecord>()
  202. .where(expr("kind == $0 && scope_type == $1",
  203. elem(EVENT_STATE_CHANGE), elem(EVENT_SCOPE_TASK)))
  204. .where((!)filter)
  205. .order_by(expr("id"))
  206. .materialise_async()) {
  207. if (!is_terminal_transition(record)) {
  208. continue;
  209. }
  210. if (record.created_at == null) {
  211. continue;
  212. }
  213. if (watermark != null
  214. && ((!)record.created_at).compare((!)watermark) <= 0) {
  215. continue;
  216. }
  217. if (oldest == null || ((!)record.created_at).compare((!)oldest) < 0) {
  218. oldest = record.created_at;
  219. }
  220. }
  221. return oldest;
  222. }
  223. /**
  224. * The newest created_at among the tasks' terminal state_change
  225. * events — the watermark advance (null when none exist).
  226. */
  227. public async DateTime? newest_terminal_transition(
  228. Invercargill.DataStructures.HashSet<string> task_ids) throws Error {
  229. var filter = TaskService.id_in("scope_id", task_ids);
  230. if (filter == null) {
  231. return null;
  232. }
  233. DateTime? newest = null;
  234. foreach (var record in yield db.query<EventRecord>()
  235. .where(expr("kind == $0 && scope_type == $1",
  236. elem(EVENT_STATE_CHANGE), elem(EVENT_SCOPE_TASK)))
  237. .where((!)filter)
  238. .order_by(expr("id"))
  239. .materialise_async()) {
  240. if (!is_terminal_transition(record) || record.created_at == null) {
  241. continue;
  242. }
  243. if (newest == null || ((!)record.created_at).compare((!)newest) > 0) {
  244. newest = record.created_at;
  245. }
  246. }
  247. return newest;
  248. }
  249. /** True when the record is a state_change to done or failed. */
  250. private static bool is_terminal_transition(EventRecord record) {
  251. if (record.kind != EVENT_STATE_CHANGE) {
  252. return false;
  253. }
  254. string to = payload_string(record, "to");
  255. return to == STATUS_DONE || to == STATUS_FAILED;
  256. }
  257. /**
  258. * The task's newest event of the "what is it doing" kinds —
  259. * the goal page's current-activity line. Null when none.
  260. */
  261. public async EventRecord? newest_activity(int64 task_id) throws Error {
  262. EventRecord? newest = null;
  263. foreach (var record in yield list_for_task(task_id,
  264. { EVENT_TURN_TEXT, EVENT_TOOL_CALL, EVENT_TOOL_RESULT,
  265. EVENT_PROGRESS, EVENT_LIFECYCLE, EVENT_COMMENT })) {
  266. newest = record;
  267. break;
  268. }
  269. return newest;
  270. }
  271. /** The newest event stamp of one session (any kind), or null. */
  272. public async DateTime? last_session_activity(int64 task_id, int64 session_id)
  273. throws Error {
  274. DateTime? newest = null;
  275. foreach (var record in yield db.query<EventRecord>()
  276. .where(expr("scope_type == $0 && scope_id == $1 && session_id == $2",
  277. elem(EVENT_SCOPE_TASK), elem<int64?>(task_id),
  278. elem<int64?>(session_id)))
  279. .order_by(expr("id"))
  280. .materialise_async()) {
  281. if (record.created_at != null) {
  282. newest = record.created_at;
  283. }
  284. }
  285. return newest;
  286. }
  287. /** Deletes every event scoped to the task (the delete cascade). */
  288. public async void delete_for_task(int64 task_id) throws Error {
  289. foreach (var record in yield db.query<EventRecord>()
  290. .where(expr("scope_type == $0 && scope_id == $1",
  291. elem(EVENT_SCOPE_TASK), elem<int64?>(task_id)))
  292. .materialise_async()) {
  293. yield db.delete_async<EventRecord>(record);
  294. }
  295. }
  296. // ------------------------------------------------------------------
  297. // Payload helpers
  298. // ------------------------------------------------------------------
  299. /** The record's payload as a JSON object (empty when malformed). */
  300. public static Json.Object payload_of(EventRecord record) {
  301. try {
  302. var node = Json.from_string(record.payload ?? "{}");
  303. if (node != null && node.get_node_type() == Json.NodeType.OBJECT) {
  304. return node.get_object();
  305. }
  306. } catch (Error e) {
  307. }
  308. return new Json.Object();
  309. }
  310. /** One string member of the record's payload ("" when absent). */
  311. public static string payload_string(EventRecord record, string member) {
  312. return EntryJson.string_member(payload_of(record), member);
  313. }
  314. /** One string member of the record's payload (null when absent). */
  315. public static string? payload_optional_string(EventRecord record, string member) {
  316. return EntryJson.optional_string_member(payload_of(record), member);
  317. }
  318. /** The record's payload serialised back to compact JSON text. */
  319. public static string json_to_text(Json.Object payload) {
  320. var generator = new Json.Generator();
  321. generator.set_root(new Json.Node(Json.NodeType.OBJECT).init_object(payload));
  322. return generator.to_data(null);
  323. }
  324. /** An OR-chain over the kind column for a batch of kinds. */
  325. private static Expression kind_in(string[] kinds) throws Error {
  326. Expression? chain = null;
  327. foreach (var kind in kinds) {
  328. var comparison = expr("kind == $0", elem(kind));
  329. chain = chain == null
  330. ? comparison : new BinaryExpression((!)chain, comparison,
  331. BinaryOperator.OR);
  332. }
  333. return (!)chain;
  334. }
  335. }
  336. }