| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342 |
- 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-<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 (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<OrmSession>();
- private GoalService goals = inject<GoalService>();
- /**
- * 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<TimelineEvent> recent_window() throws Error {
- var titles = new Dictionary<string, string>();
- // 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<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 saddle_names = new Dictionary<string, string>();
- foreach (var saddle in yield db.query<Saddle>().materialise_async()) {
- saddle_names.set(saddle.id.to_string(), saddle.name);
- }
- 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 events = new Vector<TimelineEvent>();
- int64 sequence = 0;
- foreach (var task in yield db.query<TaskEntity>()
- .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<TaskBlock>()
- .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<TaskComment>()
- .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<TaskProgress>()
- .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<Session>()
- .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: <first line>" — 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<SessionCommand>();
- foreach (var entry in yield db.query<SessionCommand>()
- .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<string, string>();
- if (yeehaw_entries.length > 0) {
- var wanted_session_ids = new Invercargill.DataStructures.HashSet<string>();
- 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<Session>()
- .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<Goal>().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<TimelineEvent>();
- 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<string> 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;
- }
- }
- }
|