@higherdev/cli 0.15.4 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,4 @@
1
+ import { narrateEvents } from "./narrate.js";
1
2
  /** How much of the stream is kept. Older lines have already scrolled past. */
2
3
  export const STREAM_LIMIT = 200;
3
4
  /**
@@ -5,31 +6,43 @@ export const STREAM_LIMIT = 200;
5
6
  * caller did not name are dropped, which is how another workspace's traffic
6
7
  * stays out of this one's stream.
7
8
  */
8
- export function toStreamLines(events, runs) {
9
+ export function toStreamLines(events, runs, raw = false) {
9
10
  const lines = [];
10
- for (const event of events) {
11
- const run = runs.get(event.run_id);
12
- if (!run)
13
- continue;
11
+ for (const run of runs.values()) {
12
+ const target = run.ticket ? ` on ${run.ticket}` : "";
13
+ lines.push({ id: `${run.runId}:start`, sourceIds: [`${run.runId}:start`], runId: run.runId,
14
+ agent: run.agent, at: run.startedAt ?? "", seq: -1, kind: "status",
15
+ title: `${run.agent} started ${run.kind}${target} (attempt ${run.attempt})` });
16
+ }
17
+ for (const run of runs.values()) {
14
18
  // The id has to be a property of the event, not of the batch it arrived in.
15
19
  // Live delivery converts one row at a time and a backfill converts eighty,
16
20
  // so an id carrying a batch position would give the same event two ids and
17
21
  // defeat the dedupe below. One event can still produce several lines, which
18
22
  // is what the second half counts.
19
- const produced = transcriptLines(event);
20
- produced.forEach((line, index) => {
23
+ narrateEvents(events.filter((event) => event.run_id === run.runId), raw).forEach((line) => {
21
24
  lines.push({
22
- id: `${event.id}:${index}`,
23
- runId: event.run_id,
25
+ id: line.id,
26
+ sourceIds: [line.sourceId],
27
+ runId: run.runId,
24
28
  agent: run.agent,
25
- at: String(event.at),
26
- seq: event.seq,
29
+ at: line.at,
30
+ seq: line.seq,
27
31
  kind: line.kind,
28
32
  title: line.title,
29
33
  });
30
34
  });
31
35
  }
32
- return lines;
36
+ for (const run of runs.values()) {
37
+ if (["queued", "running"].includes(run.status) || !run.summary)
38
+ continue;
39
+ const target = run.ticket ? ` on ${run.ticket}` : "";
40
+ lines.push({ id: `${run.runId}:end`, sourceIds: [`${run.runId}:end`], runId: run.runId,
41
+ agent: run.agent, at: run.endedAt ?? "", seq: Number.MAX_SAFE_INTEGER,
42
+ kind: run.status === "failed" || run.status === "killed" ? "error" : "status",
43
+ title: `${run.agent} finished ${run.kind}${target}: ${run.summary}` });
44
+ }
45
+ return collapse(lines.sort((a, b) => a.at.localeCompare(b.at) || a.seq - b.seq), raw);
33
46
  }
34
47
  /**
35
48
  * Adds lines to the stream without letting it grow forever and without showing
@@ -40,11 +53,14 @@ export function toStreamLines(events, runs) {
40
53
  export function appendLines(prior, incoming, limit = STREAM_LIMIT) {
41
54
  if (incoming.length === 0)
42
55
  return prior;
43
- const seen = new Set(prior.map((line) => line.id));
44
- const fresh = incoming.filter((line) => !seen.has(line.id));
56
+ const seen = new Set(prior.flatMap((line) => line.sourceIds ?? [line.id]));
57
+ const fresh = incoming.flatMap((line) => {
58
+ const ids = (line.sourceIds ?? [line.id]).filter((id) => !seen.has(id));
59
+ return ids.length ? [{ ...line, sourceIds: ids, count: line.count ? ids.length : undefined }] : [];
60
+ });
45
61
  if (fresh.length === 0)
46
62
  return prior;
47
- const next = [...prior, ...fresh].sort((left, right) => left.at.localeCompare(right.at) || left.seq - right.seq);
63
+ const next = collapse([...prior, ...fresh].sort((left, right) => left.at.localeCompare(right.at) || left.seq - right.seq));
48
64
  return next.length > limit ? next.slice(next.length - limit) : next;
49
65
  }
50
66
  /**
@@ -82,33 +98,32 @@ export async function backfill(opts) {
82
98
  return;
83
99
  opts.apply((prior) => appendLines(prior, lines));
84
100
  }
85
- function transcriptLines(event) {
86
- const payload = event.payload && typeof event.payload === "object" && !Array.isArray(event.payload)
87
- ? event.payload
88
- : {};
89
- const text = [payload.text, payload.message, payload.summary, payload.command, payload.path]
90
- .find((value) => typeof value === "string");
91
- const title = text?.trim() || JSON.stringify(event.payload) || event.type;
92
- if (!title)
93
- return [];
94
- const kind = event.type === "error"
95
- ? "error"
96
- : ["tool_use", "tool_result", "command", "file_changed"].includes(event.type)
97
- ? "tool"
98
- : event.type === "status"
99
- ? "status"
100
- : "text";
101
- return [{ kind, title }];
101
+ function collapse(lines, raw = false) {
102
+ if (raw)
103
+ return lines;
104
+ const out = [];
105
+ for (const line of lines) {
106
+ const previous = out.at(-1);
107
+ const gap = previous ? Date.parse(line.at) - Date.parse(previous.at) : Infinity;
108
+ if (previous && previous.runId === line.runId && previous.kind === line.kind && previous.title === line.title && gap <= 2_000) {
109
+ previous.count = (previous.count ?? 1) + (line.count ?? 1);
110
+ previous.sourceIds = [...(previous.sourceIds ?? [previous.id]), ...(line.sourceIds ?? [line.id])];
111
+ }
112
+ else
113
+ out.push({ ...line });
114
+ }
115
+ return out;
102
116
  }
103
117
  /**
104
118
  * The live runs, keyed by id. Realtime hands over a run event before the board
105
119
  * has caught up with the run that produced it, so anything this does not know
106
120
  * about is dropped and recovered by the backfill when the board does catch up.
107
121
  */
108
- export function runLabels(board) {
122
+ export function runLabels(board, now = Date.now()) {
109
123
  const map = new Map();
110
124
  for (const run of board.runs) {
111
- if (run.status !== "running")
125
+ const ended = run.ended_at ? Date.parse(run.ended_at) : NaN;
126
+ if (run.status !== "running" && (!Number.isFinite(ended) || now - ended > 15 * 60_000))
112
127
  continue;
113
128
  const agent = board.agents.find((row) => row.id === run.agent_id);
114
129
  const ticket = board.tickets.find((row) => row.id === run.ticket_id);
@@ -116,6 +131,8 @@ export function runLabels(board) {
116
131
  runId: run.id,
117
132
  agent: agent?.display_name ?? run.kind,
118
133
  ticket: ticket?.key ?? null,
134
+ kind: run.kind, status: run.status, attempt: ticket?.attempts ?? 1,
135
+ startedAt: run.started_at, endedAt: run.ended_at ?? null, summary: run.summary ?? null,
119
136
  });
120
137
  }
121
138
  return map;
@@ -0,0 +1,226 @@
1
+ export const TICKET_SECTIONS = ["body", "acceptance", "timeline", "messages", "pr", "runs", "decisions"];
2
+ export function ticketPr(ticket) {
3
+ if (!ticket.pr_url && ticket.pr_number == null)
4
+ return null;
5
+ const reason = ticket.stuck_reason ?? "";
6
+ let checks = "unknown";
7
+ let mergeability = "open";
8
+ if (/^Checks failing/i.test(reason)) {
9
+ checks = reason.replace(/^Checks failing(?: \(details unavailable:[^)]+\))?:?\s*/i, "") || reason;
10
+ }
11
+ else if (ticket.status === "merged" || ticket.status === "approved") {
12
+ checks = "passing";
13
+ }
14
+ if (/conflicts with main/i.test(reason))
15
+ mergeability = "conflicting";
16
+ else if (ticket.status === "merged")
17
+ mergeability = "merged";
18
+ else if (ticket.status === "approved" && !/^Checks failing/i.test(reason))
19
+ mergeability = "ready";
20
+ return { url: ticket.pr_url, number: ticket.pr_number ?? null, checks, mergeability };
21
+ }
22
+ export function wrapLines(text, width) {
23
+ if (!text)
24
+ return [""];
25
+ const lines = [];
26
+ const max = Math.max(8, width);
27
+ for (const paragraph of text.split("\n")) {
28
+ if (!paragraph) {
29
+ lines.push("");
30
+ continue;
31
+ }
32
+ let line = "";
33
+ for (const word of paragraph.split(/\s+/).filter(Boolean)) {
34
+ const candidate = line ? `${line} ${word}` : word;
35
+ if (candidate.length <= max) {
36
+ line = candidate;
37
+ continue;
38
+ }
39
+ if (line)
40
+ lines.push(line);
41
+ let rest = word;
42
+ while (rest.length > max) {
43
+ lines.push(rest.slice(0, max));
44
+ rest = rest.slice(max);
45
+ }
46
+ line = rest;
47
+ }
48
+ if (line)
49
+ lines.push(line);
50
+ }
51
+ return lines;
52
+ }
53
+ function elapsedLabel(ms) {
54
+ if (ms == null)
55
+ return "elapsed unknown";
56
+ const seconds = Math.round(ms / 1000);
57
+ return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
58
+ }
59
+ function pushWrapped(lines, section, prefix, text, width) {
60
+ wrapLines(text, Math.max(8, width - prefix.length)).forEach((row, index) => {
61
+ lines.push({
62
+ key: `${section}:${prefix}:${index}:${row.slice(0, 12)}`,
63
+ kind: "line",
64
+ section,
65
+ text: `${prefix}${row}`,
66
+ });
67
+ });
68
+ }
69
+ export function ticketViewLines(ticket, width, collapsed = []) {
70
+ const lines = [];
71
+ const hidden = new Set(collapsed);
72
+ const pr = ticket.pr ?? ticketPr(ticket);
73
+ lines.push({
74
+ key: "header:title", kind: "line", section: "header",
75
+ text: `${ticket.key} ${ticket.title}`.trim(),
76
+ });
77
+ lines.push({
78
+ key: "header:status", kind: "line", section: "header",
79
+ text: [
80
+ ticket.status.replaceAll("_", " "),
81
+ ticket.area,
82
+ ticket.agent_name ?? (ticket.provider ?? "unassigned"),
83
+ ticket.attempts ? `attempt ${ticket.attempts}` : "",
84
+ ].filter(Boolean).join(" "),
85
+ });
86
+ const why = ticket.stuck ?? ticket.stuck_reason;
87
+ if (why) {
88
+ pushWrapped(lines, "header", "why: ", why, width);
89
+ }
90
+ if (ticket.blocker_keys?.length) {
91
+ lines.push({
92
+ key: "header:blocked", kind: "line", section: "header",
93
+ text: `blocked by ${ticket.blocker_keys.join(", ")}`,
94
+ });
95
+ }
96
+ if (ticket.branch) {
97
+ lines.push({ key: "header:branch", kind: "line", section: "header", text: ticket.branch });
98
+ }
99
+ const addSection = (id, title, fill, count) => {
100
+ const folded = hidden.has(id);
101
+ const note = folded ? " (folded)" : count != null ? ` (${count})` : "";
102
+ lines.push({
103
+ key: `section:${id}`, kind: "section", section: id,
104
+ text: `${folded ? "▸" : "▾"} ${title}${note}`,
105
+ });
106
+ if (folded)
107
+ return;
108
+ fill();
109
+ };
110
+ addSection("body", "Body", () => {
111
+ if (!ticket.body_md.trim()) {
112
+ lines.push({ key: "body:empty", kind: "line", section: "body", text: " No body." });
113
+ return;
114
+ }
115
+ pushWrapped(lines, "body", " ", ticket.body_md.trim(), width);
116
+ });
117
+ addSection("acceptance", "Acceptance", () => {
118
+ if (!ticket.acceptance_md.trim()) {
119
+ lines.push({ key: "acceptance:empty", kind: "line", section: "acceptance", text: " No acceptance." });
120
+ return;
121
+ }
122
+ pushWrapped(lines, "acceptance", " ", ticket.acceptance_md.trim(), width);
123
+ });
124
+ addSection("timeline", "Timeline", () => {
125
+ if (!ticket.timeline?.length) {
126
+ lines.push({ key: "timeline:empty", kind: "line", section: "timeline", text: " No completed runs yet." });
127
+ return;
128
+ }
129
+ for (const event of ticket.timeline) {
130
+ pushWrapped(lines, "timeline", " ", event.headline, width);
131
+ lines.push({
132
+ key: `${event.id}:meta`, kind: "line", section: "timeline",
133
+ text: ` ${event.outcome} · ${event.kind}/${event.provider} · ${elapsedLabel(event.elapsed_ms)}`,
134
+ });
135
+ if (event.send_back_reason) {
136
+ pushWrapped(lines, "timeline", " sent back because: ", event.send_back_reason, width);
137
+ }
138
+ }
139
+ }, ticket.timeline?.length);
140
+ addSection("messages", "Messages", () => {
141
+ if (!ticket.messages?.length) {
142
+ lines.push({ key: "messages:empty", kind: "line", section: "messages", text: " No messages yet." });
143
+ return;
144
+ }
145
+ for (const message of ticket.messages) {
146
+ const sender = message.from_name?.trim() || message.from_role;
147
+ lines.push({
148
+ key: `${message.id}:meta`, kind: "line", section: "messages",
149
+ text: ` ${message.created_at.slice(0, 16).replace("T", " ")} ${sender}`,
150
+ });
151
+ pushWrapped(lines, "messages", " ", message.body_md.trim() || "(empty)", width);
152
+ }
153
+ }, ticket.messages?.length);
154
+ addSection("pr", "PR", () => {
155
+ if (!pr) {
156
+ lines.push({ key: "pr:empty", kind: "line", section: "pr", text: " No pull request yet." });
157
+ return;
158
+ }
159
+ lines.push({
160
+ key: "pr:id", kind: "line", section: "pr",
161
+ text: ` ${pr.number != null ? `#${pr.number} ` : ""}${pr.url ?? ""}`.trimEnd(),
162
+ });
163
+ lines.push({ key: "pr:checks", kind: "line", section: "pr", text: ` checks: ${pr.checks}` });
164
+ lines.push({
165
+ key: "pr:merge", kind: "line", section: "pr",
166
+ text: ` mergeability: ${pr.mergeability}`,
167
+ });
168
+ });
169
+ addSection("runs", "Runs", () => {
170
+ if (!ticket.runs?.length) {
171
+ lines.push({ key: "runs:empty", kind: "line", section: "runs", text: " No runs yet." });
172
+ return;
173
+ }
174
+ for (const run of ticket.runs) {
175
+ lines.push({
176
+ key: `${run.id}:meta`, kind: "line", section: "runs",
177
+ text: ` ${run.kind} ${run.provider} ${run.status} ${elapsedLabel(run.elapsed_ms)}`,
178
+ });
179
+ if (run.summary?.trim())
180
+ pushWrapped(lines, "runs", " ", run.summary.trim(), width);
181
+ }
182
+ }, ticket.runs?.length);
183
+ addSection("decisions", "Decisions", () => {
184
+ if (!ticket.decisions?.length) {
185
+ lines.push({ key: "decisions:empty", kind: "line", section: "decisions", text: " No decisions yet." });
186
+ return;
187
+ }
188
+ for (const decision of ticket.decisions) {
189
+ const state = decision.answered_at ? "answered" : "asked";
190
+ pushWrapped(lines, "decisions", ` ${state} (${decision.asked_by_role}): `, decision.question_md.trim(), width);
191
+ if (decision.answer_md?.trim()) {
192
+ pushWrapped(lines, "decisions", " answer: ", decision.answer_md.trim(), width);
193
+ }
194
+ }
195
+ }, ticket.decisions?.length);
196
+ return lines;
197
+ }
198
+ export function ticketScrollOffset(count, rows, offset) {
199
+ if (count <= rows)
200
+ return 0;
201
+ return Math.max(0, Math.min(offset, count - rows));
202
+ }
203
+ export function sectionAt(lines, index) {
204
+ if (!lines.length)
205
+ return null;
206
+ const start = Math.max(0, Math.min(index, lines.length - 1));
207
+ for (let i = start; i >= 0; i -= 1) {
208
+ if (lines[i].kind === "section")
209
+ return lines[i].section;
210
+ }
211
+ return null;
212
+ }
213
+ export function toggleSection(collapsed, section) {
214
+ if (!section || section === "header")
215
+ return collapsed;
216
+ return collapsed.includes(section)
217
+ ? collapsed.filter((id) => id !== section)
218
+ : [...collapsed, section];
219
+ }
220
+ export function formatElapsedMs(ms, started, ended) {
221
+ if (typeof ms === "number")
222
+ return ms;
223
+ const start = started ? Date.parse(started) : Number.NaN;
224
+ const end = ended ? Date.parse(ended) : Number.NaN;
225
+ return Number.isFinite(start) && Number.isFinite(end) ? Math.max(0, end - start) : null;
226
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@higherdev/cli",
3
- "version": "0.15.4",
3
+ "version": "0.17.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "hd": "dist/index.js"