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 | * verdict | plan-submitted | force-wake | cancel | answer | * 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: the last * {@link WINDOW} ledger events, newest first (plan 17: a pure * projection of the unified `events` table — the per-source table * scans it replaced are gone). The agent's protocol noise * (turn_text/tool_call/tool_result/command_output/usage) stays out; * everything human-relevant — status changes, block raises and * answers, verdicts, comments, progress, dispatches, goal * lifecycle — flows through. */ 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(); /** * The recent event window, newest first: the newest WINDOW * non-noise ledger rows, mapped onto the timeline's shape with * goal-run task rows staying internal (the goal-keyed events * carry the story). */ public async Vector recent_window() throws Error { var titles = new Dictionary(); 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 goal_titles = new Dictionary(); foreach (var goal in yield db.query().materialise_async()) { goal_titles.set(goal.id.to_string(), goal.title); } var rows = new Vector(); foreach (var record in yield db.query() .order_by_desc(expr("id")).limit(WINDOW).materialise_async()) { rows.add(record); } var ordered = rows.to_array(); for (var i = 1; i < ordered.length; i++) { var key = ordered[i]; var j = i - 1; while (j >= 0 && (epoch_of(ordered[j]) < epoch_of(key) || (epoch_of(ordered[j]) == epoch_of(key) && ordered[j].id < key.id))) { ordered[j + 1] = ordered[j]; j--; } ordered[j + 1] = key; } var events = new Vector(); int64 sequence = 0; foreach (var record in ordered) { if (record.kind == EVENT_TURN_TEXT || record.kind == EVENT_TOOL_CALL || record.kind == EVENT_TOOL_RESULT || record.kind == EVENT_COMMAND_OUTPUT || record.kind == EVENT_USAGE) { continue; } bool goal_scoped = record.scope_type == EVENT_SCOPE_GOAL; bool task_scoped = record.scope_type == EVENT_SCOPE_TASK; if (task_scoped && run_tasks.has(record.scope_id.to_string())) { continue; } var view = project(record); if (view == null) { continue; } ((!)view).sequence = sequence++; if (goal_scoped) { ((!)view).goal_id = record.scope_id; ((!)view).goal_title = goal_titles.get_or_default( record.scope_id.to_string()) ?? ""; ((!)view).kind = @"goal-$(((!)view).kind)"; } else if (task_scoped) { ((!)view).task_id = record.scope_id; ((!)view).task_title = titles.get_or_default( record.scope_id.to_string()) ?? ""; } events.add((!)view); } return events; } /** * One ledger row onto the timeline shape: `kind` keeps the * historical vocabulary the dashboard badges (state_change's * `to` maps to created/started/completed/&c.), `text` prefers * the payload's human-readable line and falls back to a * per-kind synthesis. Null when the row says nothing worth a * timeline entry. */ private static TimelineEvent? project(EventRecord record) { var payload = EventService.payload_of(record); string text = EntryJson.string_member(payload, "text"); switch (record.kind) { case EVENT_STATE_CHANGE: string to = EntryJson.string_member(payload, "to"); string kind = timeline_kind_of_status(to); if (text.length == 0) { text = @"Status → $(to)"; } // Terminal transitions append the summary's first // line (the old timeline snippet behaviour). string summary = EntryJson.string_member(payload, "summary"); if (summary.length > 0 && (to == STATUS_DONE || to == STATUS_FAILED)) { text = @"$(text): $(first_line(summary))"; } return new TimelineEvent() { kind = kind, at_epoch = record.created_at != null ? ((!)record.created_at).to_unix() : 0, text = snippet(text) }; case EVENT_ANSWER: return new TimelineEvent() { kind = "block-answered", at_epoch = epoch_of(record), text = snippet(text.length > 0 ? text : "block answered") }; case EVENT_COMMENT: string author = EntryJson.string_member(payload, "author"); string body = EntryJson.string_member(payload, "body"); if (author == COMMENT_AUTHOR_SYSTEM && comment_is_mechanical(body)) { return null; } return new TimelineEvent() { kind = "comment", at_epoch = epoch_of(record), text = snippet(@"$(author): $(body)") }; case EVENT_PROGRESS: return new TimelineEvent() { kind = "progress", at_epoch = epoch_of(record), text = snippet(EntryJson.string_member(payload, "text")) }; case EVENT_LIFECYCLE: return new TimelineEvent() { kind = "dispatch", at_epoch = epoch_of(record), text = snippet(text.length > 0 ? text : "dispatched") }; case EVENT_VERDICT: return new TimelineEvent() { kind = "verdict", at_epoch = epoch_of(record), text = snippet(text.length > 0 ? text : "verdict") }; case EVENT_PLAN_SUBMITTED: return new TimelineEvent() { kind = "plan-submitted", at_epoch = epoch_of(record), text = snippet(text.length > 0 ? text : "plan submitted for review") }; case EVENT_FORCE_WAKE: return new TimelineEvent() { kind = "force-wake", at_epoch = epoch_of(record), text = snippet(text.length > 0 ? text : "force-woke") }; case EVENT_CANCEL: return new TimelineEvent() { kind = "cancel", at_epoch = epoch_of(record), text = snippet(text.length > 0 ? text : "cancelled") }; default: return null; } } /** A terminal `to` status onto the timeline's historical kind. */ private static string timeline_kind_of_status(string to) { switch (to) { case STATUS_DRAFT: return "created"; case STATUS_WORKING: case STATUS_ASSIGNED: return "started"; case STATUS_SUSPENDED: return "suspended"; case STATUS_DONE: return "completed"; case STATUS_FAILED: return "failed"; case STATUS_BLOCKED: case STATUS_REVIEW_REQUESTED: return "block-raised"; default: return to.length > 0 ? to : "state"; } } private static int64 epoch_of(EventRecord record) { return record.created_at != null ? ((!)record.created_at).to_unix() : 0; } private static string first_line(string body) { int cut = body.index_of_char('\n'); return cut >= 0 ? body.substring(0, cut) : body; } private static string snippet(string body) { string flat = body.replace("\n", " "); return flat.length > SNIPPET_MAX ? flat.substring(0, SNIPPET_MAX) + "…" : flat; } } }