bosun 0.41.2 → 0.41.3

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 (71) hide show
  1. package/.env.example +1 -1
  2. package/agent/agent-prompt-catalog.mjs +971 -0
  3. package/agent/agent-prompts.mjs +2 -970
  4. package/agent/agent-supervisor.mjs +6 -3
  5. package/agent/autofix-git.mjs +33 -0
  6. package/agent/autofix-prompts.mjs +151 -0
  7. package/agent/autofix.mjs +11 -175
  8. package/agent/bosun-skills.mjs +3 -2
  9. package/bosun.config.example.json +17 -0
  10. package/bosun.schema.json +87 -188
  11. package/cli.mjs +34 -1
  12. package/config/config-doctor.mjs +5 -250
  13. package/config/config-file-names.mjs +5 -0
  14. package/config/config.mjs +89 -493
  15. package/config/executor-config.mjs +493 -0
  16. package/config/repo-root.mjs +1 -2
  17. package/config/workspace-health.mjs +242 -0
  18. package/git/git-safety.mjs +15 -0
  19. package/github/github-oauth-portal.mjs +46 -0
  20. package/infra/library-manager-utils.mjs +22 -0
  21. package/infra/library-manager-well-known-sources.mjs +578 -0
  22. package/infra/library-manager.mjs +512 -1030
  23. package/infra/monitor.mjs +28 -9
  24. package/infra/session-tracker.mjs +10 -7
  25. package/kanban/kanban-adapter.mjs +17 -1
  26. package/lib/codebase-audit-manifests.mjs +117 -0
  27. package/lib/codebase-audit.mjs +18 -115
  28. package/package.json +18 -3
  29. package/server/ui-server.mjs +1194 -79
  30. package/shell/codex-config-file.mjs +178 -0
  31. package/shell/codex-config.mjs +538 -575
  32. package/task/task-cli.mjs +54 -3
  33. package/task/task-executor.mjs +143 -13
  34. package/task/task-store.mjs +409 -1
  35. package/telegram/telegram-bot.mjs +127 -0
  36. package/tools/apply-pr-suggestions.mjs +401 -0
  37. package/tools/syntax-check.mjs +21 -9
  38. package/ui/app.js +3 -14
  39. package/ui/components/kanban-board.js +227 -4
  40. package/ui/components/session-list.js +85 -5
  41. package/ui/demo-defaults.js +334 -80
  42. package/ui/demo.html +155 -0
  43. package/ui/modules/session-api.js +96 -0
  44. package/ui/modules/settings-schema.js +1 -2
  45. package/ui/modules/state.js +21 -3
  46. package/ui/setup.html +4 -5
  47. package/ui/styles/components.css +58 -4
  48. package/ui/tabs/agents.js +12 -15
  49. package/ui/tabs/control.js +1 -0
  50. package/ui/tabs/library.js +484 -22
  51. package/ui/tabs/manual-flows.js +105 -29
  52. package/ui/tabs/tasks.js +785 -140
  53. package/ui/tabs/telemetry.js +129 -11
  54. package/ui/tabs/workflow-canvas-utils.mjs +130 -0
  55. package/ui/tabs/workflows.js +293 -23
  56. package/voice/voice-tool-definitions.mjs +757 -0
  57. package/voice/voice-tools.mjs +34 -778
  58. package/workflow/manual-flow-audit.mjs +165 -0
  59. package/workflow/manual-flows.mjs +164 -259
  60. package/workflow/workflow-engine.mjs +147 -58
  61. package/workflow/workflow-nodes/definitions.mjs +1207 -0
  62. package/workflow/workflow-nodes/transforms.mjs +612 -0
  63. package/workflow/workflow-nodes.mjs +304 -52
  64. package/workflow/workflow-templates.mjs +313 -191
  65. package/workflow-templates/_helpers.mjs +154 -0
  66. package/workflow-templates/agents.mjs +61 -4
  67. package/workflow-templates/code-quality.mjs +7 -7
  68. package/workflow-templates/github.mjs +20 -10
  69. package/workflow-templates/task-batch.mjs +20 -9
  70. package/workflow-templates/task-lifecycle.mjs +31 -6
  71. package/workspace/worktree-manager.mjs +277 -3
@@ -208,6 +208,7 @@ const TASK_STORE_SPRINT_EXPORTS = Object.freeze({
208
208
  const TASK_STORE_DAG_EXPORTS = Object.freeze({
209
209
  sprint: ["getSprintDag", "getTaskDagForSprint", "buildSprintDag", "buildTaskDag"],
210
210
  global: ["getGlobalDagOfDags", "getDagOfDags", "buildGlobalDagOfDags"],
211
+ organize: ["organizeTaskDag"],
211
212
  });
212
213
  const TASK_STORE_GET_TASK_EXPORTS = ["getTaskById", "getTask"];
213
214
  const TASK_STORE_COMMENT_EXPORTS = ["getTaskComments", "listTaskComments"];
@@ -308,6 +309,16 @@ function addInternalTaskComment(taskId, comment = {}) {
308
309
  }
309
310
  }
310
311
 
312
+ function unblockInternalTask(taskId, options = {}) {
313
+ const fn = getTaskStoreApiSync()?.unblockTask;
314
+ if (typeof fn !== "function") return null;
315
+ try {
316
+ return fn(taskId, options);
317
+ } catch {
318
+ return null;
319
+ }
320
+ }
321
+
311
322
  const __dirname = resolve(fileURLToPath(new URL(".", import.meta.url)));
312
323
  const repoRoot = resolveRepoRoot();
313
324
  const uiRootPreferred = resolve(__dirname, "..", "ui");
@@ -1104,6 +1115,73 @@ let _wfInitPromise = null;
1104
1115
  let _wfInitDone = false;
1105
1116
  let _wfLoadedBase = null;
1106
1117
  let _wfTaskTraceHookRegistered = false;
1118
+ let _workflowTelegramDigestPromise = null;
1119
+ const workflowTelegramDedup = new Map();
1120
+
1121
+ async function getWorkflowTelegramDigest() {
1122
+ if (_workflowTelegramDigestPromise) {
1123
+ return _workflowTelegramDigestPromise;
1124
+ }
1125
+ _workflowTelegramDigestPromise = (async () => {
1126
+ try {
1127
+ const mod = await import("../telegram/telegram-bot.mjs");
1128
+ if (typeof mod.restoreLiveDigest === "function") {
1129
+ await mod.restoreLiveDigest().catch(() => {});
1130
+ }
1131
+ return mod;
1132
+ } catch (err) {
1133
+ console.warn("[workflows/telegram] live digest unavailable:", err?.message || err);
1134
+ return null;
1135
+ }
1136
+ })();
1137
+ return _workflowTelegramDigestPromise;
1138
+ }
1139
+
1140
+ async function sendWorkflowTelegramMessage(chatId, text, options = {}) {
1141
+ const telegramToken = process.env.TELEGRAM_BOT_TOKEN;
1142
+ const defaultChatId = String(process.env.TELEGRAM_CHAT_ID || "").trim();
1143
+ const target = String(chatId || defaultChatId || "").trim();
1144
+ if (!telegramToken || !target) return;
1145
+
1146
+ const message = String(text || "");
1147
+ const dedupKey = `${target}:${message.trim()}`;
1148
+ const now = Date.now();
1149
+ const lastSentAt = workflowTelegramDedup.get(dedupKey) || 0;
1150
+ if (dedupKey && now - lastSentAt < 5 * 60 * 1000) {
1151
+ return;
1152
+ }
1153
+ workflowTelegramDedup.set(dedupKey, now);
1154
+
1155
+ const parseMode = String(options?.parseMode || "").trim();
1156
+ if (!parseMode && defaultChatId && target === defaultChatId) {
1157
+ const digest = await getWorkflowTelegramDigest();
1158
+ if (typeof digest?.notify === "function") {
1159
+ await digest.notify(message, 4, {
1160
+ silent: Boolean(options?.silent),
1161
+ category: "workflow",
1162
+ });
1163
+ return;
1164
+ }
1165
+ }
1166
+
1167
+ try {
1168
+ await fetch(
1169
+ `https://api.telegram.org/bot${telegramToken}/sendMessage`,
1170
+ {
1171
+ method: "POST",
1172
+ headers: { "Content-Type": "application/json" },
1173
+ body: JSON.stringify({
1174
+ chat_id: target,
1175
+ text: message,
1176
+ parse_mode: parseMode || "HTML",
1177
+ disable_notification: Boolean(options?.silent),
1178
+ }),
1179
+ },
1180
+ );
1181
+ } catch (e) {
1182
+ console.warn("[workflows/telegram] sendMessage failed:", e.message);
1183
+ }
1184
+ }
1107
1185
 
1108
1186
  /**
1109
1187
  * Test-only: inject a mock workflow engine module and pre-seed the per-workspace
@@ -1251,6 +1329,9 @@ async function getWorkflowEngineModule() {
1251
1329
  _wfEngine = await import(new URL("../workflow/workflow-engine.mjs", base).href);
1252
1330
  _wfNodes = await import(new URL("../workflow/workflow-nodes.mjs", base).href);
1253
1331
  _wfTemplates = await import(new URL("../workflow/workflow-templates.mjs", base).href);
1332
+ if (typeof _wfNodes?.ensureWorkflowNodeTypesLoaded === "function") {
1333
+ await _wfNodes.ensureWorkflowNodeTypesLoaded({ repoRoot });
1334
+ }
1254
1335
  if (_wfLoadedBase !== base) {
1255
1336
  console.log(`[workflows] Loaded workflow modules from: ${base}`);
1256
1337
  _wfLoadedBase = base;
@@ -1276,26 +1357,11 @@ async function getWorkflowEngineModule() {
1276
1357
  const telegramService = telegramToken
1277
1358
  ? {
1278
1359
  async sendMessage(chatId, text, options = {}) {
1279
- const target = chatId || telegramChatId;
1280
- if (!target) return;
1281
- try {
1282
- const parseMode = String(options?.parseMode || "").trim() || "HTML";
1283
- await fetch(
1284
- `https://api.telegram.org/bot${telegramToken}/sendMessage`,
1285
- {
1286
- method: "POST",
1287
- headers: { "Content-Type": "application/json" },
1288
- body: JSON.stringify({
1289
- chat_id: target,
1290
- text: String(text || ""),
1291
- parse_mode: parseMode,
1292
- disable_notification: Boolean(options?.silent),
1293
- }),
1294
- }
1295
- );
1296
- } catch (e) {
1297
- console.warn("[workflows/telegram] sendMessage failed:", e.message);
1298
- }
1360
+ await sendWorkflowTelegramMessage(
1361
+ chatId || telegramChatId,
1362
+ text,
1363
+ options,
1364
+ );
1299
1365
  },
1300
1366
  }
1301
1367
  : null;
@@ -1541,15 +1607,67 @@ function handleTaskWorkflowTraceEvent(event = {}) {
1541
1607
 
1542
1608
  function mergeTaskWorkflowRuns(baseRuns = [], extraRuns = [], limit = 60) {
1543
1609
  const merged = [];
1544
- const seen = new Set();
1610
+ const indexByKey = new Map();
1611
+ const resolveLinkedSessionId = (entry) => {
1612
+ const candidates = [
1613
+ entry?.sessionId,
1614
+ entry?.threadId,
1615
+ entry?.agentSessionId,
1616
+ entry?.meta?.sessionId,
1617
+ entry?.meta?.threadId,
1618
+ entry?.data?.sessionId,
1619
+ entry?.data?.threadId,
1620
+ ];
1621
+ for (const value of candidates) {
1622
+ const normalized = String(value || "").trim();
1623
+ if (normalized) return normalized;
1624
+ }
1625
+ return null;
1626
+ };
1627
+ const resolveSessionId = (entry) => {
1628
+ const directSessionId = resolveLinkedSessionId(entry);
1629
+ if (directSessionId) return directSessionId;
1630
+ const primarySessionId = String(entry?.primarySessionId || "").trim();
1631
+ return primarySessionId || null;
1632
+ };
1633
+ const mergeEntries = (current, incoming) => {
1634
+ const currentMeta = current?.meta && typeof current.meta === "object" ? current.meta : {};
1635
+ const incomingMeta = incoming?.meta && typeof incoming.meta === "object" ? incoming.meta : {};
1636
+ const mergedMeta = { ...currentMeta, ...incomingMeta };
1637
+ const currentMetaSessionId = String(currentMeta.sessionId || "").trim();
1638
+ const currentMetaThreadId = String(currentMeta.threadId || "").trim();
1639
+ if (currentMetaSessionId) mergedMeta.sessionId = currentMetaSessionId;
1640
+ if (currentMetaThreadId) mergedMeta.threadId = currentMetaThreadId;
1641
+ return {
1642
+ ...current,
1643
+ ...incoming,
1644
+ runId: incoming.runId || current.runId || null,
1645
+ workflowId: incoming.workflowId || current.workflowId || null,
1646
+ workflowName: incoming.workflowName || current.workflowName || null,
1647
+ status: incoming.status || current.status || null,
1648
+ outcome: incoming.outcome || current.outcome || null,
1649
+ summary: incoming.summary || current.summary || null,
1650
+ startedAt: incoming.startedAt || current.startedAt || null,
1651
+ endedAt: incoming.endedAt || current.endedAt || null,
1652
+ duration: incoming.duration ?? current.duration ?? null,
1653
+ url: incoming.url || current.url || null,
1654
+ nodeId: incoming.nodeId || current.nodeId || null,
1655
+ source: incoming.source || current.source || "workflow",
1656
+ sessionId: resolveLinkedSessionId(incoming) || resolveLinkedSessionId(current) || null,
1657
+ primarySessionId:
1658
+ String(incoming.primarySessionId || "").trim()
1659
+ || String(current.primarySessionId || "").trim()
1660
+ || resolveLinkedSessionId(incoming)
1661
+ || resolveLinkedSessionId(current),
1662
+ meta: mergedMeta,
1663
+ };
1664
+ };
1545
1665
  const push = (entry) => {
1546
1666
  if (!entry || typeof entry !== "object") return;
1547
1667
  const runId = String(entry.runId || "").trim();
1548
1668
  const workflowId = String(entry.workflowId || "").trim();
1549
1669
  const dedupKey = runId ? `run:${runId}` : `wf:${workflowId}:${entry.startedAt || entry.endedAt || ""}`;
1550
- if (seen.has(dedupKey)) return;
1551
- seen.add(dedupKey);
1552
- merged.push({
1670
+ const normalized = {
1553
1671
  runId: runId || null,
1554
1672
  workflowId: workflowId || null,
1555
1673
  workflowName: entry.workflowName != null ? String(entry.workflowName) : null,
@@ -1559,8 +1677,21 @@ function mergeTaskWorkflowRuns(baseRuns = [], extraRuns = [], limit = 60) {
1559
1677
  startedAt: entry.startedAt || null,
1560
1678
  endedAt: entry.endedAt || null,
1561
1679
  duration: Number.isFinite(Number(entry.duration)) ? Number(entry.duration) : null,
1680
+ url: entry.url != null ? String(entry.url) : null,
1681
+ nodeId: entry.nodeId != null ? String(entry.nodeId) : null,
1562
1682
  source: entry.source ? String(entry.source) : "workflow",
1563
- });
1683
+ sessionId: resolveLinkedSessionId(entry),
1684
+ primarySessionId:
1685
+ String(entry.primarySessionId || "").trim() || resolveSessionId(entry),
1686
+ meta: entry.meta && typeof entry.meta === "object" ? { ...entry.meta } : {},
1687
+ };
1688
+ const existingIndex = indexByKey.get(dedupKey);
1689
+ if (existingIndex == null) {
1690
+ indexByKey.set(dedupKey, merged.length);
1691
+ merged.push(normalized);
1692
+ return;
1693
+ }
1694
+ merged[existingIndex] = mergeEntries(merged[existingIndex], normalized);
1564
1695
  };
1565
1696
 
1566
1697
  for (const run of Array.isArray(baseRuns) ? baseRuns : []) push(run);
@@ -1599,6 +1730,33 @@ async function collectWorkflowRunsForTask(taskId, reqUrl, limit = 40) {
1599
1730
  matches = traceEvents.some((event) => String(event?.taskId || "").trim() === normalizedTaskId);
1600
1731
  }
1601
1732
  if (!matches) continue;
1733
+ const primarySessionId = (() => {
1734
+ for (const value of [
1735
+ data.sessionId,
1736
+ data.threadId,
1737
+ data?.task?.sessionId,
1738
+ data?.task?.threadId,
1739
+ ]) {
1740
+ const normalized = String(value || "").trim();
1741
+ if (normalized) return normalized;
1742
+ }
1743
+ const traceEvents = typeof engine.getTaskTraceEvents === "function"
1744
+ ? engine.getTaskTraceEvents(summary.runId) || []
1745
+ : [];
1746
+ for (let index = traceEvents.length - 1; index >= 0; index -= 1) {
1747
+ const event = traceEvents[index];
1748
+ for (const value of [
1749
+ event?.sessionId,
1750
+ event?.threadId,
1751
+ event?.meta?.sessionId,
1752
+ event?.meta?.threadId,
1753
+ ]) {
1754
+ const normalized = String(value || "").trim();
1755
+ if (normalized) return normalized;
1756
+ }
1757
+ }
1758
+ return null;
1759
+ })();
1602
1760
  out.push({
1603
1761
  runId: detail.runId,
1604
1762
  workflowId: detail.workflowId,
@@ -1611,6 +1769,8 @@ async function collectWorkflowRunsForTask(taskId, reqUrl, limit = 40) {
1611
1769
  startedAt: detail.startedAt || null,
1612
1770
  endedAt: detail.endedAt || null,
1613
1771
  duration: detail.duration || null,
1772
+ sessionId: null,
1773
+ primarySessionId,
1614
1774
  source: "workflow",
1615
1775
  });
1616
1776
  if (out.length >= limit) break;
@@ -1620,6 +1780,230 @@ async function collectWorkflowRunsForTask(taskId, reqUrl, limit = 40) {
1620
1780
  return [];
1621
1781
  }
1622
1782
  }
1783
+
1784
+ function sanitizeTaskDiagnosticText(value, maxLength = 240) {
1785
+ const normalized = String(value || "")
1786
+ .replace(/\s+/g, " ")
1787
+ .trim();
1788
+ if (!normalized) return "";
1789
+ if (!Number.isFinite(maxLength) || maxLength <= 0 || normalized.length <= maxLength) {
1790
+ return normalized;
1791
+ }
1792
+ return `${normalized.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;
1793
+ }
1794
+
1795
+ function collectTaskTimelineDiagnostics(task, limit = 8) {
1796
+ const timeline = Array.isArray(task?.timeline) ? task.timeline : [];
1797
+ const relevant = [];
1798
+ for (const entry of timeline) {
1799
+ const message = sanitizeTaskDiagnosticText(
1800
+ entry?.message || entry?.reason || entry?.error || "",
1801
+ 280,
1802
+ );
1803
+ const status = String(entry?.status || "").trim().toLowerCase();
1804
+ if (
1805
+ !message &&
1806
+ status !== "blocked"
1807
+ ) {
1808
+ continue;
1809
+ }
1810
+ const isRelevant =
1811
+ status === "blocked" ||
1812
+ /worktree failed/i.test(message) ||
1813
+ /pre-pr validation failed/i.test(message) ||
1814
+ /claim was stolen/i.test(message) ||
1815
+ /blocked/i.test(message);
1816
+ if (!isRelevant) continue;
1817
+ relevant.push({
1818
+ source: String(entry?.source || entry?.type || "timeline").trim() || "timeline",
1819
+ message: message || `Task entered ${status || "blocked"} state`,
1820
+ timestamp: entry?.timestamp || entry?.createdAt || entry?.updatedAt || null,
1821
+ status: status || null,
1822
+ kind: "timeline",
1823
+ });
1824
+ }
1825
+ return relevant.slice(-Math.max(1, limit));
1826
+ }
1827
+
1828
+ function collectTaskLogDiagnostics(task, workspaceDir = "", limit = 8) {
1829
+ const taskId = String(task?.id || task?.taskId || "").trim();
1830
+ if (!taskId) {
1831
+ return {
1832
+ counts: {
1833
+ prePrValidationFailed: 0,
1834
+ worktreeFailed: 0,
1835
+ blockedTransitions: 0,
1836
+ createPrFailed: 0,
1837
+ },
1838
+ entries: [],
1839
+ };
1840
+ }
1841
+
1842
+ const taskBranch = String(task?.branch || task?.branchName || "").trim();
1843
+ const needles = [taskId, taskBranch].filter((value) => value && value.length >= 8);
1844
+ const logPaths = [];
1845
+ const pushLogPath = (candidate) => {
1846
+ if (!candidate || !existsSync(candidate) || logPaths.includes(candidate)) return;
1847
+ logPaths.push(candidate);
1848
+ };
1849
+ if (workspaceDir) {
1850
+ pushLogPath(resolve(workspaceDir, ".bosun", "logs", "monitor-error.log"));
1851
+ pushLogPath(resolve(workspaceDir, ".bosun", "logs", "monitor.log"));
1852
+ }
1853
+ pushLogPath(resolve(repoRoot, ".bosun", "logs", "monitor-error.log"));
1854
+ pushLogPath(resolve(repoRoot, ".bosun", "logs", "monitor.log"));
1855
+
1856
+ const counts = {
1857
+ prePrValidationFailed: 0,
1858
+ worktreeFailed: 0,
1859
+ blockedTransitions: 0,
1860
+ createPrFailed: 0,
1861
+ };
1862
+ const entries = [];
1863
+
1864
+ for (const logPath of logPaths) {
1865
+ let raw = "";
1866
+ try {
1867
+ raw = readFileSync(logPath, "utf8");
1868
+ } catch {
1869
+ continue;
1870
+ }
1871
+ const logName = /monitor-error\.log$/i.test(logPath) ? "monitor-error.log" : "monitor.log";
1872
+ for (const line of raw.split(/\r?\n/)) {
1873
+ if (!line) continue;
1874
+ if (!needles.some((needle) => line.includes(needle))) continue;
1875
+ const text = sanitizeTaskDiagnosticText(line, 320);
1876
+ let matched = false;
1877
+ if (/pre-PR validation failed/i.test(text)) {
1878
+ counts.prePrValidationFailed += 1;
1879
+ matched = true;
1880
+ }
1881
+ if (/Worktree failed for/i.test(text) || /Worktree acquisition failed/i.test(text)) {
1882
+ counts.worktreeFailed += 1;
1883
+ matched = true;
1884
+ }
1885
+ if (/-> blocked/i.test(text) || /status: .*blocked/i.test(text)) {
1886
+ counts.blockedTransitions += 1;
1887
+ matched = true;
1888
+ }
1889
+ if (/create-pr FAILED/i.test(text)) {
1890
+ counts.createPrFailed += 1;
1891
+ matched = true;
1892
+ }
1893
+ if (!matched) continue;
1894
+ entries.push({
1895
+ source: logName,
1896
+ message: text,
1897
+ kind: "log",
1898
+ });
1899
+ if (entries.length > limit) entries.shift();
1900
+ }
1901
+ }
1902
+
1903
+ return { counts, entries };
1904
+ }
1905
+
1906
+ function buildTaskBlockedContext(task, options = {}) {
1907
+ const currentTask = task && typeof task === "object" ? task : {};
1908
+ const canStart = options.canStart && typeof options.canStart === "object"
1909
+ ? options.canStart
1910
+ : null;
1911
+ const normalizedStatus = normalizeTaskStatusKey(currentTask?.status);
1912
+ const explicitReason = sanitizeTaskDiagnosticText(
1913
+ currentTask?.blockedReason
1914
+ || currentTask?.meta?.worktreeFailure?.blockedReason
1915
+ || currentTask?.meta?.autoRecovery?.error
1916
+ || currentTask?.meta?.worktreeFailure?.error
1917
+ || "",
1918
+ 280,
1919
+ );
1920
+ const workflowRuns = Array.isArray(options.workflowRuns)
1921
+ ? options.workflowRuns
1922
+ : Array.isArray(currentTask?.workflowRuns)
1923
+ ? currentTask.workflowRuns
1924
+ : [];
1925
+ const timelineEvidence = collectTaskTimelineDiagnostics(currentTask, 6);
1926
+ const logDiagnostics = collectTaskLogDiagnostics(
1927
+ currentTask,
1928
+ normalizeCandidatePath(options.workspaceDir),
1929
+ 6,
1930
+ );
1931
+ const hasPlannerCorruption = /planner payload corrupted/i.test(explicitReason);
1932
+ const hasWorktreeFailure =
1933
+ Boolean(currentTask?.meta?.worktreeFailure) ||
1934
+ logDiagnostics.counts.worktreeFailed > 0 ||
1935
+ timelineEvidence.some((entry) => /worktree failed/i.test(String(entry?.message || "")));
1936
+ const isDependencyBlocked = canStart?.canStart === false && String(canStart?.reason || "") === "dependency_blocked";
1937
+
1938
+ let category = "";
1939
+ let headline = "";
1940
+ let summary = "";
1941
+ let recommendation = "";
1942
+
1943
+ if (hasPlannerCorruption) {
1944
+ category = "planner_payload_corruption";
1945
+ headline = "Planner payload corruption quarantined this task.";
1946
+ summary = explicitReason;
1947
+ recommendation = "Do not requeue this task as-is. Recreate it from the fixed planner path or repair its payload first.";
1948
+ } else if (hasWorktreeFailure) {
1949
+ category = "worktree_failure";
1950
+ headline = "Task Lifecycle blocked this task after worktree acquisition failed.";
1951
+ summary = explicitReason
1952
+ || "Bosun could not acquire or refresh a clean managed worktree for this task.";
1953
+ recommendation = "If the worktree guard fix is now deployed, move the task back to todo to retry it on a fresh lifecycle run.";
1954
+ } else if (isDependencyBlocked) {
1955
+ category = "dependency_blocked";
1956
+ headline = "This task cannot start because one or more dependencies are not done yet.";
1957
+ summary = "Bosun will not dispatch this task until every blocking dependency below is resolved.";
1958
+ recommendation = "Complete or unblock the listed dependencies, then dispatch this task again.";
1959
+ } else if (normalizedStatus === "blocked") {
1960
+ category = "blocked";
1961
+ headline = "This task is blocked.";
1962
+ summary = explicitReason || "Bosun marked this task as blocked, but the original blocked reason was not persisted.";
1963
+ recommendation = "Review the recent workflow evidence below. After the underlying issue is fixed, move the task back to todo to clear the block and retry it.";
1964
+ } else if (canStart?.canStart === false) {
1965
+ category = "start_guard_blocked";
1966
+ headline = "This task is currently not startable.";
1967
+ summary = sanitizeTaskDiagnosticText(canStart?.reason || "Bosun start guards rejected dispatch for this task.");
1968
+ recommendation = "Resolve the blocking condition below before dispatching the task.";
1969
+ } else {
1970
+ return null;
1971
+ }
1972
+
1973
+ return {
1974
+ status: normalizedStatus,
1975
+ category,
1976
+ headline,
1977
+ summary,
1978
+ recommendation,
1979
+ reason: explicitReason || sanitizeTaskDiagnosticText(canStart?.reason || ""),
1980
+ workflowRunCount: workflowRuns.length,
1981
+ prePrValidationFailureCount: logDiagnostics.counts.prePrValidationFailed,
1982
+ worktreeFailureCount: logDiagnostics.counts.worktreeFailed,
1983
+ blockedTransitionCount: logDiagnostics.counts.blockedTransitions,
1984
+ createPrFailureCount: logDiagnostics.counts.createPrFailed,
1985
+ blockedBy: Array.isArray(canStart?.blockedBy) ? canStart.blockedBy : [],
1986
+ blockingTaskIds: Array.isArray(canStart?.blockingTaskIds) ? canStart.blockingTaskIds : [],
1987
+ timelineEvidence,
1988
+ logEvidence: logDiagnostics.entries,
1989
+ };
1990
+ }
1991
+
1992
+ function buildTaskMetaPatch(previousMeta, metadataPatchMeta, options = {}) {
1993
+ const clearBlockedState = options.clearBlockedState === true;
1994
+ const nextMeta = previousMeta && typeof previousMeta === "object"
1995
+ ? { ...previousMeta }
1996
+ : {};
1997
+ if (clearBlockedState) {
1998
+ delete nextMeta.autoRecovery;
1999
+ delete nextMeta.blockedReason;
2000
+ }
2001
+ if (metadataPatchMeta && typeof metadataPatchMeta === "object") {
2002
+ Object.assign(nextMeta, metadataPatchMeta);
2003
+ }
2004
+ return nextMeta;
2005
+ }
2006
+
1623
2007
  function maybeBootstrapWorkspaceWorkflowTemplates(engine, workspaceKey, workspaceLabel) {
1624
2008
  if (!engine || !_wfTemplates) return;
1625
2009
  if (_wfRecommendedInstalledByWorkspace.has(workspaceKey)) return;
@@ -2450,31 +2834,159 @@ function normalizeTriggerTemplateId(template = {}) {
2450
2834
  .toLowerCase();
2451
2835
  }
2452
2836
 
2837
+ function sanitizeTriggerTemplateInput(template = {}) {
2838
+ if (!template || typeof template !== "object" || Array.isArray(template)) {
2839
+ return {};
2840
+ }
2841
+ const sanitized = {};
2842
+ for (const key of [
2843
+ "id",
2844
+ "name",
2845
+ "description",
2846
+ "enabled",
2847
+ "action",
2848
+ "minIntervalMinutes",
2849
+ "trigger",
2850
+ "config",
2851
+ ]) {
2852
+ if (Object.prototype.hasOwnProperty.call(template, key)) {
2853
+ sanitized[key] = template[key];
2854
+ }
2855
+ }
2856
+ return sanitized;
2857
+ }
2858
+
2453
2859
  function normalizeTriggerTemplate(template = {}) {
2454
- const id = normalizeTriggerTemplateId(template);
2860
+ const source = sanitizeTriggerTemplateInput(template);
2861
+ const id = normalizeTriggerTemplateId(source);
2455
2862
  if (!id) return null;
2456
2863
  return {
2457
- ...template,
2458
2864
  id,
2459
- name: String(template?.name || id).trim() || id,
2460
- enabled: template?.enabled === true,
2461
- action: String(template?.action || "create-task").trim(),
2865
+ name: String(source?.name || id).trim() || id,
2866
+ description: String(source?.description || "").trim(),
2867
+ enabled: source?.enabled === true,
2868
+ action: String(source?.action || "create-task").trim(),
2462
2869
  minIntervalMinutes:
2463
- Number.isFinite(Number(template?.minIntervalMinutes)) &&
2464
- Number(template?.minIntervalMinutes) > 0
2465
- ? Number(template.minIntervalMinutes)
2870
+ Number.isFinite(Number(source?.minIntervalMinutes)) &&
2871
+ Number(source?.minIntervalMinutes) > 0
2872
+ ? Number(source.minIntervalMinutes)
2466
2873
  : undefined,
2467
2874
  trigger:
2468
- template?.trigger && typeof template.trigger === "object"
2469
- ? template.trigger
2875
+ source?.trigger && typeof source.trigger === "object"
2876
+ ? source.trigger
2470
2877
  : { anyOf: [] },
2471
2878
  config:
2472
- template?.config && typeof template.config === "object"
2473
- ? template.config
2879
+ source?.config && typeof source.config === "object"
2880
+ ? source.config
2474
2881
  : {},
2475
2882
  };
2476
2883
  }
2477
2884
 
2885
+ function buildTaskStateExportPayload(tasks = [], backend = "unknown") {
2886
+ return {
2887
+ schemaVersion: 1,
2888
+ kind: "bosun-task-state-export",
2889
+ exportedAt: new Date().toISOString(),
2890
+ backend,
2891
+ tasks: Array.isArray(tasks) ? tasks : [],
2892
+ };
2893
+ }
2894
+
2895
+ function extractImportedTaskList(body = null) {
2896
+ if (Array.isArray(body)) return body;
2897
+ if (body && typeof body === "object") {
2898
+ if (Array.isArray(body.tasks)) return body.tasks;
2899
+ if (Array.isArray(body.backlog)) return body.backlog;
2900
+ if (body.data && typeof body.data === "object" && Array.isArray(body.data.tasks)) {
2901
+ return body.data.tasks;
2902
+ }
2903
+ }
2904
+ return null;
2905
+ }
2906
+
2907
+ async function importInternalTaskStateSnapshot(body = {}) {
2908
+ const taskStore = await ensureTaskStoreApi();
2909
+ const addTaskFn = typeof taskStore?.addTask === "function" ? taskStore.addTask : null;
2910
+ const updateTaskFn = typeof taskStore?.updateTask === "function" ? taskStore.updateTask : null;
2911
+ if (!addTaskFn || !updateTaskFn) {
2912
+ throw new Error("Internal task store import is unavailable");
2913
+ }
2914
+
2915
+ const tasks = extractImportedTaskList(body);
2916
+ if (!Array.isArray(tasks)) {
2917
+ throw new Error("JSON must contain an array of tasks (top-level or under 'tasks' key)");
2918
+ }
2919
+
2920
+ const mode = String(body?.mode || "merge").trim().toLowerCase();
2921
+ if (!["merge", "upsert"].includes(mode)) {
2922
+ throw new Error("Only merge/upsert import mode is supported");
2923
+ }
2924
+
2925
+ const existingById = new Map(
2926
+ getAllInternalTasks()
2927
+ .filter((task) => task && task.id)
2928
+ .map((task) => [String(task.id), task]),
2929
+ );
2930
+ const summary = {
2931
+ total: tasks.length,
2932
+ created: 0,
2933
+ updated: 0,
2934
+ failed: 0,
2935
+ };
2936
+ const results = [];
2937
+
2938
+ for (const entry of tasks) {
2939
+ const task = entry && typeof entry === "object" && !Array.isArray(entry) ? entry : null;
2940
+ const taskId = String(task?.id || "").trim();
2941
+ if (!task || !taskId) {
2942
+ summary.failed += 1;
2943
+ results.push({ id: taskId || null, status: "failed", error: "task.id is required" });
2944
+ continue;
2945
+ }
2946
+ if (!String(task.title || "").trim()) {
2947
+ summary.failed += 1;
2948
+ results.push({ id: taskId, status: "failed", error: "task.title is required" });
2949
+ continue;
2950
+ }
2951
+
2952
+ try {
2953
+ if (existingById.has(taskId)) {
2954
+ updateTaskFn(taskId, task);
2955
+ summary.updated += 1;
2956
+ results.push({ id: taskId, status: "updated" });
2957
+ } else {
2958
+ addTaskFn(task);
2959
+ existingById.set(taskId, task);
2960
+ summary.created += 1;
2961
+ results.push({ id: taskId, status: "created" });
2962
+ }
2963
+ } catch (err) {
2964
+ summary.failed += 1;
2965
+ results.push({
2966
+ id: taskId,
2967
+ status: "failed",
2968
+ error: err?.message || "import failed",
2969
+ });
2970
+ }
2971
+ }
2972
+
2973
+ if (summary.created > 0 || summary.updated > 0) {
2974
+ const waitForWritesFn = typeof taskStore?.waitForStoreWrites === "function"
2975
+ ? taskStore.waitForStoreWrites
2976
+ : null;
2977
+ if (waitForWritesFn) {
2978
+ await waitForWritesFn();
2979
+ }
2980
+ }
2981
+
2982
+ return {
2983
+ backend: "internal",
2984
+ mode,
2985
+ summary,
2986
+ results,
2987
+ };
2988
+ }
2989
+
2478
2990
  function readConfigDocument() {
2479
2991
  const configPath = resolveConfigPath();
2480
2992
  let configData = { $schema: "./bosun.schema.json" };
@@ -2925,6 +3437,7 @@ async function applySharedStateToTasks(tasks) {
2925
3437
  function mapTaskStatusToBoardColumn(status) {
2926
3438
  const normalized = String(status || "").trim().toLowerCase();
2927
3439
  if (normalized === "draft") return "draft";
3440
+ if (["blocked", "error", "failed"].includes(normalized)) return "blocked";
2928
3441
  if (["inprogress", "in-progress", "working", "active", "assigned", "running"].includes(normalized)) return "inProgress";
2929
3442
  if (["inreview", "in-review", "review", "pr-open", "pr-review"].includes(normalized)) return "inReview";
2930
3443
  if (["done", "completed", "closed", "merged", "cancelled"].includes(normalized)) return "done";
@@ -3017,6 +3530,87 @@ function resolveActiveWorkspaceExecutionContext() {
3017
3530
  };
3018
3531
  }
3019
3532
 
3533
+ function resolveDefaultRepositoryForWorkspaceContext(workspaceContext = {}) {
3534
+ const configDir = resolveUiConfigDir();
3535
+ if (!configDir) return "";
3536
+ const listed = listManagedWorkspaces(configDir, { repoRoot });
3537
+ const workspaceId = String(workspaceContext?.workspaceId || "").trim().toLowerCase();
3538
+ const workspace =
3539
+ (workspaceId
3540
+ ? listed.find((entry) => String(entry?.id || "").trim().toLowerCase() === workspaceId)
3541
+ : null) ||
3542
+ getActiveManagedWorkspace(configDir) ||
3543
+ listed[0] ||
3544
+ null;
3545
+ if (!workspace) return "";
3546
+ return String(
3547
+ workspace?.activeRepo ||
3548
+ workspace?.repos?.find((repo) => repo?.primary)?.name ||
3549
+ workspace?.repos?.[0]?.name ||
3550
+ "",
3551
+ ).trim();
3552
+ }
3553
+
3554
+ async function resolveDefaultKanbanProjectId(adapter, requestedProjectId = "") {
3555
+ const explicitProjectId = String(requestedProjectId || "").trim();
3556
+ if (explicitProjectId) return explicitProjectId;
3557
+ if (!adapter || typeof adapter.listProjects !== "function") return "";
3558
+ try {
3559
+ const projects = await adapter.listProjects();
3560
+ return String(projects?.[0]?.id || projects?.[0]?.project_id || "").trim();
3561
+ } catch {
3562
+ return "";
3563
+ }
3564
+ }
3565
+
3566
+ function createManualFlowTaskManager(workspaceContext = {}, opts = {}) {
3567
+ return {
3568
+ async createTask(spec = {}) {
3569
+ const title = String(spec?.title || "").trim();
3570
+ if (!title) throw new Error("title is required");
3571
+
3572
+ const adapter = getKanbanAdapter();
3573
+ const projectId = await resolveDefaultKanbanProjectId(
3574
+ adapter,
3575
+ opts?.projectId || spec?.projectId || spec?.project || "",
3576
+ );
3577
+ const labels = normalizeTagsInput(spec?.labels || spec?.tags);
3578
+ const workspace = String(
3579
+ spec?.workspace || opts?.workspaceId || workspaceContext?.workspaceId || "",
3580
+ ).trim();
3581
+ const repository = String(
3582
+ spec?.repository ||
3583
+ spec?.meta?.repository ||
3584
+ opts?.repository ||
3585
+ resolveDefaultRepositoryForWorkspaceContext(workspaceContext),
3586
+ ).trim();
3587
+ const repositories = Array.isArray(spec?.repositories)
3588
+ ? spec.repositories.filter((value) => typeof value === "string" && value.trim())
3589
+ : [];
3590
+ const taskPayload = {
3591
+ title,
3592
+ description: String(spec?.description || ""),
3593
+ status: String(spec?.status || "todo").trim() || "todo",
3594
+ priority: spec?.priority || undefined,
3595
+ ...(workspace ? { workspace } : {}),
3596
+ ...(repository ? { repository } : {}),
3597
+ ...(repositories.length ? { repositories } : {}),
3598
+ ...(labels.length ? { labels, tags: labels } : {}),
3599
+ meta: {
3600
+ ...(workspace ? { workspace } : {}),
3601
+ ...(repository ? { repository } : {}),
3602
+ ...(repositories.length ? { repositories } : {}),
3603
+ ...(labels.length ? { tags: labels } : {}),
3604
+ manualFlowTemplateId: String(opts?.templateId || "").trim() || undefined,
3605
+ ...(spec?.meta && typeof spec.meta === "object" ? spec.meta : {}),
3606
+ },
3607
+ };
3608
+ const createdRaw = await adapter.createTask(projectId, taskPayload);
3609
+ return withTaskMetadataTopLevel(createdRaw);
3610
+ },
3611
+ };
3612
+ }
3613
+
3020
3614
  function resolveWorkspaceContextById(workspaceId = "") {
3021
3615
  const requestedId = String(workspaceId || "").trim().toLowerCase();
3022
3616
  if (!requestedId) return resolveActiveWorkspaceExecutionContext();
@@ -6371,6 +6965,53 @@ function normalizeJsonResponsePayload(payload) {
6371
6965
  return scrubStackTraces(payload);
6372
6966
  }
6373
6967
 
6968
+ function makeJsonSafe(value, options = {}) {
6969
+ const depth = Number.isFinite(options.depth) ? options.depth : 0;
6970
+ const maxDepth = Number.isFinite(options.maxDepth) ? options.maxDepth : 5;
6971
+ const seen = options.seen instanceof WeakSet ? options.seen : new WeakSet();
6972
+
6973
+ if (value == null) return value;
6974
+ if (typeof value === "string" || typeof value === "boolean") return value;
6975
+ if (typeof value === "number") return Number.isFinite(value) ? value : String(value);
6976
+ if (typeof value === "bigint") return String(value);
6977
+ if (typeof value === "undefined" || typeof value === "function" || typeof value === "symbol") {
6978
+ return undefined;
6979
+ }
6980
+ if (value instanceof Date) {
6981
+ return Number.isFinite(value.getTime()) ? value.toISOString() : String(value);
6982
+ }
6983
+ if (value instanceof Error) {
6984
+ const errorValue = {
6985
+ name: String(value.name || "Error"),
6986
+ message: String(value.message || ""),
6987
+ };
6988
+ if (value.code != null) errorValue.code = String(value.code);
6989
+ return errorValue;
6990
+ }
6991
+ if (depth >= maxDepth) return "[Truncated]";
6992
+ if (typeof value !== "object") return String(value);
6993
+ if (seen.has(value)) return "[Circular]";
6994
+
6995
+ seen.add(value);
6996
+ try {
6997
+ if (Array.isArray(value)) {
6998
+ return value.map((entry) => {
6999
+ const normalized = makeJsonSafe(entry, { depth: depth + 1, maxDepth, seen });
7000
+ return normalized === undefined ? null : normalized;
7001
+ });
7002
+ }
7003
+
7004
+ const out = {};
7005
+ for (const [key, entry] of Object.entries(value)) {
7006
+ const normalized = makeJsonSafe(entry, { depth: depth + 1, maxDepth, seen });
7007
+ if (normalized !== undefined) out[key] = normalized;
7008
+ }
7009
+ return out;
7010
+ } finally {
7011
+ seen.delete(value);
7012
+ }
7013
+ }
7014
+
6374
7015
  function extractSafeErrorMessage(payload) {
6375
7016
  if (payload == null) return "Internal server error";
6376
7017
  if (payload instanceof Error) {
@@ -6505,7 +7146,7 @@ function normalizeCanStartResult(result, { override = false } = {}) {
6505
7146
  missingDependencyTaskIds,
6506
7147
  blockingSprintIds,
6507
7148
  blockingEpicIds,
6508
- raw,
7149
+ raw: makeJsonSafe(raw),
6509
7150
  };
6510
7151
  }
6511
7152
 
@@ -6658,6 +7299,15 @@ async function getGlobalDagData() {
6658
7299
  };
6659
7300
  }
6660
7301
 
7302
+ async function organizeDagData(options = {}) {
7303
+ const organizeResult = await callTaskStoreFunction(TASK_STORE_DAG_EXPORTS.organize, [options]);
7304
+ if (!organizeResult.found) return null;
7305
+ return {
7306
+ source: `task-store.${organizeResult.found}`,
7307
+ data: organizeResult.value,
7308
+ };
7309
+ }
7310
+
6661
7311
  function normalizeTaskComments(comments = []) {
6662
7312
  if (!Array.isArray(comments)) return [];
6663
7313
  return comments
@@ -9056,12 +9706,12 @@ function runGit(args, timeoutMs = 10000) {
9056
9706
  return String(res.stdout || "").trim();
9057
9707
  }
9058
9708
 
9059
- async function readJsonBody(req) {
9709
+ async function readJsonBody(req, maxBytes = 1_000_000) {
9060
9710
  return new Promise((resolveBody, rejectBody) => {
9061
9711
  let data = "";
9062
9712
  req.on("data", (chunk) => {
9063
9713
  data += chunk;
9064
- if (data.length > 1_000_000) {
9714
+ if (data.length > maxBytes) {
9065
9715
  rejectBody(new Error("payload too large"));
9066
9716
  req.destroy();
9067
9717
  }
@@ -9279,6 +9929,14 @@ function buildTaskMetadataPatch(input = {}) {
9279
9929
  }
9280
9930
  }
9281
9931
 
9932
+ if (hasOwn(input, "blockedReason")) {
9933
+ const blockedReason = normalizeOptionalStringInput(input?.blockedReason);
9934
+ if (blockedReason) {
9935
+ topLevel.blockedReason = blockedReason;
9936
+ meta.blockedReason = blockedReason;
9937
+ }
9938
+ }
9939
+
9282
9940
  return { topLevel, meta };
9283
9941
  }
9284
9942
 
@@ -9464,13 +10122,105 @@ async function readJsonlTail(filePath, maxLines = 2000) {
9464
10122
  .filter(Boolean);
9465
10123
  }
9466
10124
 
10125
+ function getEntryTimestamp(entry) {
10126
+ const numericCandidates = [
10127
+ entry?.endedAt,
10128
+ entry?.startedAt,
10129
+ ];
10130
+ for (const candidate of numericCandidates) {
10131
+ const parsed = Number(candidate);
10132
+ if (Number.isFinite(parsed) && parsed > 0) return parsed;
10133
+ }
10134
+
10135
+ const isoCandidates = [
10136
+ entry?.timestamp,
10137
+ entry?.recordedAt,
10138
+ entry?.updatedAt,
10139
+ entry?.createdAt,
10140
+ ];
10141
+ for (const candidate of isoCandidates) {
10142
+ const parsed = Date.parse(candidate || "");
10143
+ if (Number.isFinite(parsed)) return parsed;
10144
+ }
10145
+ return Number.NaN;
10146
+ }
10147
+
10148
+ function getEntryDayKey(entry, fallbackTs = Number.NaN) {
10149
+ const isoCandidates = [
10150
+ entry?.timestamp,
10151
+ entry?.recordedAt,
10152
+ entry?.updatedAt,
10153
+ entry?.createdAt,
10154
+ ];
10155
+ for (const candidate of isoCandidates) {
10156
+ const value = String(candidate || "").trim();
10157
+ if (value.length >= 10) return value.slice(0, 10);
10158
+ }
10159
+ const ts = Number.isFinite(fallbackTs) ? fallbackTs : getEntryTimestamp(entry);
10160
+ if (!Number.isFinite(ts)) return "";
10161
+ return new Date(ts).toISOString().slice(0, 10);
10162
+ }
10163
+
9467
10164
  function withinDays(entry, days) {
9468
10165
  if (!days) return true;
9469
- const ts = Date.parse(entry?.timestamp || "");
10166
+ const ts = getEntryTimestamp(entry);
9470
10167
  if (!Number.isFinite(ts)) return true;
9471
10168
  return ts >= Date.now() - days * 24 * 60 * 60 * 1000;
9472
10169
  }
9473
10170
 
10171
+ async function readCompletedSessionEntries(maxLines = 100_000) {
10172
+ const sessionLogPath = resolve(repoRoot, ".cache", "session-accumulator.jsonl");
10173
+ const entries = await readJsonlTail(sessionLogPath, maxLines);
10174
+ return {
10175
+ sessionLogPath,
10176
+ entries: entries.filter((entry) => String(entry?.type || "completed_session") === "completed_session"),
10177
+ };
10178
+ }
10179
+
10180
+ function roundMetric(value, precision = 6) {
10181
+ const numeric = Number(value);
10182
+ if (!Number.isFinite(numeric)) return 0;
10183
+ return Number(numeric.toFixed(precision));
10184
+ }
10185
+
10186
+ const SHREDDING_ESTIMATED_CHARS_PER_TOKEN = 4;
10187
+
10188
+ function estimateTokensFromChars(chars) {
10189
+ const numeric = Number(chars);
10190
+ if (!Number.isFinite(numeric) || numeric <= 0) return 0;
10191
+ return Math.max(0, Math.round(numeric / SHREDDING_ESTIMATED_CHARS_PER_TOKEN));
10192
+ }
10193
+
10194
+ function summarizeObservedSessionCostModel(entries = []) {
10195
+ let totalCostUsd = 0;
10196
+ let totalTokens = 0;
10197
+ let totalInputTokens = 0;
10198
+ let pricedSessions = 0;
10199
+ for (const entry of entries) {
10200
+ const costUsd = numberOrZero(entry?.costUsd);
10201
+ const tokenCount = numberOrZero(entry?.tokenCount);
10202
+ const inputTokens = numberOrZero(entry?.inputTokens);
10203
+ if (costUsd <= 0 || tokenCount <= 0) continue;
10204
+ totalCostUsd += costUsd;
10205
+ totalTokens += tokenCount;
10206
+ totalInputTokens += inputTokens;
10207
+ pricedSessions += 1;
10208
+ }
10209
+ const blendedCostPerToken = totalCostUsd > 0 && totalTokens > 0
10210
+ ? totalCostUsd / totalTokens
10211
+ : null;
10212
+ return {
10213
+ pricedSessions,
10214
+ totalCostUsd: roundMetric(totalCostUsd),
10215
+ totalTokens,
10216
+ totalInputTokens,
10217
+ blendedCostPerToken,
10218
+ blendedCostPerMillionTokensUsd: blendedCostPerToken != null
10219
+ ? roundMetric(blendedCostPerToken * 1_000_000, 4)
10220
+ : null,
10221
+ };
10222
+ }
10223
+
9474
10224
  function summarizeTelemetry(metrics, days) {
9475
10225
  const filtered = metrics.filter((m) => withinDays(m, days));
9476
10226
  if (filtered.length === 0) return null;
@@ -9562,7 +10312,10 @@ function isEffectiveShreddingEvent(event) {
9562
10312
  async function buildUsageAnalytics(days) {
9563
10313
  const logDir = resolveAgentWorkLogDir();
9564
10314
  const streamPath = resolve(logDir, "agent-work-stream.jsonl");
9565
- const events = await readJsonlTail(streamPath, 100_000);
10315
+ const [{ entries: completedSessions }, events] = await Promise.all([
10316
+ readCompletedSessionEntries(100_000),
10317
+ readJsonlTail(streamPath, 100_000),
10318
+ ]);
9566
10319
 
9567
10320
  const cutoff = days ? Date.now() - days * 24 * 60 * 60 * 1000 : 0;
9568
10321
 
@@ -9588,22 +10341,50 @@ async function buildUsageAnalytics(days) {
9588
10341
 
9589
10342
  const allDates = new Set();
9590
10343
 
10344
+ const sessionWindow = completedSessions.filter((session) => {
10345
+ const ts = getEntryTimestamp(session);
10346
+ return !cutoff || (Number.isFinite(ts) && ts >= cutoff);
10347
+ });
10348
+
10349
+ if (sessionWindow.length > 0) {
10350
+ for (const session of sessionWindow) {
10351
+ const ts = getEntryTimestamp(session);
10352
+ if (!Number.isFinite(ts)) continue;
10353
+ if (ts < oldestTs) oldestTs = ts;
10354
+ if (ts > newestTs) newestTs = ts;
10355
+ const day = getEntryDayKey(session, ts);
10356
+ if (day) allDates.add(day);
10357
+
10358
+ agentRuns += 1;
10359
+ const exec = String(session.executor || session.model || "unknown").trim() || "unknown";
10360
+ agents.set(exec, (agents.get(exec) || 0) + 1);
10361
+ if (day) {
10362
+ (dailyAgents[day] = dailyAgents[day] || {})[exec] =
10363
+ (dailyAgents[day][exec] || 0) + 1;
10364
+ }
10365
+ }
10366
+ }
10367
+
10368
+ let streamSessionStarts = 0;
9591
10369
  for (const e of events) {
9592
- const ts = Date.parse(e.timestamp || "");
10370
+ const ts = getEntryTimestamp(e);
9593
10371
  if (!Number.isFinite(ts)) continue;
9594
10372
  if (cutoff && ts < cutoff) continue;
9595
10373
  if (ts < oldestTs) oldestTs = ts;
9596
10374
  if (ts > newestTs) newestTs = ts;
9597
- const day = (e.timestamp || "").slice(0, 10);
10375
+ const day = getEntryDayKey(e, ts);
9598
10376
  if (day) allDates.add(day);
9599
10377
 
9600
10378
  if (e.event_type === "session_start") {
9601
- agentRuns++;
9602
- const exec = e.executor || "unknown";
9603
- agents.set(exec, (agents.get(exec) || 0) + 1);
9604
- if (day) {
9605
- (dailyAgents[day] = dailyAgents[day] || {})[exec] =
9606
- (dailyAgents[day][exec] || 0) + 1;
10379
+ streamSessionStarts += 1;
10380
+ if (sessionWindow.length === 0) {
10381
+ agentRuns++;
10382
+ const exec = e.executor || "unknown";
10383
+ agents.set(exec, (agents.get(exec) || 0) + 1);
10384
+ if (day) {
10385
+ (dailyAgents[day] = dailyAgents[day] || {})[exec] =
10386
+ (dailyAgents[day][exec] || 0) + 1;
10387
+ }
9607
10388
  }
9608
10389
  } else if (e.event_type === "skill_invoke") {
9609
10390
  skillInvocations++;
@@ -9669,6 +10450,11 @@ async function buildUsageAnalytics(days) {
9669
10450
  topSkills,
9670
10451
  topMcpTools,
9671
10452
  trend,
10453
+ diagnostics: {
10454
+ agentRunSource: sessionWindow.length > 0 ? "completed_sessions" : "session_start_events",
10455
+ completedSessions: sessionWindow.length,
10456
+ sessionStarts: streamSessionStarts,
10457
+ },
9672
10458
  };
9673
10459
  }
9674
10460
 
@@ -10391,6 +11177,44 @@ async function handleApi(req, res, url) {
10391
11177
  return;
10392
11178
  }
10393
11179
 
11180
+ if (path === "/api/tasks/export") {
11181
+ try {
11182
+ const adapter = getKanbanAdapter();
11183
+ const tasks = await listAllTasksForApi(adapter);
11184
+ jsonResponse(res, 200, {
11185
+ ok: true,
11186
+ data: buildTaskStateExportPayload(tasks, getKanbanBackendName()),
11187
+ });
11188
+ } catch (err) {
11189
+ jsonResponse(res, 500, { ok: false, error: err.message });
11190
+ }
11191
+ return;
11192
+ }
11193
+
11194
+ if (path === "/api/tasks/import" && req.method === "POST") {
11195
+ try {
11196
+ const backend = getKanbanBackendName();
11197
+ if (backend !== "internal") {
11198
+ jsonResponse(res, 400, {
11199
+ ok: false,
11200
+ error: "Task state import is only supported for the internal backend.",
11201
+ });
11202
+ return;
11203
+ }
11204
+ const body = await readJsonBody(req, 10_000_000);
11205
+ const imported = await importInternalTaskStateSnapshot(body || {});
11206
+ jsonResponse(res, 200, { ok: true, data: imported });
11207
+ broadcastUiEvent(["tasks", "overview"], "invalidate", {
11208
+ reason: "task-state-imported",
11209
+ created: imported.summary?.created || 0,
11210
+ updated: imported.summary?.updated || 0,
11211
+ });
11212
+ } catch (err) {
11213
+ jsonResponse(res, 400, { ok: false, error: err.message });
11214
+ }
11215
+ return;
11216
+ }
11217
+
10394
11218
  if (path === "/api/tasks") {
10395
11219
  const status = url.searchParams.get("status") || "";
10396
11220
  const projectId = url.searchParams.get("project") || "";
@@ -10477,6 +11301,7 @@ async function handleApi(req, res, url) {
10477
11301
  const statusCounts = {
10478
11302
  draft: 0,
10479
11303
  backlog: 0,
11304
+ blocked: 0,
10480
11305
  inProgress: 0,
10481
11306
  inReview: 0,
10482
11307
  done: 0,
@@ -10510,6 +11335,11 @@ async function handleApi(req, res, url) {
10510
11335
  try {
10511
11336
  const taskId =
10512
11337
  url.searchParams.get("taskId") || url.searchParams.get("id") || "";
11338
+ const includeDagParam = String(url.searchParams.get("includeDag") || "").trim().toLowerCase();
11339
+ const includeWorkflowRunsParam = String(url.searchParams.get("includeWorkflowRuns") || "").trim().toLowerCase();
11340
+ const includeDag = !["0", "false", "no"].includes(includeDagParam);
11341
+ const includeWorkflowRuns = !["0", "false", "no"].includes(includeWorkflowRunsParam);
11342
+ const workspaceContext = resolveWorkspaceContextFromRequest(url, { allowAll: false });
10513
11343
  if (!taskId) {
10514
11344
  jsonResponse(res, 400, { ok: false, error: "taskId required" });
10515
11345
  return;
@@ -10519,25 +11349,46 @@ async function handleApi(req, res, url) {
10519
11349
  const enriched = await applySharedStateToTasks(task ? [task] : []);
10520
11350
  let detailTask = enriched[0] || null;
10521
11351
  if (detailTask) {
10522
- const workflowRuns = await collectWorkflowRunsForTask(detailTask.id, url, 40);
10523
- const mergedWorkflowRuns = mergeTaskWorkflowRuns(detailTask.workflowRuns, workflowRuns, 80);
11352
+ const workflowRuns = includeWorkflowRuns
11353
+ ? await collectWorkflowRunsForTask(detailTask.id, url, 40)
11354
+ : [];
11355
+ const mergedWorkflowRuns = includeWorkflowRuns
11356
+ ? mergeTaskWorkflowRuns(detailTask.workflowRuns, workflowRuns, 80)
11357
+ : Array.isArray(detailTask.workflowRuns)
11358
+ ? detailTask.workflowRuns
11359
+ : [];
10524
11360
  detailTask.workflowRuns = mergedWorkflowRuns;
11361
+ const canStart = await evaluateTaskCanStart({
11362
+ taskId: detailTask.id,
11363
+ task: detailTask,
11364
+ reqUrl: url,
11365
+ adapter,
11366
+ });
10525
11367
 
10526
11368
  const sprintId = resolveTaskSprintId(detailTask);
10527
- const sprintDag = sprintId ? await getSprintDagData(sprintId) : null;
10528
- const globalDag = await getGlobalDagData();
11369
+ const sprintDag = includeDag && sprintId ? await getSprintDagData(sprintId) : null;
11370
+ const globalDag = includeDag ? await getGlobalDagData() : null;
11371
+ const blockedContext = buildTaskBlockedContext(detailTask, {
11372
+ canStart,
11373
+ workflowRuns: mergedWorkflowRuns,
11374
+ workspaceDir: workspaceContext?.workspaceDir || repoRoot,
11375
+ });
10529
11376
 
10530
11377
  detailTask.meta = {
10531
11378
  ...(detailTask.meta || {}),
10532
11379
  workflowRuns: mergedWorkflowRuns,
10533
11380
  historyCount: Array.isArray(detailTask.statusHistory) ? detailTask.statusHistory.length : 0,
10534
11381
  timelineCount: Array.isArray(detailTask.timeline) ? detailTask.timeline.length : 0,
11382
+ canStart,
11383
+ blockedContext,
10535
11384
  ...(sprintId ? { sprintId } : {}),
10536
11385
  ...(sprintDag ? { sprintDag: sprintDag.data } : {}),
10537
11386
  ...(globalDag ? { dagOfDags: globalDag.data } : {}),
10538
11387
  };
10539
11388
  if (sprintDag) detailTask.sprintDag = sprintDag.data;
10540
11389
  if (globalDag) detailTask.dagOfDags = globalDag.data;
11390
+ detailTask.canStart = canStart;
11391
+ detailTask.blockedContext = blockedContext;
10541
11392
  detailTask = withTaskRuntimeSnapshot(detailTask);
10542
11393
  }
10543
11394
  jsonResponse(res, 200, { ok: true, data: detailTask });
@@ -11488,6 +12339,41 @@ async function handleApi(req, res, url) {
11488
12339
  }
11489
12340
  return;
11490
12341
  }
12342
+
12343
+ if (path === "/api/tasks/dag/organize" && req.method === "POST") {
12344
+ try {
12345
+ const body = await readJsonBody(req);
12346
+ const sprintId = String(body?.sprintId || body?.sprint || "").trim();
12347
+ const organizeOptions = {
12348
+ ...(sprintId ? { sprintId } : {}),
12349
+ ...(body?.applyDependencySuggestions != null
12350
+ ? { applyDependencySuggestions: Boolean(body.applyDependencySuggestions) }
12351
+ : {}),
12352
+ ...(body?.syncEpicDependencies != null
12353
+ ? { syncEpicDependencies: Boolean(body.syncEpicDependencies) }
12354
+ : {}),
12355
+ };
12356
+ const organized = await organizeDagData(organizeOptions);
12357
+ if (!organized) {
12358
+ jsonResponse(res, 501, { ok: false, error: "DAG organize API is unavailable." });
12359
+ return;
12360
+ }
12361
+ jsonResponse(res, 200, {
12362
+ ok: true,
12363
+ sprintId: sprintId || null,
12364
+ source: organized.source,
12365
+ data: organized.data,
12366
+ suggestions: Array.isArray(organized.data?.suggestions) ? organized.data.suggestions : [],
12367
+ });
12368
+ broadcastUiEvent(["tasks", "overview"], "invalidate", {
12369
+ reason: "dag-organized",
12370
+ sprintId: sprintId || null,
12371
+ });
12372
+ } catch (err) {
12373
+ jsonResponse(res, 500, { ok: false, error: err.message });
12374
+ }
12375
+ return;
12376
+ }
11491
12377
  if (path === "/api/tasks/attachments/upload" && req.method === "POST") {
11492
12378
  try {
11493
12379
  const { fields, files } = await readMultipartForm(req);
@@ -11825,11 +12711,20 @@ async function handleApi(req, res, url) {
11825
12711
  const tagsProvided = hasOwn(body, "tags");
11826
12712
  const tags = tagsProvided ? normalizeTagsInput(body?.tags) : undefined;
11827
12713
  const draftProvided = hasOwn(body, "draft");
12714
+ const blockedReasonProvided = hasOwn(body, "blockedReason");
12715
+ const blockedReason = blockedReasonProvided
12716
+ ? String(body?.blockedReason || "").trim() || null
12717
+ : undefined;
11828
12718
  const baseBranchProvided = hasOwn(body, "baseBranch") || hasOwn(body, "base_branch");
11829
12719
  const baseBranch = baseBranchProvided
11830
12720
  ? normalizeBranchInput(body?.baseBranch ?? body?.base_branch)
11831
12721
  : undefined;
11832
12722
  const metadataPatch = buildTaskMetadataPatch(body || {});
12723
+ const requestedStatus = normalizeTaskStatusKey(body?.status);
12724
+ const clearsBlockedState = requestedStatus === "todo";
12725
+ const nextMeta = (Object.keys(metadataPatch.meta).length > 0 || clearsBlockedState)
12726
+ ? buildTaskMetaPatch(previousTask?.meta, metadataPatch.meta, { clearBlockedState: clearsBlockedState })
12727
+ : null;
11833
12728
  const patch = {
11834
12729
  status: body?.status,
11835
12730
  title: body?.title,
@@ -11840,16 +12735,13 @@ async function handleApi(req, res, url) {
11840
12735
  repositories: Array.isArray(body?.repositories) ? body.repositories : undefined,
11841
12736
  ...(tagsProvided ? { tags } : {}),
11842
12737
  ...(draftProvided ? { draft: Boolean(body?.draft) } : {}),
12738
+ ...(clearsBlockedState
12739
+ ? { cooldownUntil: null, blockedReason: null }
12740
+ : (blockedReasonProvided ? { blockedReason } : {})),
12741
+ ...(clearsBlockedState ? { replaceMeta: true } : {}),
11843
12742
  ...(baseBranchProvided ? { baseBranch } : {}),
11844
12743
  ...metadataPatch.topLevel,
11845
- ...(Object.keys(metadataPatch.meta).length > 0
11846
- ? {
11847
- meta: {
11848
- ...(previousTask?.meta && typeof previousTask.meta === "object" ? previousTask.meta : {}),
11849
- ...metadataPatch.meta,
11850
- },
11851
- }
11852
- : {}),
12744
+ ...(nextMeta ? { meta: nextMeta } : {}),
11853
12745
  };
11854
12746
  if (!hasTaskPatchValues(patch) && !baseBranchProvided && !draftProvided && !tagsProvided) {
11855
12747
  jsonResponse(res, 400, {
@@ -11960,11 +12852,20 @@ async function handleApi(req, res, url) {
11960
12852
  const tagsProvided = hasOwn(body, "tags");
11961
12853
  const tags = tagsProvided ? normalizeTagsInput(body?.tags) : undefined;
11962
12854
  const draftProvided = hasOwn(body, "draft");
12855
+ const blockedReasonProvided = hasOwn(body, "blockedReason");
12856
+ const blockedReason = blockedReasonProvided
12857
+ ? String(body?.blockedReason || "").trim() || null
12858
+ : undefined;
11963
12859
  const baseBranchProvided = hasOwn(body, "baseBranch") || hasOwn(body, "base_branch");
11964
12860
  const baseBranch = baseBranchProvided
11965
12861
  ? normalizeBranchInput(body?.baseBranch ?? body?.base_branch)
11966
12862
  : undefined;
11967
12863
  const metadataPatch = buildTaskMetadataPatch(body || {});
12864
+ const requestedStatus = normalizeTaskStatusKey(body?.status);
12865
+ const clearsBlockedState = requestedStatus === "todo";
12866
+ const nextMeta = (Object.keys(metadataPatch.meta).length > 0 || clearsBlockedState)
12867
+ ? buildTaskMetaPatch(previousTask?.meta, metadataPatch.meta, { clearBlockedState: clearsBlockedState })
12868
+ : null;
11968
12869
  const patch = {
11969
12870
  title: body?.title,
11970
12871
  description: body?.description,
@@ -11975,16 +12876,13 @@ async function handleApi(req, res, url) {
11975
12876
  repositories: Array.isArray(body?.repositories) ? body.repositories : undefined,
11976
12877
  ...(tagsProvided ? { tags } : {}),
11977
12878
  ...(draftProvided ? { draft: Boolean(body?.draft) } : {}),
12879
+ ...(clearsBlockedState
12880
+ ? { cooldownUntil: null, blockedReason: null }
12881
+ : (blockedReasonProvided ? { blockedReason } : {})),
12882
+ ...(clearsBlockedState ? { replaceMeta: true } : {}),
11978
12883
  ...(baseBranchProvided ? { baseBranch } : {}),
11979
12884
  ...metadataPatch.topLevel,
11980
- ...(Object.keys(metadataPatch.meta).length > 0
11981
- ? {
11982
- meta: {
11983
- ...(previousTask?.meta && typeof previousTask.meta === "object" ? previousTask.meta : {}),
11984
- ...metadataPatch.meta,
11985
- },
11986
- }
11987
- : {}),
12885
+ ...(nextMeta ? { meta: nextMeta } : {}),
11988
12886
  };
11989
12887
  if (!hasTaskPatchValues(patch) && !baseBranchProvided && !draftProvided && !tagsProvided) {
11990
12888
  jsonResponse(res, 400, {
@@ -12157,6 +13055,10 @@ async function handleApi(req, res, url) {
12157
13055
  const adapter = getKanbanAdapter();
12158
13056
  const tags = normalizeTagsInput(body?.tags);
12159
13057
  const wantsDraft = Boolean(body?.draft) || body?.status === "draft";
13058
+ const blockedReasonProvided = hasOwn(body, "blockedReason");
13059
+ const blockedReason = blockedReasonProvided
13060
+ ? String(body?.blockedReason || "").trim() || null
13061
+ : undefined;
12160
13062
  const baseBranch = normalizeBranchInput(body?.baseBranch ?? body?.base_branch);
12161
13063
  const activeWorkspace = getActiveManagedWorkspace(resolveUiConfigDir());
12162
13064
  const defaultRepository =
@@ -12175,6 +13077,7 @@ async function handleApi(req, res, url) {
12175
13077
  description: body?.description || "",
12176
13078
  status: body?.status || (wantsDraft ? "draft" : "todo"),
12177
13079
  priority: body?.priority || undefined,
13080
+ ...(blockedReasonProvided ? { blockedReason } : {}),
12178
13081
  ...(workspace ? { workspace } : {}),
12179
13082
  ...(repository ? { repository } : {}),
12180
13083
  ...(repositories.length ? { repositories } : {}),
@@ -13704,7 +14607,10 @@ async function handleApi(req, res, url) {
13704
14607
  resolveAgentWorkLogDir(),
13705
14608
  "shredding-stats.jsonl",
13706
14609
  );
13707
- const raw = await readJsonlTail(shreddingPath, 10_000);
14610
+ const [{ entries: completedSessions }, raw] = await Promise.all([
14611
+ readCompletedSessionEntries(100_000),
14612
+ readJsonlTail(shreddingPath, 10_000),
14613
+ ]);
13708
14614
  const inWindow = raw.filter((e) => withinDays(e, days));
13709
14615
  let excludedSynthetic = 0;
13710
14616
  let excludedNoop = 0;
@@ -13729,7 +14635,14 @@ async function handleApi(req, res, url) {
13729
14635
  let totalOriginalChars = 0;
13730
14636
  let totalCompressedChars = 0;
13731
14637
  let totalSavedChars = 0;
14638
+ let totalOriginalTokensEstimated = 0;
14639
+ let totalCompressedTokensEstimated = 0;
14640
+ let totalSavedTokensEstimated = 0;
13732
14641
  const dailySaved = {};
14642
+ const dailyOriginal = {};
14643
+ const dailyCompressed = {};
14644
+ const dailySavedTokensEstimated = {};
14645
+ const dailyCostSavedUsd = {};
13733
14646
  const dailyCounts = {};
13734
14647
  const agentCounts = {};
13735
14648
  const stageCounts = {};
@@ -13740,17 +14653,37 @@ async function handleApi(req, res, url) {
13740
14653
  let liveOriginalChars = 0;
13741
14654
  let liveCompressedChars = 0;
13742
14655
  let liveSavedChars = 0;
14656
+ let liveSavedTokensEstimated = 0;
14657
+ const sessionCostModel = summarizeObservedSessionCostModel(
14658
+ completedSessions.filter((entry) => withinDays(entry, days)),
14659
+ );
14660
+ const blendedCostPerToken = sessionCostModel.blendedCostPerToken;
13743
14661
 
13744
14662
  for (const e of events) {
13745
14663
  const originalChars = numberOrZero(e.originalChars);
13746
14664
  const compressedChars = numberOrZero(e.compressedChars);
13747
14665
  const savedChars = numberOrZero(e.savedChars);
14666
+ const originalTokensEstimated = estimateTokensFromChars(originalChars);
14667
+ const compressedTokensEstimated = estimateTokensFromChars(compressedChars);
14668
+ const savedTokensEstimated = estimateTokensFromChars(savedChars);
14669
+ const estimatedCostSavedUsd = blendedCostPerToken != null
14670
+ ? roundMetric(savedTokensEstimated * blendedCostPerToken)
14671
+ : null;
13748
14672
  totalOriginalChars += originalChars;
13749
14673
  totalCompressedChars += compressedChars;
13750
14674
  totalSavedChars += savedChars;
13751
- const day = (e.timestamp || "").slice(0, 10);
14675
+ totalOriginalTokensEstimated += originalTokensEstimated;
14676
+ totalCompressedTokensEstimated += compressedTokensEstimated;
14677
+ totalSavedTokensEstimated += savedTokensEstimated;
14678
+ const day = getEntryDayKey(e);
13752
14679
  if (day) {
14680
+ dailyOriginal[day] = (dailyOriginal[day] || 0) + originalChars;
14681
+ dailyCompressed[day] = (dailyCompressed[day] || 0) + compressedChars;
13753
14682
  dailySaved[day] = (dailySaved[day] || 0) + savedChars;
14683
+ dailySavedTokensEstimated[day] = (dailySavedTokensEstimated[day] || 0) + savedTokensEstimated;
14684
+ if (estimatedCostSavedUsd != null) {
14685
+ dailyCostSavedUsd[day] = roundMetric((dailyCostSavedUsd[day] || 0) + estimatedCostSavedUsd);
14686
+ }
13754
14687
  dailyCounts[day] = (dailyCounts[day] || 0) + 1;
13755
14688
  }
13756
14689
  const agent = normalizeShreddingAgentType(e.agentType);
@@ -13764,6 +14697,7 @@ async function handleApi(req, res, url) {
13764
14697
  liveOriginalChars += originalChars;
13765
14698
  liveCompressedChars += compressedChars;
13766
14699
  liveSavedChars += savedChars;
14700
+ liveSavedTokensEstimated += savedTokensEstimated;
13767
14701
  const compactionFamily = String(e.compactionFamily || "unknown").trim().toLowerCase() || "unknown";
13768
14702
  const commandFamily = String(e.commandFamily || "unknown").trim().toLowerCase() || "unknown";
13769
14703
  compactionFamilyCounts[compactionFamily] = (compactionFamilyCounts[compactionFamily] || 0) + 1;
@@ -13798,12 +14732,27 @@ async function handleApi(req, res, url) {
13798
14732
  savedPct: numberOrZero(e.savedPct),
13799
14733
  originalChars: numberOrZero(e.originalChars),
13800
14734
  compressedChars: numberOrZero(e.compressedChars),
14735
+ estimatedSavedTokens: estimateTokensFromChars(numberOrZero(e.savedChars)),
14736
+ estimatedCostSavedUsd: blendedCostPerToken != null
14737
+ ? roundMetric(estimateTokensFromChars(numberOrZero(e.savedChars)) * blendedCostPerToken)
14738
+ : null,
13801
14739
  agentType: normalizeShreddingAgentType(e.agentType),
13802
14740
  attemptId: e.attemptId || null,
13803
14741
  stage: String(e.stage || "session_total").trim().toLowerCase() || "session_total",
13804
14742
  compactionFamily: String(e.compactionFamily || "").trim().toLowerCase() || null,
13805
14743
  commandFamily: String(e.commandFamily || "").trim().toLowerCase() || null,
13806
14744
  }));
14745
+ const dailyReductionPct = {};
14746
+ for (const day of Object.keys(dailyOriginal)) {
14747
+ const originalChars = numberOrZero(dailyOriginal[day]);
14748
+ const savedChars = numberOrZero(dailySaved[day]);
14749
+ dailyReductionPct[day] = originalChars > 0
14750
+ ? Math.round((savedChars / originalChars) * 100)
14751
+ : 0;
14752
+ }
14753
+ const totalEstimatedCostSavedUsd = blendedCostPerToken != null
14754
+ ? roundMetric(totalSavedTokensEstimated * blendedCostPerToken)
14755
+ : null;
13807
14756
 
13808
14757
  jsonResponse(res, 200, {
13809
14758
  ok: true,
@@ -13814,17 +14763,35 @@ async function handleApi(req, res, url) {
13814
14763
  totalSavedChars,
13815
14764
  avgSavedPct,
13816
14765
  sortedDates,
14766
+ dailyOriginal,
14767
+ dailyCompressed,
13817
14768
  dailySaved,
14769
+ dailySavedTokensEstimated,
14770
+ dailyCostSavedUsd,
14771
+ dailyReductionPct,
13818
14772
  dailyCounts,
13819
14773
  topAgents,
13820
14774
  stageCounts,
13821
14775
  topCompactionFamilies,
13822
14776
  topCommandFamilies,
14777
+ totals: {
14778
+ originalTokensEstimated: totalOriginalTokensEstimated,
14779
+ compressedTokensEstimated: totalCompressedTokensEstimated,
14780
+ savedTokensEstimated: totalSavedTokensEstimated,
14781
+ estimatedCostSavedUsd: totalEstimatedCostSavedUsd,
14782
+ },
14783
+ estimation: {
14784
+ charsPerToken: SHREDDING_ESTIMATED_CHARS_PER_TOKEN,
14785
+ costModel: blendedCostPerToken != null ? "observed_blended_session_cost" : "unavailable",
14786
+ blendedCostPerMillionTokensUsd: sessionCostModel.blendedCostPerMillionTokensUsd,
14787
+ pricedSessions: sessionCostModel.pricedSessions,
14788
+ },
13823
14789
  liveCompaction: {
13824
14790
  totalEvents: liveTotalEvents,
13825
14791
  totalOriginalChars: liveOriginalChars,
13826
14792
  totalCompressedChars: liveCompressedChars,
13827
14793
  totalSavedChars: liveSavedChars,
14794
+ savedTokensEstimated: liveSavedTokensEstimated,
13828
14795
  avgSavedPct: liveAvgSavedPct,
13829
14796
  },
13830
14797
  recentEvents,
@@ -13835,6 +14802,7 @@ async function handleApi(req, res, url) {
13835
14802
  unknownAttribution,
13836
14803
  includeSynthetic,
13837
14804
  includeNoop,
14805
+ pricedSessions: sessionCostModel.pricedSessions,
13838
14806
  },
13839
14807
  },
13840
14808
  });
@@ -14575,6 +15543,9 @@ async function handleApi(req, res, url) {
14575
15543
  templateName: template.name,
14576
15544
  workflowId,
14577
15545
  variables: { ...template.variables, ...userVars },
15546
+ workspaceId,
15547
+ repository: executeInput.repository || executeInput._targetRepo || null,
15548
+ targetRepo: executeInput._targetRepo || executeInput.repository || null,
14578
15549
  dispatchedAt,
14579
15550
  });
14580
15551
  return;
@@ -14587,6 +15558,9 @@ async function handleApi(req, res, url) {
14587
15558
  templateId,
14588
15559
  templateName: template.name,
14589
15560
  workflowId,
15561
+ workspaceId,
15562
+ repository: executeInput.repository || executeInput._targetRepo || null,
15563
+ targetRepo: executeInput._targetRepo || executeInput.repository || null,
14590
15564
  result,
14591
15565
  });
14592
15566
  } catch (err) {
@@ -14646,6 +15620,31 @@ async function handleApi(req, res, url) {
14646
15620
  return;
14647
15621
  }
14648
15622
 
15623
+ if (path === "/api/workflows/reflow-template-layouts" && req.method === "POST") {
15624
+ try {
15625
+ const wfCtx = await getWorkflowRequestContext(url);
15626
+ if (!wfCtx.ok) {
15627
+ jsonResponse(res, wfCtx.status, { ok: false, error: wfCtx.error });
15628
+ return;
15629
+ }
15630
+ if (typeof _wfTemplates?.relayoutInstalledTemplateWorkflows !== "function") {
15631
+ jsonResponse(res, 503, { ok: false, error: "Template relayout service unavailable" });
15632
+ return;
15633
+ }
15634
+ const body = await readJsonBody(req).catch(() => ({}));
15635
+ const result = _wfTemplates.relayoutInstalledTemplateWorkflows(wfCtx.engine, {
15636
+ workflowIds: body?.workflowIds || body?.workflowId,
15637
+ });
15638
+ const workflows = result.updatedWorkflowIds
15639
+ .map((workflowId) => wfCtx.engine.get(workflowId))
15640
+ .filter(Boolean);
15641
+ jsonResponse(res, 200, { ok: true, result, workflows });
15642
+ } catch (err) {
15643
+ jsonResponse(res, 500, { ok: false, error: err.message });
15644
+ }
15645
+ return;
15646
+ }
15647
+
14649
15648
  if (path === "/api/workflows/template-updates") {
14650
15649
  try {
14651
15650
  const wfCtx = await getWorkflowRequestContext(url);
@@ -14953,6 +15952,23 @@ async function handleApi(req, res, url) {
14953
15952
  return;
14954
15953
  }
14955
15954
 
15955
+ if (action === "reflow-layout" && req.method === "POST") {
15956
+ if (typeof _wfTemplates?.relayoutInstalledTemplateWorkflows !== "function") {
15957
+ jsonResponse(res, 503, { ok: false, error: "Template relayout service unavailable" });
15958
+ return;
15959
+ }
15960
+ const result = _wfTemplates.relayoutInstalledTemplateWorkflows(engine, {
15961
+ workflowId,
15962
+ });
15963
+ const workflow = engine.get(workflowId);
15964
+ if (!workflow) {
15965
+ jsonResponse(res, 404, { ok: false, error: "Workflow not found after relayout" });
15966
+ return;
15967
+ }
15968
+ jsonResponse(res, 200, { ok: true, workflow, result });
15969
+ return;
15970
+ }
15971
+
14956
15972
  if (action === "runs") {
14957
15973
  const rawOffset = Number(url.searchParams.get("offset"));
14958
15974
  const rawLimit = Number(url.searchParams.get("limit"));
@@ -15074,7 +16090,7 @@ async function handleApi(req, res, url) {
15074
16090
  if (path === "/api/manual-flows/execute" && req.method === "POST") {
15075
16091
  try {
15076
16092
  const body = await readJsonBody(req);
15077
- const { templateId, formValues } = body || {};
16093
+ const { templateId, formValues, executionContext } = body || {};
15078
16094
  if (!templateId) {
15079
16095
  jsonResponse(res, 400, { ok: false, error: "templateId is required" });
15080
16096
  return;
@@ -15082,7 +16098,40 @@ async function handleApi(req, res, url) {
15082
16098
  const mf = await import("../workflow/manual-flows.mjs");
15083
16099
  const ctx = resolveActiveWorkspaceExecutionContext();
15084
16100
  const wfCtx = await getWorkflowRequestContext(url);
15085
- const flowContext = wfCtx?.ok ? { engine: wfCtx.engine } : {};
16101
+ const repository = String(
16102
+ executionContext?.repository ||
16103
+ executionContext?.targetRepo ||
16104
+ formValues?._targetRepo ||
16105
+ resolveDefaultRepositoryForWorkspaceContext(ctx),
16106
+ ).trim();
16107
+ const workspaceId = String(
16108
+ executionContext?.workspaceId ||
16109
+ executionContext?.workspace ||
16110
+ ctx.workspaceId ||
16111
+ "",
16112
+ ).trim();
16113
+ const projectId = String(
16114
+ executionContext?.projectId ||
16115
+ executionContext?.project ||
16116
+ body?.project ||
16117
+ "",
16118
+ ).trim();
16119
+ const flowContext = {
16120
+ ...(wfCtx?.ok ? { engine: wfCtx.engine } : {}),
16121
+ taskManager: createManualFlowTaskManager(ctx, {
16122
+ repository,
16123
+ workspaceId,
16124
+ projectId,
16125
+ templateId,
16126
+ }),
16127
+ runMetadata: {
16128
+ repository,
16129
+ workspaceId,
16130
+ workspaceDir: ctx.workspaceDir,
16131
+ projectId,
16132
+ triggerSource: "manual-ui",
16133
+ },
16134
+ };
15086
16135
  const run = await mf.executeFlow(templateId, formValues || {}, ctx.workspaceDir, flowContext);
15087
16136
  jsonResponse(res, 200, { ok: true, run });
15088
16137
  } catch (err) {
@@ -15606,12 +16655,26 @@ async function handleApi(req, res, url) {
15606
16655
  jsonResponse(res, 404, { ok: false, error: "Task not found." });
15607
16656
  return;
15608
16657
  }
15609
- if (typeof adapter.updateTask === "function") {
15610
- await adapter.updateTask(taskId, { status: "todo" });
15611
- } else if (typeof adapter.updateTaskStatus === "function") {
15612
- await adapter.updateTaskStatus(taskId, "todo");
16658
+ let nextTask = unblockInternalTask(taskId, {
16659
+ status: "todo",
16660
+ source: "manual-retry",
16661
+ });
16662
+ if (!nextTask) {
16663
+ if (typeof adapter.updateTask === "function") {
16664
+ await adapter.updateTask(taskId, {
16665
+ status: "todo",
16666
+ cooldownUntil: null,
16667
+ blockedReason: null,
16668
+ meta: task?.meta && typeof task.meta === "object"
16669
+ ? Object.fromEntries(Object.entries(task.meta).filter(([key]) => key !== "autoRecovery"))
16670
+ : task?.meta,
16671
+ });
16672
+ } else if (typeof adapter.updateTaskStatus === "function") {
16673
+ await adapter.updateTaskStatus(taskId, "todo");
16674
+ }
16675
+ nextTask = await adapter.getTask(taskId);
15613
16676
  }
15614
- executor.executeTask(task).catch((error) => {
16677
+ executor.executeTask(nextTask || { ...task, status: "todo" }).catch((error) => {
15615
16678
  console.warn(
15616
16679
  `[telegram-ui] failed to retry task ${taskId}: ${error.message}`,
15617
16680
  );
@@ -15636,6 +16699,58 @@ async function handleApi(req, res, url) {
15636
16699
  return;
15637
16700
  }
15638
16701
 
16702
+ if (path === "/api/tasks/unblock") {
16703
+ if (req.method !== "POST") {
16704
+ res.setHeader("Allow", "POST");
16705
+ jsonResponse(res, 405, { ok: false, error: "Method Not Allowed" });
16706
+ return;
16707
+ }
16708
+ try {
16709
+ const body = await readJsonBody(req);
16710
+ const taskId = body?.taskId || body?.id;
16711
+ const targetStatus = String(body?.status || "todo").trim().toLowerCase() || "todo";
16712
+ if (!taskId) {
16713
+ jsonResponse(res, 400, { ok: false, error: "taskId is required" });
16714
+ return;
16715
+ }
16716
+ const adapter = getKanbanAdapter();
16717
+ const task = await adapter.getTask(taskId);
16718
+ if (!task) {
16719
+ jsonResponse(res, 404, { ok: false, error: "Task not found." });
16720
+ return;
16721
+ }
16722
+ let updatedTask = unblockInternalTask(taskId, {
16723
+ status: targetStatus,
16724
+ source: "api.tasks.unblock",
16725
+ });
16726
+ if (!updatedTask) {
16727
+ const nextMeta = task?.meta && typeof task.meta === "object"
16728
+ ? Object.fromEntries(Object.entries(task.meta).filter(([key]) => key !== "autoRecovery"))
16729
+ : task?.meta;
16730
+ if (typeof adapter.updateTask === "function") {
16731
+ await adapter.updateTask(taskId, {
16732
+ status: targetStatus,
16733
+ cooldownUntil: null,
16734
+ blockedReason: null,
16735
+ meta: nextMeta,
16736
+ });
16737
+ } else if (typeof adapter.updateTaskStatus === "function") {
16738
+ await adapter.updateTaskStatus(taskId, targetStatus);
16739
+ }
16740
+ updatedTask = await adapter.getTask(taskId);
16741
+ }
16742
+ jsonResponse(res, 200, { ok: true, taskId, data: updatedTask || null });
16743
+ broadcastUiEvent(
16744
+ ["tasks", "overview", "executor", "agents"],
16745
+ "invalidate",
16746
+ { reason: "task-unblocked", taskId },
16747
+ );
16748
+ } catch (err) {
16749
+ jsonResponse(res, 500, { ok: false, error: err.message });
16750
+ }
16751
+ return;
16752
+ }
16753
+
15639
16754
  // ── GET /api/retry-queue ───────────────────────────────────────────
15640
16755
  if (path === "/api/retry-queue" && req.method === "GET") {
15641
16756
  try {