bosun 0.42.2 → 0.42.4

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.
Files changed (49) hide show
  1. package/.env.example +9 -0
  2. package/agent/agent-event-bus.mjs +10 -0
  3. package/agent/agent-supervisor.mjs +20 -0
  4. package/bosun-tui.mjs +107 -105
  5. package/cli.mjs +10 -0
  6. package/config/config.mjs +25 -0
  7. package/config/executor-config.mjs +124 -1
  8. package/infra/container-runner.mjs +565 -1
  9. package/infra/monitor.mjs +18 -0
  10. package/infra/tracing.mjs +544 -240
  11. package/infra/tui-bridge.mjs +13 -1
  12. package/kanban/kanban-adapter.mjs +128 -4
  13. package/lib/repo-map.mjs +114 -3
  14. package/package.json +11 -4
  15. package/server/ui-server.mjs +3 -0
  16. package/task/task-archiver.mjs +18 -6
  17. package/task/task-attachments.mjs +14 -10
  18. package/task/task-cli.mjs +24 -4
  19. package/task/task-executor.mjs +19 -0
  20. package/task/task-store.mjs +194 -37
  21. package/telegram/telegram-bot.mjs +4 -1
  22. package/tui/app.mjs +131 -171
  23. package/tui/components/status-header.mjs +178 -75
  24. package/tui/lib/header-config.mjs +68 -0
  25. package/tui/lib/ws-bridge.mjs +61 -9
  26. package/tui/screens/agents.mjs +127 -0
  27. package/tui/screens/tasks.mjs +1 -48
  28. package/ui/app.js +8 -5
  29. package/ui/components/kanban-board.js +65 -3
  30. package/ui/components/session-list.js +18 -32
  31. package/ui/demo-defaults.js +52 -2
  32. package/ui/modules/session-api.js +100 -0
  33. package/ui/modules/state.js +71 -15
  34. package/ui/tabs/workflows.js +25 -1
  35. package/ui/tui/App.js +298 -0
  36. package/ui/tui/TasksScreen.js +564 -0
  37. package/ui/tui/constants.js +55 -0
  38. package/ui/tui/tasks-screen-helpers.js +301 -0
  39. package/ui/tui/useTasks.js +61 -0
  40. package/ui/tui/useWebSocket.js +166 -0
  41. package/ui/tui/useWorkflows.js +30 -0
  42. package/workflow/workflow-engine.mjs +412 -7
  43. package/workflow/workflow-nodes.mjs +616 -75
  44. package/workflow-templates/agents.mjs +3 -0
  45. package/workflow-templates/planning.mjs +7 -0
  46. package/workflow-templates/sub-workflows.mjs +5 -0
  47. package/workflow-templates/task-execution.mjs +3 -0
  48. package/workspace/command-diagnostics.mjs +1 -1
  49. package/workspace/context-cache.mjs +182 -9
@@ -0,0 +1,301 @@
1
+ import {
2
+ taskCreate,
3
+ taskDelete,
4
+ taskList,
5
+ taskUpdate,
6
+ } from "../../task/task-cli.mjs";
7
+
8
+ export const TASK_VIEW_WIDTH_THRESHOLD = 140;
9
+ export const DISPLAY_COLUMNS = [
10
+ { key: "todo", label: "TODO" },
11
+ { key: "in_progress", label: "IN PROGRESS" },
12
+ { key: "review", label: "REVIEW" },
13
+ { key: "done", label: "DONE" },
14
+ ];
15
+ export const STATUS_OPTIONS = DISPLAY_COLUMNS.map((column) => column.key);
16
+ export const PRIORITY_OPTIONS = ["critical", "high", "medium", "low"];
17
+ export const PRIORITY_COLOR_MAP = {
18
+ critical: "red",
19
+ high: "#ff9e3d",
20
+ medium: "yellow",
21
+ low: "green",
22
+ };
23
+ export const EMPTY_TASK_FORM = {
24
+ title: "",
25
+ priority: "medium",
26
+ status: "todo",
27
+ tagsText: "",
28
+ description: "",
29
+ stepsText: "",
30
+ acceptanceCriteriaText: "",
31
+ verificationText: "",
32
+ };
33
+ export const LINE_LIST_FIELD_KEYS = new Set([
34
+ "stepsText",
35
+ "acceptanceCriteriaText",
36
+ "verificationText",
37
+ ]);
38
+
39
+ const DISPLAY_STATUS_ORDER = {
40
+ todo: 0,
41
+ in_progress: 1,
42
+ review: 2,
43
+ done: 3,
44
+ };
45
+
46
+ const PERSISTED_STATUS_MAP = {
47
+ draft: "todo",
48
+ todo: "todo",
49
+ in_progress: "in_progress",
50
+ inprogress: "in_progress",
51
+ blocked: "in_progress",
52
+ review: "review",
53
+ inreview: "review",
54
+ done: "done",
55
+ };
56
+
57
+ const PRIORITY_ORDER = {
58
+ critical: 0,
59
+ high: 1,
60
+ medium: 2,
61
+ low: 3,
62
+ };
63
+
64
+ function shortTaskId(id) {
65
+ const value = String(id || "").trim();
66
+ if (!value) return "--------";
67
+ return value.length <= 8 ? value : value.slice(0, 8);
68
+ }
69
+
70
+ export function truncateText(value, maxWidth = 24) {
71
+ const text = String(value || "");
72
+ if (text.length <= maxWidth) return text;
73
+ if (maxWidth <= 1) return "…";
74
+ return `${text.slice(0, Math.max(0, maxWidth - 1))}…`;
75
+ }
76
+
77
+ export function normalizeTaskStatus(status) {
78
+ return PERSISTED_STATUS_MAP[String(status || "todo").trim().toLowerCase()] || "todo";
79
+ }
80
+
81
+ export function toPersistedStatus(status) {
82
+ const normalized = normalizeTaskStatus(status);
83
+ if (normalized === "in_progress") return "inprogress";
84
+ if (normalized === "review") return "inreview";
85
+ return normalized;
86
+ }
87
+
88
+ export function resolveTaskView(width, preferredView = "kanban") {
89
+ if (Number(width || 0) < TASK_VIEW_WIDTH_THRESHOLD) return "list";
90
+ return preferredView === "list" ? "list" : "kanban";
91
+ }
92
+
93
+ export function parseTags(tagsText) {
94
+ if (Array.isArray(tagsText)) {
95
+ return tagsText.map((tag) => String(tag || "").trim().toLowerCase()).filter(Boolean);
96
+ }
97
+ return String(tagsText || "")
98
+ .split(",")
99
+ .map((tag) => tag.trim().toLowerCase())
100
+ .filter(Boolean);
101
+ }
102
+
103
+ export function parseLineList(value) {
104
+ if (Array.isArray(value)) {
105
+ return value.map((line) => String(line || "").trim()).filter(Boolean);
106
+ }
107
+ return String(value || "")
108
+ .split(/\r?\n/)
109
+ .map((line) => line.trim())
110
+ .filter(Boolean);
111
+ }
112
+
113
+ function normalizeTask(task, titleWidth = 24) {
114
+ const normalizedStatus = normalizeTaskStatus(task?.status);
115
+ const tags = Array.isArray(task?.tags) ? task.tags : parseTags(task?.tagsText);
116
+ return {
117
+ ...task,
118
+ idShort: shortTaskId(task?.id),
119
+ statusDisplay: normalizedStatus,
120
+ priority: String(task?.priority || "medium").toLowerCase(),
121
+ priorityColor: PRIORITY_COLOR_MAP[String(task?.priority || "medium").toLowerCase()] || "yellow",
122
+ tags,
123
+ truncatedTitle: truncateText(task?.title || "Untitled", Math.max(8, titleWidth)),
124
+ };
125
+ }
126
+
127
+ function matchesFilter(task, filterText) {
128
+ const query = String(filterText || "").trim().toLowerCase();
129
+ if (!query) return true;
130
+ const title = String(task?.title || "").toLowerCase();
131
+ const id = String(task?.id || "").toLowerCase();
132
+ const tags = (Array.isArray(task?.tags) ? task.tags : []).map((tag) => String(tag || "").toLowerCase());
133
+ return title.includes(query) || id.includes(query) || tags.some((tag) => tag.includes(query));
134
+ }
135
+
136
+ function compareTasks(left, right) {
137
+ const statusDelta = (DISPLAY_STATUS_ORDER[left.statusDisplay] ?? 99) - (DISPLAY_STATUS_ORDER[right.statusDisplay] ?? 99);
138
+ if (statusDelta !== 0) return statusDelta;
139
+ const priorityDelta = (PRIORITY_ORDER[left.priority] ?? 99) - (PRIORITY_ORDER[right.priority] ?? 99);
140
+ if (priorityDelta !== 0) return priorityDelta;
141
+ return String(left.title || "").localeCompare(String(right.title || ""));
142
+ }
143
+
144
+ export function buildBoardColumns(tasks = [], { filterText = "", columnWidth = 32 } = {}) {
145
+ const filtered = (Array.isArray(tasks) ? tasks : [])
146
+ .map((task) => normalizeTask(task, columnWidth - 12))
147
+ .filter((task) => matchesFilter(task, filterText))
148
+ .sort(compareTasks);
149
+
150
+ return DISPLAY_COLUMNS.map((column) => {
151
+ const items = filtered.filter((task) => task.statusDisplay === column.key);
152
+ return {
153
+ ...column,
154
+ count: items.length,
155
+ items,
156
+ };
157
+ });
158
+ }
159
+
160
+ export function buildListRows(tasks = [], { filterText = "", rowWidth = 72 } = {}) {
161
+ return (Array.isArray(tasks) ? tasks : [])
162
+ .map((task) => normalizeTask(task, rowWidth - 28))
163
+ .filter((task) => matchesFilter(task, filterText))
164
+ .sort(compareTasks);
165
+ }
166
+
167
+ export function formatColumnSummary(columns = []) {
168
+ return (Array.isArray(columns) ? columns : [])
169
+ .map((column) => `${column.label} (${Number(column.count || 0)})`)
170
+ .join(" | ");
171
+ }
172
+
173
+ export function validateTaskForm(formState = {}) {
174
+ const errors = {};
175
+ if (!String(formState.title || "").trim()) {
176
+ errors.title = "Title is required";
177
+ }
178
+ return errors;
179
+ }
180
+
181
+ export function parseTaskDescription(description = "") {
182
+ const lines = String(description || "").split(/\r?\n/);
183
+ const state = {
184
+ description: [],
185
+ steps: [],
186
+ acceptanceCriteria: [],
187
+ verification: [],
188
+ };
189
+ let section = "description";
190
+
191
+ for (const rawLine of lines) {
192
+ const line = String(rawLine || "");
193
+ const trimmed = line.trim();
194
+ if (/^##\s+Implementation Steps$/i.test(trimmed)) {
195
+ section = "steps";
196
+ continue;
197
+ }
198
+ if (/^##\s+Acceptance Criteria$/i.test(trimmed)) {
199
+ section = "acceptanceCriteria";
200
+ continue;
201
+ }
202
+ if (/^##\s+Verification$/i.test(trimmed)) {
203
+ section = "verification";
204
+ continue;
205
+ }
206
+ if (!trimmed) {
207
+ if (section === "description") {
208
+ state.description.push("");
209
+ }
210
+ continue;
211
+ }
212
+ if (section === "description") {
213
+ state.description.push(line);
214
+ continue;
215
+ }
216
+ state[section].push(trimmed.replace(/^-\s*/, ""));
217
+ }
218
+
219
+ return {
220
+ description: state.description.join("\n").trim(),
221
+ steps: state.steps,
222
+ acceptanceCriteria: state.acceptanceCriteria,
223
+ verification: state.verification,
224
+ };
225
+ }
226
+
227
+ export function buildTaskDescription(formState = {}) {
228
+ const sections = [];
229
+ const description = String(formState.description || "").trim();
230
+ if (description) sections.push(description);
231
+
232
+ const groups = [
233
+ ["Implementation Steps", parseLineList(formState.steps ?? formState.stepsText)],
234
+ ["Acceptance Criteria", parseLineList(formState.acceptanceCriteria ?? formState.acceptanceCriteriaText)],
235
+ ["Verification", parseLineList(formState.verification ?? formState.verificationText)],
236
+ ];
237
+
238
+ for (const [label, entries] of groups) {
239
+ if (!entries.length) continue;
240
+ if (sections.length) sections.push("");
241
+ sections.push(`## ${label}`);
242
+ entries.forEach((entry) => {
243
+ sections.push(`- ${entry}`);
244
+ });
245
+ }
246
+
247
+ return sections.join("\n").trim();
248
+ }
249
+
250
+ export function buildTaskPayload(formState = {}) {
251
+ return {
252
+ title: String(formState.title || "").trim(),
253
+ priority: String(formState.priority || "medium").trim().toLowerCase() || "medium",
254
+ status: toPersistedStatus(formState.status || "todo"),
255
+ tags: parseTags(formState.tagsText ?? formState.tags),
256
+ description: buildTaskDescription(formState),
257
+ draft: false,
258
+ };
259
+ }
260
+
261
+ export function buildFormStateFromTask(task = {}) {
262
+ const parsed = parseTaskDescription(task.description || "");
263
+ return {
264
+ title: String(task.title || ""),
265
+ priority: String(task.priority || "medium").toLowerCase() || "medium",
266
+ status: normalizeTaskStatus(task.status || "todo"),
267
+ tagsText: (Array.isArray(task.tags) ? task.tags : []).join(", "),
268
+ description: parsed.description,
269
+ stepsText: parsed.steps.join("\n"),
270
+ acceptanceCriteriaText: parsed.acceptanceCriteria.join("\n"),
271
+ verificationText: parsed.verification.join("\n"),
272
+ };
273
+ }
274
+
275
+ export async function listTasksFromApi(filters = {}, taskApi = { list: taskList }) {
276
+ return taskApi.list(filters);
277
+ }
278
+
279
+ export async function createTaskFromForm(formState = {}, taskApi = { create: taskCreate }) {
280
+ const errors = validateTaskForm(formState);
281
+ if (Object.keys(errors).length) {
282
+ const err = new Error(errors.title || "Validation failed");
283
+ err.validationErrors = errors;
284
+ throw err;
285
+ }
286
+ return taskApi.create(buildTaskPayload(formState));
287
+ }
288
+
289
+ export async function updateTaskFromForm(taskId, formState = {}, taskApi = { update: taskUpdate }) {
290
+ const errors = validateTaskForm(formState);
291
+ if (Object.keys(errors).length) {
292
+ const err = new Error(errors.title || "Validation failed");
293
+ err.validationErrors = errors;
294
+ throw err;
295
+ }
296
+ return taskApi.update(taskId, buildTaskPayload(formState));
297
+ }
298
+
299
+ export async function deleteTaskById(taskId, taskApi = { delete: taskDelete }) {
300
+ return taskApi.delete(taskId);
301
+ }
@@ -0,0 +1,61 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+
3
+ import { taskCreate, taskDelete, taskList, taskUpdate } from "../../task/task-cli.mjs";
4
+
5
+ async function withMutedConsole(work) {
6
+ const originalLog = console.log;
7
+ const originalInfo = console.info;
8
+ console.log = () => {};
9
+ console.info = () => {};
10
+ try {
11
+ return await work();
12
+ } finally {
13
+ console.log = originalLog;
14
+ console.info = originalInfo;
15
+ }
16
+ }
17
+
18
+ export function useTasks() {
19
+ const [tasks, setTasks] = useState([]);
20
+ const [loading, setLoading] = useState(true);
21
+ const [error, setError] = useState(null);
22
+
23
+ const refresh = useCallback(async () => {
24
+ setLoading(true);
25
+ try {
26
+ const nextTasks = await withMutedConsole(() => taskList());
27
+ setTasks(Array.isArray(nextTasks) ? nextTasks : []);
28
+ setError(null);
29
+ } catch (err) {
30
+ setError(String(err?.message || err));
31
+ } finally {
32
+ setLoading(false);
33
+ }
34
+ }, []);
35
+
36
+ useEffect(() => {
37
+ refresh();
38
+ }, [refresh]);
39
+
40
+ const createTask = useCallback(async (payload) => {
41
+ const created = await withMutedConsole(() => taskCreate(payload));
42
+ await refresh();
43
+ return created;
44
+ }, [refresh]);
45
+
46
+ const updateTask = useCallback(async (taskId, patch) => {
47
+ const updated = await withMutedConsole(() => taskUpdate(taskId, patch));
48
+ await refresh();
49
+ return updated;
50
+ }, [refresh]);
51
+
52
+ const deleteTask = useCallback(async (taskId) => {
53
+ const deleted = await withMutedConsole(() => taskDelete(taskId));
54
+ await refresh();
55
+ return deleted;
56
+ }, [refresh]);
57
+
58
+ return { tasks, loading, error, refresh, createTask, updateTask, deleteTask };
59
+ }
60
+
61
+ export default useTasks;
@@ -0,0 +1,166 @@
1
+ import React, { useEffect, useMemo, useRef, useState } from "react";
2
+
3
+ import { TuiWsBridge, buildTuiWebSocketUrl } from "../../tui/lib/ws-bridge.mjs";
4
+
5
+ function applyTaskPatch(tasks, payload) {
6
+ const next = Array.isArray(tasks) ? [...tasks] : [];
7
+ const reason = String(payload?.reason || "").toLowerCase();
8
+ const sourceEvent = String(payload?.sourceEvent || "").toLowerCase();
9
+ const taskId = payload?.taskId ?? payload?.patch?.id;
10
+ const patch = taskId ? { ...(payload?.patch || {}), id: taskId } : null;
11
+ const index = next.findIndex((task) => String(task?.id || "") === String(taskId || ""));
12
+
13
+ if (reason.includes("delete") || sourceEvent.includes("delete")) {
14
+ if (index >= 0) next.splice(index, 1);
15
+ return next;
16
+ }
17
+
18
+ if (!patch) return next;
19
+ if (index >= 0) next[index] = { ...next[index], ...patch };
20
+ else next.unshift(patch);
21
+ return next;
22
+ }
23
+
24
+ function upsertSession(sessions, session) {
25
+ const next = Array.isArray(sessions) ? [...sessions] : [];
26
+ const sessionId = String(session?.id || "");
27
+ if (!sessionId) return next;
28
+ const index = next.findIndex((entry) => String(entry?.id || "") === sessionId);
29
+ if (index >= 0) next[index] = { ...next[index], ...session };
30
+ else next.unshift(session);
31
+ return next;
32
+ }
33
+
34
+ export function buildBusWebSocketUrl(options) {
35
+ return buildTuiWebSocketUrl(options);
36
+ }
37
+
38
+ export function useWebSocket({ host, port, configDir, protocol = "ws" }) {
39
+ const bridgeRef = useRef(null);
40
+ const [state, setState] = useState({
41
+ connected: false,
42
+ connectionStatus: "connecting",
43
+ reconnectPulse: false,
44
+ reconnectCount: 0,
45
+ stats: null,
46
+ sessions: [],
47
+ tasks: [],
48
+ logs: [],
49
+ workflows: [],
50
+ error: null,
51
+ lastEventAt: null,
52
+ });
53
+
54
+ useEffect(() => {
55
+ const bridge = new TuiWsBridge({ host, port, configDir, protocol });
56
+ bridgeRef.current = bridge;
57
+
58
+ const unsubscribers = [
59
+ bridge.on("connect", () => {
60
+ setState((prev) => ({
61
+ ...prev,
62
+ connected: true,
63
+ connectionStatus: "connected",
64
+ reconnectPulse: false,
65
+ error: null,
66
+ lastEventAt: Date.now(),
67
+ }));
68
+ }),
69
+ bridge.on("disconnect", () => {
70
+ setState((prev) => ({
71
+ ...prev,
72
+ connected: false,
73
+ connectionStatus: "reconnecting",
74
+ reconnectCount: prev.reconnectCount + 1,
75
+ lastEventAt: Date.now(),
76
+ }));
77
+ }),
78
+ bridge.on("error", (error) => {
79
+ setState((prev) => ({
80
+ ...prev,
81
+ connected: false,
82
+ connectionStatus: prev.connected ? "disconnected" : "reconnecting",
83
+ error: String(error?.message || "WebSocket error"),
84
+ lastEventAt: Date.now(),
85
+ }));
86
+ }),
87
+ bridge.on("monitor:stats", (stats) => {
88
+ setState((prev) => ({ ...prev, stats: stats || null, lastEventAt: Date.now() }));
89
+ }),
90
+ bridge.on("stats", (stats) => {
91
+ setState((prev) => ({ ...prev, stats: stats || prev.stats, lastEventAt: Date.now() }));
92
+ }),
93
+ bridge.on("sessions:update", (payload) => {
94
+ const sessions = Array.isArray(payload?.sessions)
95
+ ? payload.sessions
96
+ : Array.isArray(payload)
97
+ ? payload
98
+ : [];
99
+ setState((prev) => ({ ...prev, sessions, lastEventAt: Date.now() }));
100
+ }),
101
+ bridge.on("session:event", (payload) => {
102
+ const session = payload?.session;
103
+ if (!session) return;
104
+ setState((prev) => ({
105
+ ...prev,
106
+ sessions: upsertSession(prev.sessions, session),
107
+ lastEventAt: Date.now(),
108
+ }));
109
+ }),
110
+ bridge.on("tasks:update", (payload) => {
111
+ setState((prev) => ({
112
+ ...prev,
113
+ tasks: applyTaskPatch(prev.tasks, payload),
114
+ lastEventAt: Date.now(),
115
+ }));
116
+ }),
117
+ bridge.on("logs:stream", (payload) => {
118
+ setState((prev) => ({
119
+ ...prev,
120
+ logs: [payload, ...prev.logs].slice(0, 100),
121
+ lastEventAt: Date.now(),
122
+ }));
123
+ }),
124
+ bridge.on("workflow:status", (payload) => {
125
+ setState((prev) => ({
126
+ ...prev,
127
+ workflows: [payload, ...prev.workflows].slice(0, 50),
128
+ lastEventAt: Date.now(),
129
+ }));
130
+ }),
131
+ ];
132
+
133
+ bridge.connect();
134
+
135
+ return () => {
136
+ for (const unsubscribe of unsubscribers) {
137
+ if (typeof unsubscribe === "function") unsubscribe();
138
+ }
139
+ bridge.disconnect();
140
+ bridgeRef.current = null;
141
+ };
142
+ }, [configDir, host, port, protocol]);
143
+
144
+ useEffect(() => {
145
+ if (state.connectionStatus !== "reconnecting") {
146
+ setState((prev) => (prev.reconnectPulse ? { ...prev, reconnectPulse: false } : prev));
147
+ return undefined;
148
+ }
149
+
150
+ const timer = setInterval(() => {
151
+ setState((prev) => ({ ...prev, reconnectPulse: !prev.reconnectPulse }));
152
+ }, 450);
153
+
154
+ return () => clearInterval(timer);
155
+ }, [state.connectionStatus]);
156
+
157
+ return useMemo(() => ({
158
+ ...state,
159
+ send(type, payload = {}) {
160
+ bridgeRef.current?.send(type, payload);
161
+ },
162
+ url: buildTuiWebSocketUrl({ host, port, protocol }),
163
+ }), [host, port, protocol, state]);
164
+ }
165
+
166
+ export default useWebSocket;
@@ -0,0 +1,30 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+
3
+ import { listWorkflowSummaries } from "../../workflow/workflow-cli.mjs";
4
+
5
+ export function useWorkflows(config) {
6
+ const [workflows, setWorkflows] = useState([]);
7
+ const [loading, setLoading] = useState(true);
8
+ const [error, setError] = useState(null);
9
+
10
+ const refresh = useCallback(async () => {
11
+ setLoading(true);
12
+ try {
13
+ const summaries = await Promise.resolve(listWorkflowSummaries(config));
14
+ setWorkflows(Array.isArray(summaries) ? summaries : []);
15
+ setError(null);
16
+ } catch (err) {
17
+ setError(String(err?.message || err));
18
+ } finally {
19
+ setLoading(false);
20
+ }
21
+ }, [config]);
22
+
23
+ useEffect(() => {
24
+ refresh();
25
+ }, [refresh]);
26
+
27
+ return { workflows, loading, error, refresh };
28
+ }
29
+
30
+ export default useWorkflows;