TimelineService.vala 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  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. /** One materialised cross-task timeline event (plan 10 phase 3). */
  9. public class TimelineEvent : Object {
  10. /** created | started | suspended | completed | failed |
  11. * block-raised | block-answered | comment | progress | dispatch |
  12. * verdict | plan-submitted | force-wake | cancel | answer |
  13. * goal-<kind> (plan 12) */
  14. public string kind { get; set; default = ""; }
  15. /** The event's epoch second — the newest-first sort key. */
  16. public int64 at_epoch { get; set; }
  17. public int64 task_id { get; set; }
  18. public string task_title { get; set; default = ""; }
  19. /** Plan 12: set on goal events (else 0) — the timeline links the goal. */
  20. public int64 goal_id { get; set; }
  21. public string goal_title { get; set; default = ""; }
  22. public string text { get; set; default = ""; }
  23. /** Build-order tiebreak for same-second events. */
  24. public int64 sequence { get; set; }
  25. }
  26. /**
  27. * The home dashboard's cross-task activity timeline: the last
  28. * {@link WINDOW} ledger events, newest first (plan 17: a pure
  29. * projection of the unified `events` table — the per-source table
  30. * scans it replaced are gone). The agent's protocol noise
  31. * (turn_text/tool_call/tool_result/command_output/usage) stays out;
  32. * everything human-relevant — status changes, block raises and
  33. * answers, verdicts, comments, progress, dispatches, goal
  34. * lifecycle — flows through.
  35. */
  36. public class TimelineService : Object {
  37. /** The dashboard timeline window (events shown, newest first). */
  38. public const int WINDOW = 50;
  39. /** Snippet clamp for bodies, questions and summaries. */
  40. public const int SNIPPET_MAX = 160;
  41. private OrmSession db = inject<OrmSession>();
  42. /**
  43. * The recent event window, newest first: the newest WINDOW
  44. * non-noise ledger rows, mapped onto the timeline's shape with
  45. * goal-run task rows staying internal (the goal-keyed events
  46. * carry the story).
  47. */
  48. public async Vector<TimelineEvent> recent_window() throws Error {
  49. var titles = new Dictionary<string, string>();
  50. var run_tasks = new Invercargill.DataStructures.HashSet<string>();
  51. foreach (var task in yield db.query<TaskEntity>().materialise_async()) {
  52. titles.set(task.id.to_string(), task.title);
  53. if (task.kind == TASK_KIND_GOAL_RUN) {
  54. run_tasks.add(task.id.to_string());
  55. }
  56. }
  57. var goal_titles = new Dictionary<string, string>();
  58. foreach (var goal in yield db.query<Goal>().materialise_async()) {
  59. goal_titles.set(goal.id.to_string(), goal.title);
  60. }
  61. var rows = new Vector<EventRecord>();
  62. foreach (var record in yield db.query<EventRecord>()
  63. .order_by_desc(expr("id")).limit(WINDOW).materialise_async()) {
  64. rows.add(record);
  65. }
  66. var ordered = rows.to_array();
  67. for (var i = 1; i < ordered.length; i++) {
  68. var key = ordered[i];
  69. var j = i - 1;
  70. while (j >= 0
  71. && (epoch_of(ordered[j]) < epoch_of(key)
  72. || (epoch_of(ordered[j]) == epoch_of(key)
  73. && ordered[j].id < key.id))) {
  74. ordered[j + 1] = ordered[j];
  75. j--;
  76. }
  77. ordered[j + 1] = key;
  78. }
  79. var events = new Vector<TimelineEvent>();
  80. int64 sequence = 0;
  81. foreach (var record in ordered) {
  82. if (record.kind == EVENT_TURN_TEXT || record.kind == EVENT_TOOL_CALL
  83. || record.kind == EVENT_TOOL_RESULT
  84. || record.kind == EVENT_COMMAND_OUTPUT
  85. || record.kind == EVENT_USAGE) {
  86. continue;
  87. }
  88. bool goal_scoped = record.scope_type == EVENT_SCOPE_GOAL;
  89. bool task_scoped = record.scope_type == EVENT_SCOPE_TASK;
  90. if (task_scoped && run_tasks.has(record.scope_id.to_string())) {
  91. continue;
  92. }
  93. var view = project(record);
  94. if (view == null) {
  95. continue;
  96. }
  97. ((!)view).sequence = sequence++;
  98. if (goal_scoped) {
  99. ((!)view).goal_id = record.scope_id;
  100. ((!)view).goal_title = goal_titles.get_or_default(
  101. record.scope_id.to_string()) ?? "";
  102. ((!)view).kind = @"goal-$(((!)view).kind)";
  103. } else if (task_scoped) {
  104. ((!)view).task_id = record.scope_id;
  105. ((!)view).task_title = titles.get_or_default(
  106. record.scope_id.to_string()) ?? "";
  107. }
  108. events.add((!)view);
  109. }
  110. return events;
  111. }
  112. /**
  113. * One ledger row onto the timeline shape: `kind` keeps the
  114. * historical vocabulary the dashboard badges (state_change's
  115. * `to` maps to created/started/completed/&c.), `text` prefers
  116. * the payload's human-readable line and falls back to a
  117. * per-kind synthesis. Null when the row says nothing worth a
  118. * timeline entry.
  119. */
  120. private static TimelineEvent? project(EventRecord record) {
  121. var payload = EventService.payload_of(record);
  122. string text = EntryJson.string_member(payload, "text");
  123. switch (record.kind) {
  124. case EVENT_STATE_CHANGE:
  125. string to = EntryJson.string_member(payload, "to");
  126. string kind = timeline_kind_of_status(to);
  127. if (text.length == 0) {
  128. text = @"Status → $(to)";
  129. }
  130. // Terminal transitions append the summary's first
  131. // line (the old timeline snippet behaviour).
  132. string summary = EntryJson.string_member(payload, "summary");
  133. if (summary.length > 0 && (to == STATUS_DONE || to == STATUS_FAILED)) {
  134. text = @"$(text): $(first_line(summary))";
  135. }
  136. return new TimelineEvent() {
  137. kind = kind, at_epoch = record.created_at != null
  138. ? ((!)record.created_at).to_unix() : 0,
  139. text = snippet(text)
  140. };
  141. case EVENT_ANSWER:
  142. return new TimelineEvent() {
  143. kind = "block-answered", at_epoch = epoch_of(record),
  144. text = snippet(text.length > 0 ? text : "block answered")
  145. };
  146. case EVENT_COMMENT:
  147. string author = EntryJson.string_member(payload, "author");
  148. string body = EntryJson.string_member(payload, "body");
  149. if (author == COMMENT_AUTHOR_SYSTEM && comment_is_mechanical(body)) {
  150. return null;
  151. }
  152. return new TimelineEvent() {
  153. kind = "comment", at_epoch = epoch_of(record),
  154. text = snippet(@"$(author): $(body)")
  155. };
  156. case EVENT_PROGRESS:
  157. return new TimelineEvent() {
  158. kind = "progress", at_epoch = epoch_of(record),
  159. text = snippet(EntryJson.string_member(payload, "text"))
  160. };
  161. case EVENT_LIFECYCLE:
  162. return new TimelineEvent() {
  163. kind = "dispatch", at_epoch = epoch_of(record),
  164. text = snippet(text.length > 0 ? text : "dispatched")
  165. };
  166. case EVENT_VERDICT:
  167. return new TimelineEvent() {
  168. kind = "verdict", at_epoch = epoch_of(record),
  169. text = snippet(text.length > 0 ? text : "verdict")
  170. };
  171. case EVENT_PLAN_SUBMITTED:
  172. return new TimelineEvent() {
  173. kind = "plan-submitted", at_epoch = epoch_of(record),
  174. text = snippet(text.length > 0 ? text : "plan submitted for review")
  175. };
  176. case EVENT_FORCE_WAKE:
  177. return new TimelineEvent() {
  178. kind = "force-wake", at_epoch = epoch_of(record),
  179. text = snippet(text.length > 0 ? text : "force-woke")
  180. };
  181. case EVENT_CANCEL:
  182. return new TimelineEvent() {
  183. kind = "cancel", at_epoch = epoch_of(record),
  184. text = snippet(text.length > 0 ? text : "cancelled")
  185. };
  186. default:
  187. return null;
  188. }
  189. }
  190. /** A terminal `to` status onto the timeline's historical kind. */
  191. private static string timeline_kind_of_status(string to) {
  192. switch (to) {
  193. case STATUS_DRAFT:
  194. return "created";
  195. case STATUS_WORKING:
  196. case STATUS_ASSIGNED:
  197. return "started";
  198. case STATUS_SUSPENDED:
  199. return "suspended";
  200. case STATUS_DONE:
  201. return "completed";
  202. case STATUS_FAILED:
  203. return "failed";
  204. case STATUS_BLOCKED:
  205. case STATUS_REVIEW_REQUESTED:
  206. return "block-raised";
  207. default:
  208. return to.length > 0 ? to : "state";
  209. }
  210. }
  211. private static int64 epoch_of(EventRecord record) {
  212. return record.created_at != null ? ((!)record.created_at).to_unix() : 0;
  213. }
  214. private static string first_line(string body) {
  215. int cut = body.index_of_char('\n');
  216. return cut >= 0 ? body.substring(0, cut) : body;
  217. }
  218. private static string snippet(string body) {
  219. string flat = body.replace("\n", " ");
  220. return flat.length > SNIPPET_MAX ? flat.substring(0, SNIPPET_MAX) + "…" : flat;
  221. }
  222. }
  223. }