| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236 |
- 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-<kind> (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<OrmSession>();
- /**
- * 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<TimelineEvent> recent_window() throws Error {
- var titles = new Dictionary<string, string>();
- var run_tasks = new Invercargill.DataStructures.HashSet<string>();
- foreach (var task in yield db.query<TaskEntity>().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<string, string>();
- foreach (var goal in yield db.query<Goal>().materialise_async()) {
- goal_titles.set(goal.id.to_string(), goal.title);
- }
- var rows = new Vector<EventRecord>();
- foreach (var record in yield db.query<EventRecord>()
- .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<TimelineEvent>();
- 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;
- }
- }
- }
|