TimelineService.vala 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342
  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. * yeehaw | goal-<kind> (plan 12) */
  13. public string kind { get; set; default = ""; }
  14. /** The event's epoch second — the newest-first sort key. */
  15. public int64 at_epoch { get; set; }
  16. public int64 task_id { get; set; }
  17. public string task_title { get; set; default = ""; }
  18. /** Plan 12: set on goal events (else 0) — the timeline links the goal. */
  19. public int64 goal_id { get; set; }
  20. public string goal_title { get; set; default = ""; }
  21. public string text { get; set; default = ""; }
  22. /** Build-order tiebreak for same-second events. */
  23. public int64 sequence { get; set; }
  24. }
  25. /**
  26. * The home dashboard's cross-task activity timeline (plan 10
  27. * phase 3): the last {@link WINDOW} events across every task,
  28. * newest first — status changes (started, suspended), blocks
  29. * raised and answered, comments (author + snippet), progress
  30. * updates, completions/failures and session dispatches — plus the
  31. * plan-12 goal events (plan submissions/reviews, task
  32. * fan-out, lifecycle — plan 15 C3: derived per goal by
  33. * {@link GoalService.narrative}) — materialised per request from
  34. * the existing tables (tasks, task_blocks, task_comments,
  35. * task_progress, sessions, goals). Nothing is archived or
  36. * aggregated: at this fleet size a bounded newest-cut per source,
  37. * merged and re-sorted, is the whole job.
  38. */
  39. public class TimelineService : Object {
  40. /** The dashboard timeline window (events shown, newest first). */
  41. public const int WINDOW = 50;
  42. /** Snippet clamp for bodies, questions and summaries. */
  43. public const int SNIPPET_MAX = 160;
  44. private OrmSession db = inject<OrmSession>();
  45. private GoalService goals = inject<GoalService>();
  46. /**
  47. * The recent event window, newest first. Each source
  48. * contributes its newest WINDOW rows by id; the merged view is
  49. * sorted by event time (epoch desc, then materialisation order
  50. * for same-second ties) and trimmed to WINDOW.
  51. */
  52. public async Vector<TimelineEvent> recent_window() throws Error {
  53. var titles = new Dictionary<string, string>();
  54. // Goal-agent runs are internal tasks: their own lifecycle,
  55. // mirrored blocks and telemetry never surface as task
  56. // events (the goal-keyed events below carry the story).
  57. var run_tasks = new Invercargill.DataStructures.HashSet<string>();
  58. foreach (var task in yield db.query<TaskEntity>().materialise_async()) {
  59. titles.set(task.id.to_string(), task.title);
  60. if (task.kind == TASK_KIND_GOAL_RUN) {
  61. run_tasks.add(task.id.to_string());
  62. }
  63. }
  64. var saddle_names = new Dictionary<string, string>();
  65. foreach (var saddle in yield db.query<Saddle>().materialise_async()) {
  66. saddle_names.set(saddle.id.to_string(), saddle.name);
  67. }
  68. var goal_titles = new Dictionary<string, string>();
  69. foreach (var goal in yield db.query<Goal>().materialise_async()) {
  70. goal_titles.set(goal.id.to_string(), goal.title);
  71. }
  72. var events = new Vector<TimelineEvent>();
  73. int64 sequence = 0;
  74. foreach (var task in yield db.query<TaskEntity>()
  75. .order_by_desc(expr("id")).limit(WINDOW).materialise_async()) {
  76. if (run_tasks.has(task.id.to_string())) {
  77. continue;
  78. }
  79. string title = titles.get_or_default(task.id.to_string()) ?? task.title;
  80. if (task.created_at != null) {
  81. events.add(new TimelineEvent() {
  82. kind = "created", at_epoch = ((!)task.created_at).to_unix(),
  83. task_id = task.id, task_title = title, text = "Task created",
  84. sequence = sequence++
  85. });
  86. }
  87. if (task.started_at != null) {
  88. events.add(new TimelineEvent() {
  89. kind = "started", at_epoch = ((!)task.started_at).to_unix(),
  90. task_id = task.id, task_title = title, text = "Status → working",
  91. sequence = sequence++
  92. });
  93. }
  94. if (task.suspended_at != null) {
  95. events.add(new TimelineEvent() {
  96. kind = "suspended", at_epoch = ((!)task.suspended_at).to_unix(),
  97. task_id = task.id, task_title = title,
  98. text = "Parked — goal agent awaiting its tasks", sequence = sequence++
  99. });
  100. }
  101. if (task.completed_at != null) {
  102. bool done = task.status == STATUS_DONE;
  103. string text = done ? "Task completed" : "Task failed";
  104. // Heredoc markdown summaries open with their
  105. // headline: the event snippet carries the first
  106. // line only (fallback: the bare verdict text).
  107. string? lead = first_line(task.summary);
  108. if (lead != null) {
  109. text += ": " + snippet((!)lead);
  110. }
  111. events.add(new TimelineEvent() {
  112. kind = done ? "completed" : "failed",
  113. at_epoch = ((!)task.completed_at).to_unix(),
  114. task_id = task.id, task_title = title, text = text,
  115. sequence = sequence++
  116. });
  117. }
  118. }
  119. foreach (var block in yield db.query<TaskBlock>()
  120. .order_by_desc(expr("id")).limit(WINDOW).materialise_async()) {
  121. if (run_tasks.has(block.task_id.to_string())) {
  122. continue;
  123. }
  124. if (block.created_at != null) {
  125. events.add(new TimelineEvent() {
  126. kind = "block-raised", at_epoch = ((!)block.created_at).to_unix(),
  127. task_id = block.task_id,
  128. task_title = titles.get_or_default(block.task_id.to_string()) ?? "",
  129. text = @"$(block.block_type) raised: $(snippet(block.question))",
  130. sequence = sequence++
  131. });
  132. }
  133. if (block.answered_at != null) {
  134. events.add(new TimelineEvent() {
  135. kind = "block-answered", at_epoch = ((!)block.answered_at).to_unix(),
  136. task_id = block.task_id,
  137. task_title = titles.get_or_default(block.task_id.to_string()) ?? "",
  138. text = @"$(block.block_type) answered: $(snippet(block.answer ?? ""))",
  139. sequence = sequence++
  140. });
  141. }
  142. }
  143. foreach (var comment in yield db.query<TaskComment>()
  144. .order_by_desc(expr("id")).limit(WINDOW).materialise_async()) {
  145. if (comment.created_at == null || run_tasks.has(comment.task_id.to_string())) {
  146. continue;
  147. }
  148. // Plan 16 S5 (U2): mechanical rows (restores, park
  149. // notices) never reach the dashboard timeline — they
  150. // live in the raw terminal log only.
  151. if (comment.author == COMMENT_AUTHOR_SYSTEM
  152. && comment_is_mechanical(comment.body)) {
  153. continue;
  154. }
  155. events.add(new TimelineEvent() {
  156. kind = "comment", at_epoch = ((!)comment.created_at).to_unix(),
  157. task_id = comment.task_id,
  158. task_title = titles.get_or_default(comment.task_id.to_string()) ?? "",
  159. text = @"$(comment.author): $(snippet(comment.body))",
  160. sequence = sequence++
  161. });
  162. }
  163. foreach (var progress in yield db.query<TaskProgress>()
  164. .order_by_desc(expr("id")).limit(WINDOW).materialise_async()) {
  165. if (progress.created_at == null || run_tasks.has(progress.task_id.to_string())) {
  166. continue;
  167. }
  168. events.add(new TimelineEvent() {
  169. kind = "progress", at_epoch = ((!)progress.created_at).to_unix(),
  170. task_id = progress.task_id,
  171. task_title = titles.get_or_default(progress.task_id.to_string()) ?? "",
  172. text = snippet(progress.summary), sequence = sequence++
  173. });
  174. }
  175. foreach (var session in yield db.query<Session>()
  176. .order_by_desc(expr("id")).limit(WINDOW).materialise_async()) {
  177. if (session.started_at == null || run_tasks.has(session.task_id.to_string())) {
  178. continue;
  179. }
  180. string saddle = saddle_names.get_or_default(session.saddle_id.to_string())
  181. ?? @"saddle #$(session.saddle_id)";
  182. events.add(new TimelineEvent() {
  183. kind = "dispatch", at_epoch = ((!)session.started_at).to_unix(),
  184. task_id = session.task_id,
  185. task_title = titles.get_or_default(session.task_id.to_string()) ?? "",
  186. text = @"Dispatched to $(saddle)", sequence = sequence++
  187. });
  188. }
  189. // Agent celebrations (`saddle yeehaw`): the stored
  190. // session_commands rows surface as their own event kind —
  191. // "🤖 yeehaw'd: <first line>" — keyed on the owning session's
  192. // task. Only the WINDOW newest entries load, and only
  193. // THEIR sessions (a batched id lookup — not every session
  194. // row). Entries without a timestamp cannot sort and are
  195. // skipped like every other timestamp-less source row.
  196. var yeehaw_entries = new Vector<SessionCommand>();
  197. foreach (var entry in yield db.query<SessionCommand>()
  198. .where(expr("type == $0", elem(ENTRY_YEEHAW)))
  199. .order_by_desc(expr("id")).limit(WINDOW).materialise_async()) {
  200. if (entry.started_at != null) {
  201. yeehaw_entries.add(entry);
  202. }
  203. }
  204. var session_tasks = new Dictionary<string, string>();
  205. if (yeehaw_entries.length > 0) {
  206. var wanted_session_ids = new Invercargill.DataStructures.HashSet<string>();
  207. foreach (var entry in yeehaw_entries) {
  208. wanted_session_ids.add(entry.session_id.to_string());
  209. }
  210. var session_filter = id_filter("id", wanted_session_ids);
  211. if (session_filter != null) {
  212. foreach (var session in yield db.query<Session>()
  213. .where((!)session_filter)
  214. .materialise_async()) {
  215. session_tasks.set(session.id.to_string(), session.task_id.to_string());
  216. }
  217. }
  218. }
  219. foreach (var entry in yeehaw_entries) {
  220. string? task_key = session_tasks.get_or_default(entry.session_id.to_string());
  221. if (task_key == null || run_tasks.has((!)task_key)) {
  222. continue;
  223. }
  224. string? lead = first_line(entry.output_text ?? entry.command_text);
  225. string text = lead != null
  226. ? @"🤖 yeehaw'd: $(snippet((!)lead))" : "🤖 yeehaw'd";
  227. events.add(new TimelineEvent() {
  228. kind = ENTRY_YEEHAW, at_epoch = ((!)entry.started_at).to_unix(),
  229. task_id = int64.parse((!)task_key),
  230. task_title = titles.get_or_default((!)task_key) ?? "",
  231. text = text, sequence = sequence++
  232. });
  233. }
  234. // Plan 12: goal events — plan submissions/reviews, the
  235. // goal agent's task operations, task terminal events and
  236. // lifecycle moves — keyed on the goal (the timeline links
  237. // the goal page, not the task).
  238. // Plan 15 C3: the goal narrative derives at read time —
  239. // per goal, newest WINDOW rows, keyed on the goal (the
  240. // timeline links the goal page, not the task). Narrative
  241. // comment rows land as goal-comment footnotes, harness
  242. // system rows as goal-system footnotes.
  243. foreach (var goal in yield db.query<Goal>().materialise_async()) {
  244. int64 taken_per_goal = 0;
  245. foreach (var row in yield goals.narrative(goal.id)) {
  246. if (row.at == null || taken_per_goal >= WINDOW) {
  247. continue;
  248. }
  249. taken_per_goal++;
  250. // A narrative row may reference the goal agent's
  251. // own run task — the row stays goal-keyed, but the
  252. // internal task never becomes a clickable task
  253. // reference.
  254. int64 event_task_id = run_tasks.has(row.task_id.to_string())
  255. ? 0 : row.task_id;
  256. string kind = row.kind == "comment" ? "goal-comment"
  257. : row.kind == "system" ? "goal-system" : @"goal-$(row.kind)";
  258. events.add(new TimelineEvent() {
  259. kind = kind,
  260. at_epoch = ((!)row.at).to_unix(),
  261. task_id = event_task_id,
  262. task_title = event_task_id > 0
  263. ? titles.get_or_default(event_task_id.to_string()) ?? "" : "",
  264. goal_id = goal.id,
  265. goal_title = goal_titles.get_or_default(goal.id.to_string()) ?? "",
  266. text = snippet(row.text), sequence = sequence++
  267. });
  268. }
  269. }
  270. var sorted = events.sort((a, b) => {
  271. if (a.at_epoch != b.at_epoch) {
  272. return a.at_epoch > b.at_epoch ? -1 : 1;
  273. }
  274. return a.sequence > b.sequence ? -1 : 1;
  275. });
  276. var trimmed = new Vector<TimelineEvent>();
  277. int64 taken = 0;
  278. foreach (var item in sorted) {
  279. if (taken >= WINDOW) {
  280. break;
  281. }
  282. trimmed.add(item);
  283. taken++;
  284. }
  285. return trimmed;
  286. }
  287. private static string snippet(string body) {
  288. string flat = body.replace("\n", " ");
  289. return flat.length > SNIPPET_MAX ? flat.substring(0, SNIPPET_MAX) + "…" : flat;
  290. }
  291. /**
  292. * An OR-chain of `column == id` comparisons for a batch of ids
  293. * (the grammar has no IN operator), or null for an empty
  294. * batch — the WHERE-in the batched lookups build.
  295. */
  296. private static Expression? id_filter(string column,
  297. Invercargill.DataStructures.HashSet<string> ids) throws Error {
  298. Expression? chain = null;
  299. foreach (var id in ids) {
  300. var comparison = expr(column + " == $0", elem(id));
  301. chain = chain == null
  302. ? comparison : new BinaryExpression((!)chain, comparison,
  303. BinaryOperator.OR);
  304. }
  305. return chain;
  306. }
  307. /**
  308. * The first non-empty line of a summary (a markdown report's
  309. * headline), or null when the summary holds nothing usable.
  310. */
  311. private static string? first_line(string? body) {
  312. if (body == null) {
  313. return null;
  314. }
  315. string trimmed = ((!)body).strip();
  316. if (trimmed.length == 0) {
  317. return null;
  318. }
  319. int cut = trimmed.index_of("\n");
  320. return cut >= 0 ? trimmed.substring(0, cut).strip() : trimmed;
  321. }
  322. }
  323. }