using Invercargill; using Invercargill.DataStructures; using Invercargill.Expressions; using InvercargillSql.Orm; using Inversion; using ClankerSaddle.Data; namespace ClankerSaddle.Services { /** One materialised cross-task timeline event (plan 10 phase 3). */ public class TimelineEvent : Object { /** created | started | suspended | completed | failed | * block-raised | block-answered | comment | progress | dispatch | * yeehaw | goal- (plan 12) */ public string kind { get; set; default = ""; } /** The event's epoch second — the newest-first sort key. */ public int64 at_epoch { get; set; } public int64 task_id { get; set; } public string task_title { get; set; default = ""; } /** Plan 12: set on goal events (else 0) — the timeline links the goal. */ public int64 goal_id { get; set; } public string goal_title { get; set; default = ""; } public string text { get; set; default = ""; } /** Build-order tiebreak for same-second events. */ public int64 sequence { get; set; } } /** * The home dashboard's cross-task activity timeline (plan 10 * phase 3): the last {@link WINDOW} events across every task, * newest first — status changes (started, suspended), blocks * raised and answered, comments (author + snippet), progress * updates, completions/failures and session dispatches — plus the * plan-12 goal events (plan submissions/reviews, task * fan-out, lifecycle — plan 15 C3: derived per goal by * {@link GoalService.narrative}) — materialised per request from * the existing tables (tasks, task_blocks, task_comments, * task_progress, sessions, goals). Nothing is archived or * aggregated: at this fleet size a bounded newest-cut per source, * merged and re-sorted, is the whole job. */ public class TimelineService : Object { /** The dashboard timeline window (events shown, newest first). */ public const int WINDOW = 50; /** Snippet clamp for bodies, questions and summaries. */ public const int SNIPPET_MAX = 160; private OrmSession db = inject(); private GoalService goals = inject(); /** * The recent event window, newest first. Each source * contributes its newest WINDOW rows by id; the merged view is * sorted by event time (epoch desc, then materialisation order * for same-second ties) and trimmed to WINDOW. */ public async Vector recent_window() throws Error { var titles = new Dictionary(); // Goal-agent runs are internal tasks: their own lifecycle, // mirrored blocks and telemetry never surface as task // events (the goal-keyed events below carry the story). var run_tasks = new Invercargill.DataStructures.HashSet(); foreach (var task in yield db.query().materialise_async()) { titles.set(task.id.to_string(), task.title); if (task.kind == TASK_KIND_GOAL_RUN) { run_tasks.add(task.id.to_string()); } } var saddle_names = new Dictionary(); foreach (var saddle in yield db.query().materialise_async()) { saddle_names.set(saddle.id.to_string(), saddle.name); } var goal_titles = new Dictionary(); foreach (var goal in yield db.query().materialise_async()) { goal_titles.set(goal.id.to_string(), goal.title); } var events = new Vector(); int64 sequence = 0; foreach (var task in yield db.query() .order_by_desc(expr("id")).limit(WINDOW).materialise_async()) { if (run_tasks.has(task.id.to_string())) { continue; } string title = titles.get_or_default(task.id.to_string()) ?? task.title; if (task.created_at != null) { events.add(new TimelineEvent() { kind = "created", at_epoch = ((!)task.created_at).to_unix(), task_id = task.id, task_title = title, text = "Task created", sequence = sequence++ }); } if (task.started_at != null) { events.add(new TimelineEvent() { kind = "started", at_epoch = ((!)task.started_at).to_unix(), task_id = task.id, task_title = title, text = "Status → working", sequence = sequence++ }); } if (task.suspended_at != null) { events.add(new TimelineEvent() { kind = "suspended", at_epoch = ((!)task.suspended_at).to_unix(), task_id = task.id, task_title = title, text = "Parked — goal agent awaiting its tasks", sequence = sequence++ }); } if (task.completed_at != null) { bool done = task.status == STATUS_DONE; string text = done ? "Task completed" : "Task failed"; // Heredoc markdown summaries open with their // headline: the event snippet carries the first // line only (fallback: the bare verdict text). string? lead = first_line(task.summary); if (lead != null) { text += ": " + snippet((!)lead); } events.add(new TimelineEvent() { kind = done ? "completed" : "failed", at_epoch = ((!)task.completed_at).to_unix(), task_id = task.id, task_title = title, text = text, sequence = sequence++ }); } } foreach (var block in yield db.query() .order_by_desc(expr("id")).limit(WINDOW).materialise_async()) { if (run_tasks.has(block.task_id.to_string())) { continue; } if (block.created_at != null) { events.add(new TimelineEvent() { kind = "block-raised", at_epoch = ((!)block.created_at).to_unix(), task_id = block.task_id, task_title = titles.get_or_default(block.task_id.to_string()) ?? "", text = @"$(block.block_type) raised: $(snippet(block.question))", sequence = sequence++ }); } if (block.answered_at != null) { events.add(new TimelineEvent() { kind = "block-answered", at_epoch = ((!)block.answered_at).to_unix(), task_id = block.task_id, task_title = titles.get_or_default(block.task_id.to_string()) ?? "", text = @"$(block.block_type) answered: $(snippet(block.answer ?? ""))", sequence = sequence++ }); } } foreach (var comment in yield db.query() .order_by_desc(expr("id")).limit(WINDOW).materialise_async()) { if (comment.created_at == null || run_tasks.has(comment.task_id.to_string())) { continue; } // Plan 16 S5 (U2): mechanical rows (restores, park // notices) never reach the dashboard timeline — they // live in the raw terminal log only. if (comment.author == COMMENT_AUTHOR_SYSTEM && comment_is_mechanical(comment.body)) { continue; } events.add(new TimelineEvent() { kind = "comment", at_epoch = ((!)comment.created_at).to_unix(), task_id = comment.task_id, task_title = titles.get_or_default(comment.task_id.to_string()) ?? "", text = @"$(comment.author): $(snippet(comment.body))", sequence = sequence++ }); } foreach (var progress in yield db.query() .order_by_desc(expr("id")).limit(WINDOW).materialise_async()) { if (progress.created_at == null || run_tasks.has(progress.task_id.to_string())) { continue; } events.add(new TimelineEvent() { kind = "progress", at_epoch = ((!)progress.created_at).to_unix(), task_id = progress.task_id, task_title = titles.get_or_default(progress.task_id.to_string()) ?? "", text = snippet(progress.summary), sequence = sequence++ }); } foreach (var session in yield db.query() .order_by_desc(expr("id")).limit(WINDOW).materialise_async()) { if (session.started_at == null || run_tasks.has(session.task_id.to_string())) { continue; } string saddle = saddle_names.get_or_default(session.saddle_id.to_string()) ?? @"saddle #$(session.saddle_id)"; events.add(new TimelineEvent() { kind = "dispatch", at_epoch = ((!)session.started_at).to_unix(), task_id = session.task_id, task_title = titles.get_or_default(session.task_id.to_string()) ?? "", text = @"Dispatched to $(saddle)", sequence = sequence++ }); } // Agent celebrations (`saddle yeehaw`): the stored // session_commands rows surface as their own event kind — // "🤖 yeehaw'd: " — keyed on the owning session's // task. Only the WINDOW newest entries load, and only // THEIR sessions (a batched id lookup — not every session // row). Entries without a timestamp cannot sort and are // skipped like every other timestamp-less source row. var yeehaw_entries = new Vector(); foreach (var entry in yield db.query() .where(expr("type == $0", elem(ENTRY_YEEHAW))) .order_by_desc(expr("id")).limit(WINDOW).materialise_async()) { if (entry.started_at != null) { yeehaw_entries.add(entry); } } var session_tasks = new Dictionary(); if (yeehaw_entries.length > 0) { var wanted_session_ids = new Invercargill.DataStructures.HashSet(); foreach (var entry in yeehaw_entries) { wanted_session_ids.add(entry.session_id.to_string()); } var session_filter = id_filter("id", wanted_session_ids); if (session_filter != null) { foreach (var session in yield db.query() .where((!)session_filter) .materialise_async()) { session_tasks.set(session.id.to_string(), session.task_id.to_string()); } } } foreach (var entry in yeehaw_entries) { string? task_key = session_tasks.get_or_default(entry.session_id.to_string()); if (task_key == null || run_tasks.has((!)task_key)) { continue; } string? lead = first_line(entry.output_text ?? entry.command_text); string text = lead != null ? @"🤖 yeehaw'd: $(snippet((!)lead))" : "🤖 yeehaw'd"; events.add(new TimelineEvent() { kind = ENTRY_YEEHAW, at_epoch = ((!)entry.started_at).to_unix(), task_id = int64.parse((!)task_key), task_title = titles.get_or_default((!)task_key) ?? "", text = text, sequence = sequence++ }); } // Plan 12: goal events — plan submissions/reviews, the // goal agent's task operations, task terminal events and // lifecycle moves — keyed on the goal (the timeline links // the goal page, not the task). // Plan 15 C3: the goal narrative derives at read time — // per goal, newest WINDOW rows, keyed on the goal (the // timeline links the goal page, not the task). Narrative // comment rows land as goal-comment footnotes, harness // system rows as goal-system footnotes. foreach (var goal in yield db.query().materialise_async()) { int64 taken_per_goal = 0; foreach (var row in yield goals.narrative(goal.id)) { if (row.at == null || taken_per_goal >= WINDOW) { continue; } taken_per_goal++; // A narrative row may reference the goal agent's // own run task — the row stays goal-keyed, but the // internal task never becomes a clickable task // reference. int64 event_task_id = run_tasks.has(row.task_id.to_string()) ? 0 : row.task_id; string kind = row.kind == "comment" ? "goal-comment" : row.kind == "system" ? "goal-system" : @"goal-$(row.kind)"; events.add(new TimelineEvent() { kind = kind, at_epoch = ((!)row.at).to_unix(), task_id = event_task_id, task_title = event_task_id > 0 ? titles.get_or_default(event_task_id.to_string()) ?? "" : "", goal_id = goal.id, goal_title = goal_titles.get_or_default(goal.id.to_string()) ?? "", text = snippet(row.text), sequence = sequence++ }); } } var sorted = events.sort((a, b) => { if (a.at_epoch != b.at_epoch) { return a.at_epoch > b.at_epoch ? -1 : 1; } return a.sequence > b.sequence ? -1 : 1; }); var trimmed = new Vector(); int64 taken = 0; foreach (var item in sorted) { if (taken >= WINDOW) { break; } trimmed.add(item); taken++; } return trimmed; } private static string snippet(string body) { string flat = body.replace("\n", " "); return flat.length > SNIPPET_MAX ? flat.substring(0, SNIPPET_MAX) + "…" : flat; } /** * An OR-chain of `column == id` comparisons for a batch of ids * (the grammar has no IN operator), or null for an empty * batch — the WHERE-in the batched lookups build. */ private static Expression? id_filter(string column, Invercargill.DataStructures.HashSet ids) throws Error { Expression? chain = null; foreach (var id in ids) { var comparison = expr(column + " == $0", elem(id)); chain = chain == null ? comparison : new BinaryExpression((!)chain, comparison, BinaryOperator.OR); } return chain; } /** * The first non-empty line of a summary (a markdown report's * headline), or null when the summary holds nothing usable. */ private static string? first_line(string? body) { if (body == null) { return null; } string trimmed = ((!)body).strip(); if (trimmed.length == 0) { return null; } int cut = trimmed.index_of("\n"); return cut >= 0 ? trimmed.substring(0, cut).strip() : trimmed; } } }