@tea-agent/loop-agent 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +3 -2
  3. package/dist/application/dag/generate-task-dag.js +15 -0
  4. package/dist/application/dag/run-dag.js +10 -0
  5. package/dist/application/dag/validate-dag.js +11 -0
  6. package/dist/commands/init.js +74 -7
  7. package/dist/shared/package-metadata.js +135 -0
  8. package/dist/task/config-types.js +1 -0
  9. package/dist/worker/cli.js +3 -1
  10. package/dist/worker/observability/event-history.js +216 -0
  11. package/dist/worker/observability/read-model.js +312 -83
  12. package/dist/worker/observe/paths.js +17 -0
  13. package/dist/worker/observe/routes.js +165 -21
  14. package/dist/worker/observe/server.js +59 -1
  15. package/dist/worker/observe/static/api.js +27 -0
  16. package/dist/worker/observe/static/app.js +120 -2598
  17. package/dist/worker/observe/static/constants.js +148 -0
  18. package/dist/worker/observe/static/copy.js +67 -0
  19. package/dist/worker/observe/static/dag-helpers.js +172 -0
  20. package/dist/worker/observe/static/dag-model.js +72 -0
  21. package/dist/worker/observe/static/dom.js +61 -0
  22. package/dist/worker/observe/static/format-pool.js +67 -0
  23. package/dist/worker/observe/static/format.js +292 -0
  24. package/dist/worker/observe/static/index.html +300 -82
  25. package/dist/worker/observe/static/kpi.js +94 -0
  26. package/dist/worker/observe/static/relations.js +128 -0
  27. package/dist/worker/observe/static/router.js +85 -0
  28. package/dist/worker/observe/static/run-processing.js +148 -0
  29. package/dist/worker/observe/static/shell-chrome.js +68 -0
  30. package/dist/worker/observe/static/state.js +253 -0
  31. package/dist/worker/observe/static/styles.css +1719 -495
  32. package/dist/worker/observe/static/views/batch.js +226 -0
  33. package/dist/worker/observe/static/views/dag-graph.js +172 -0
  34. package/dist/worker/observe/static/views/dag-inspector.js +477 -0
  35. package/dist/worker/observe/static/views/dag.js +362 -0
  36. package/dist/worker/observe/static/views/dashboard.js +442 -0
  37. package/dist/worker/observe/static/views/failures.js +143 -0
  38. package/dist/worker/observe/static/views/feature.js +453 -0
  39. package/dist/worker/observe/static/views/pool.js +347 -0
  40. package/dist/worker/observe/static/views/run.js +453 -0
  41. package/dist/worker/observe/static/views/session-timeline.js +205 -0
  42. package/dist/worker/observe/static/views/shell.js +7 -0
  43. package/dist/worker/observe/static/views/task.js +260 -0
  44. package/dist/worker/observe/static/views/timeline.js +163 -0
  45. package/dist/workflows/dag/controller-identity.js +104 -0
  46. package/dist/workflows/dag/init-hybrid.js +396 -3
  47. package/dist/workflows/dag/node-execution.js +123 -29
  48. package/dist/workflows/dag/repair-artifact.js +91 -0
  49. package/dist/workflows/dag/report.js +50 -0
  50. package/dist/workflows/dag/retry-policy.js +138 -0
  51. package/dist/workflows/dag/runner.js +32 -0
  52. package/dist/workflows/dag/runtime-contract.js +87 -0
  53. package/dist/workflows/dag/skill-snapshot.js +2 -0
  54. package/dist/workflows/dag/types.js +44 -1
  55. package/dist/workflows/dag/validate.js +68 -4
  56. package/docs/agent-dag-runner.md +26 -1
  57. package/docs/architecture/dag-execution.md +6 -0
  58. package/docs/architecture/evolution.md +4 -3
  59. package/docs/architecture/facts-and-state.md +1 -1
  60. package/docs/design/README.md +4 -3
  61. package/docs/exec-plans/active/README.md +1 -3
  62. package/docs/exec-plans/completed/README.md +11 -0
  63. package/docs/feature-workflow.md +28 -0
  64. package/docs/progress/README.md +18 -0
  65. package/docs/reports/README.md +8 -2
  66. package/docs/templates/agent-dag-report.schema.json +17 -0
  67. package/docs/templates/agent-dag.schema.json +69 -1
  68. package/docs/templates/agent-dag.supervised-implementation.json +8 -2
  69. package/docs/templates/backend-test-dag.generate-pytest.prompt.md +139 -0
  70. package/docs/templates/backend-test-dag.json +276 -0
  71. package/docs/templates/backend-test-dag.retrospect.prompt.md +125 -0
  72. package/docs/templates/backend-test-dag.review-cases.prompt.md +81 -0
  73. package/package.json +1 -1
  74. package/skills/loop-agent/references/command-reference.md +1 -0
  75. package/skills/loop-agent/references/hybrid-dag.md +22 -3
  76. package/skills/loop-agent/references/verification-and-failure-handling.md +6 -0
@@ -0,0 +1,148 @@
1
+ /** Shared constants for Observe UI (R5). */
2
+
3
+ export const ACTIVE_DETAIL_POLL_MS = 2000;
4
+ export const HIDDEN_POLL_MS = 30000;
5
+ export const DASHBOARD_POLL_MS = 5000;
6
+ export const DAG_INSPECTOR_DEFAULT_WIDTH = 440;
7
+ export const DAG_INSPECTOR_MIN_WIDTH = 360;
8
+ export const DAG_INSPECTOR_MAX_WIDTH = 680;
9
+ export const DAG_INSPECTOR_RIGHT_OFFSET = 24;
10
+ export const DAG_INSPECTOR_VIEWPORT_MARGIN = 48;
11
+ export const DAG_INSPECTOR_KEYBOARD_STEP = 24;
12
+ /** Distance from bottom (px) at which output regions auto-follow latest content. */
13
+ export const OUTPUT_FOLLOW_LATEST_PX = 48;
14
+
15
+ export const UI_TEXT = {
16
+ dashboard: "总览",
17
+ dagRuns: "DAG 运行",
18
+ failures: "异常 Task",
19
+ batchDetail: "批次详情",
20
+ runDetail: "运行详情",
21
+ dagDetail: "DAG 详情",
22
+ activeDags: "进行中的 DAG",
23
+ recentDags: "最近 DAG 运行",
24
+ batches: "Worker 批次",
25
+ noActiveDags: "当前没有进行中的 DAG,启动后将出现在这里。",
26
+ noRecentDags: "暂无 DAG 运行记录。",
27
+ noBatches: "暂无 Worker 批次,运行后将出现在这里。",
28
+ noNodes: "暂无节点。",
29
+ noOutput: "暂无节点输出。",
30
+ noSessionEvents: "该节点暂无过程日志",
31
+ noArtifacts: "暂无产物记录。",
32
+ noActiveCommand: "当前没有正在执行的命令。",
33
+ noFailures: "当前没有异常 Task。",
34
+ enterDetail: "查看详情 →",
35
+ repoRoot: "仓库根目录",
36
+ generatedAt: "生成时间",
37
+ refreshStatus: "更新",
38
+ failureInbox: "异常 Task",
39
+ nearRealtime: "轮询刷新",
40
+ };
41
+
42
+ export const STATUS_LABELS = {
43
+ running: "运行中",
44
+ started: "已启动",
45
+ pending: "等待中",
46
+ queued: "排队中",
47
+ succeeded: "成功",
48
+ finished: "已完成",
49
+ completed: "已完成",
50
+ done: "完成",
51
+ failed: "失败",
52
+ error: "错误",
53
+ partial_failed: "部分失败",
54
+ partialfailed: "部分失败",
55
+ blocked: "阻塞",
56
+ stale: "心跳失联",
57
+ reused: "复用",
58
+ unknown: "未知",
59
+ cancelled: "已取消",
60
+ skipped: "已跳过",
61
+ finished_upper: "已完成",
62
+ error_upper: "错误",
63
+ skipped_upper: "已跳过",
64
+ };
65
+
66
+ export const LIVENESS_LABELS = {
67
+ active: "执行器正常",
68
+ "node-quiet": "执行器正常,节点长时间无活动",
69
+ quiet: "无输出(存活)",
70
+ stale: "执行器心跳中断",
71
+ "timeout-risk": "即将超时",
72
+ orphaned: "执行器已退出",
73
+ "unknown-host": "无法判断(在其他主机运行)",
74
+ unknown: "无法判断(无心跳记录)",
75
+ };
76
+
77
+ export const DAG_EFFECTIVE_STATUS_LABELS = {
78
+ pending: "等待执行",
79
+ running: "正在执行",
80
+ "running-quiet": "运行可疑",
81
+ paused: "已暂停",
82
+ interrupted: "执行已中断",
83
+ "remote-unknown": "远端状态未知",
84
+ finished: "已完成",
85
+ failed: "执行失败",
86
+ superseded: "任务已另行完成",
87
+ abandoned: "已放弃",
88
+ unknown: "状态未知",
89
+ };
90
+
91
+ export const STEP_LABELS = {
92
+ preflight: "预检",
93
+ materialize: "准备 Task 文件",
94
+ "dag-run-task": "生成 DAG",
95
+ "pi-model-override": "覆盖 Pi 模型",
96
+ "dag-validate": "校验 DAG",
97
+ "run-dag": "执行 DAG",
98
+ "report-decision": "上报决策结果",
99
+ "dag-doctor": "DAG 诊断",
100
+ "closeout-draft": "失败收尾草稿",
101
+ "promote-run": "提升运行",
102
+ "closeout-task": "Task 收尾",
103
+ };
104
+
105
+ export const ARTIFACT_LABELS = {
106
+ stdoutPath: "运行输出",
107
+ stderrPath: "错误输出",
108
+ resultPath: "结果",
109
+ runRecordPath: "运行记录",
110
+ reportMarkdown: "报告",
111
+ doctorMarkdown: "诊断",
112
+ closeoutDraft: "收尾草稿",
113
+ dagPath: "DAG 路径",
114
+ };
115
+
116
+ export const STEP_ORDER = [
117
+ "preflight",
118
+ "materialize",
119
+ "dag-run-task",
120
+ "pi-model-override",
121
+ "dag-validate",
122
+ "run-dag",
123
+ "report-decision",
124
+ "dag-doctor",
125
+ "closeout-draft",
126
+ "promote-run",
127
+ "closeout-task",
128
+ ];
129
+
130
+ export const TERMINAL_RUN_STATUSES = new Set([
131
+ "finished",
132
+ "completed",
133
+ "succeeded",
134
+ "failed",
135
+ "partial_failed",
136
+ "error",
137
+ "cancelled",
138
+ ]);
139
+
140
+ export const ARTIFACT_KEYS = [
141
+ "stdoutPath",
142
+ "stderrPath",
143
+ "resultPath",
144
+ "runRecordPath",
145
+ "reportMarkdown",
146
+ "doctorMarkdown",
147
+ "closeoutDraft",
148
+ ];
@@ -0,0 +1,67 @@
1
+ /** Copy-only recommended commands (Observe UI R5). */
2
+ import { el } from "./dom.js";
3
+
4
+ export async function copyText(text) {
5
+ const value = String(text ?? "");
6
+ if (!value) return false;
7
+ try {
8
+ if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
9
+ await navigator.clipboard.writeText(value);
10
+ return true;
11
+ }
12
+ } catch {
13
+ // fall through
14
+ }
15
+ try {
16
+ const ta = document.createElement("textarea");
17
+ ta.value = value;
18
+ ta.setAttribute("readonly", "");
19
+ ta.style.position = "fixed";
20
+ ta.style.left = "-9999px";
21
+ document.body.appendChild(ta);
22
+ ta.select();
23
+ const ok = document.execCommand("copy");
24
+ document.body.removeChild(ta);
25
+ return ok;
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
30
+
31
+ /** Advisory recommended command: copy-only, never executes. */
32
+ export function renderRecommendedCommand(action) {
33
+ const block = el("div", "recommended-command");
34
+ if (!action) {
35
+ block.appendChild(el("span", "muted", "缺失"));
36
+ return block;
37
+ }
38
+ const head = el("div", "recommended-command-head");
39
+ if (action.kind) {
40
+ head.appendChild(el("span", "recommended-command-kind", action.kind));
41
+ }
42
+ head.appendChild(
43
+ el("span", "recommended-command-label", action.label ?? "—"),
44
+ );
45
+ block.appendChild(head);
46
+ if (action.command) {
47
+ const body = el("div", "recommended-command-body");
48
+ const code = el("code", "recommended-command-text", action.command);
49
+ body.appendChild(code);
50
+ const btn = el("button", "copy-command", "复制");
51
+ btn.type = "button";
52
+ btn.addEventListener("click", (e) => {
53
+ e.preventDefault();
54
+ e.stopPropagation();
55
+ void copyText(action.command).then((ok) => {
56
+ btn.textContent = ok ? "已复制" : "复制失败";
57
+ setTimeout(() => {
58
+ btn.textContent = "复制";
59
+ }, 1500);
60
+ });
61
+ });
62
+ body.appendChild(btn);
63
+ block.appendChild(body);
64
+ }
65
+ return block;
66
+ }
67
+
@@ -0,0 +1,172 @@
1
+ /** DAG status helpers, progress, active cards, rank lanes. */
2
+ import { UI_TEXT } from "./constants.js";
3
+ import { el } from "./dom.js";
4
+ import {
5
+ badge,
6
+ dagStatusBadge,
7
+ formatDuration,
8
+ formatAgo,
9
+ formatMs,
10
+ } from "./format.js";
11
+ import { navigate } from "./router.js";
12
+
13
+ export function isNodeActive(status) {
14
+ if (!status) return false;
15
+ const s = status.toLowerCase();
16
+ return (
17
+ s === "running" || s === "started" || s === "pending" || s === "queued"
18
+ );
19
+ }
20
+
21
+ export function isNodeFinished(status) {
22
+ if (!status) return false;
23
+ const s = status.toLowerCase();
24
+ return (
25
+ s === "finished" ||
26
+ s === "completed" ||
27
+ s === "succeeded" ||
28
+ s === "done" ||
29
+ s === "error" ||
30
+ s === "failed" ||
31
+ s === "skipped"
32
+ );
33
+ }
34
+
35
+ export function isNodeFailed(status) {
36
+ if (!status) return false;
37
+ const s = status.toLowerCase();
38
+ return s === "error" || s === "failed" || s === "partial_failed";
39
+ }
40
+
41
+ export function dagProgress(dag) {
42
+ const nodes = dag.nodes ?? [];
43
+ const finished = nodes.filter((n) => isNodeFinished(n.status)).length;
44
+ return { finished, total: nodes.length };
45
+ }
46
+
47
+ export function getActiveNodes(dag) {
48
+ return (dag.nodes ?? []).filter((n) => isNodeActive(n.status));
49
+ }
50
+
51
+ export function dagSortTime(dag) {
52
+ return dag.startedAt ?? dag.finishedAt ?? "";
53
+ }
54
+
55
+ export function renderActiveDagCard(dag, nowMs) {
56
+ const card = el("div", "active-dag-card");
57
+ if (isNodeActive(dag.status) || getActiveNodes(dag).length > 0) {
58
+ card.classList.add("active-dag-card-running");
59
+ }
60
+ const title = dag.title || dag.dagRunId;
61
+ const header = el("div", "active-dag-header");
62
+ header.appendChild(el("span", "active-dag-title", title));
63
+ header.appendChild(dagStatusBadge(dag.effectiveStatus ?? dag.status));
64
+ card.appendChild(header);
65
+
66
+ const progress = dagProgress(dag);
67
+ const activeNodes = getActiveNodes(dag);
68
+ const currentLabel =
69
+ activeNodes.length > 0
70
+ ? activeNodes
71
+ .map((n) => {
72
+ const parts = [n.nodeId];
73
+ if (n.executor) parts.push(`(${n.executor})`);
74
+ if (n.model) parts.push(n.model);
75
+ return parts.join(" ");
76
+ })
77
+ .join(", ")
78
+ : "—";
79
+
80
+ const elapsed =
81
+ dag.durationMs ??
82
+ (dag.startedAt
83
+ ? Math.max(0, nowMs - Date.parse(dag.startedAt))
84
+ : undefined);
85
+ const lastUpdate = dag.finishedAt ?? dag.startedAt;
86
+
87
+ const meta = el("div", "active-dag-meta");
88
+ meta.appendChild(
89
+ el(
90
+ "span",
91
+ null,
92
+ `进度 ${progress.finished}/${progress.total} · 当前节点:${currentLabel}`,
93
+ ),
94
+ );
95
+ meta.appendChild(
96
+ el(
97
+ "span",
98
+ null,
99
+ `已用时 ${formatDuration(elapsed)} · 最近活动 ${formatAgo(lastUpdate, nowMs)}`,
100
+ ),
101
+ );
102
+ card.appendChild(meta);
103
+
104
+ const link = el("a", "active-dag-link", UI_TEXT.enterDetail);
105
+ link.href = `#/dag/${encodeURIComponent(dag.dagRunId)}`;
106
+ link.addEventListener("click", (e) => {
107
+ e.preventDefault();
108
+ navigate(`/dag/${encodeURIComponent(dag.dagRunId)}`);
109
+ });
110
+ card.appendChild(link);
111
+ return card;
112
+ }
113
+
114
+ export function renderRankLanes(dag) {
115
+ const container = el("div", "rank-lanes");
116
+ const nodes = dag.nodes ?? [];
117
+ const ranks = dag.ranks;
118
+ const rankGroups = new Map();
119
+
120
+ if (ranks && ranks.length > 0) {
121
+ for (let i = 0; i < ranks.length; i++) {
122
+ rankGroups.set(String(i), ranks[i]);
123
+ }
124
+ for (const node of nodes) {
125
+ const rank = node.rank ?? "—";
126
+ if (!rankGroups.has(rank)) {
127
+ rankGroups.set(rank, [node.nodeId]);
128
+ } else if (!rankGroups.get(rank).includes(node.nodeId)) {
129
+ rankGroups.get(rank).push(node.nodeId);
130
+ }
131
+ }
132
+ } else {
133
+ for (const node of nodes) {
134
+ const rank = node.rank ?? "—";
135
+ const list = rankGroups.get(rank) ?? [];
136
+ list.push(node.nodeId);
137
+ rankGroups.set(rank, list);
138
+ }
139
+ }
140
+
141
+ const sortedRanks = [...rankGroups.keys()].sort((a, b) => {
142
+ const na = Number(a);
143
+ const nb = Number(b);
144
+ if (!Number.isNaN(na) && !Number.isNaN(nb)) return na - nb;
145
+ return a.localeCompare(b);
146
+ });
147
+
148
+ const nodeById = Object.fromEntries(nodes.map((n) => [n.nodeId, n]));
149
+
150
+ for (const rank of sortedRanks) {
151
+ const lane = el("div", "rank-lane");
152
+ lane.appendChild(el("div", "rank-lane-label", `第 ${rank} 层`));
153
+ const chips = el("div", "rank-lane-nodes");
154
+ for (const nodeId of rankGroups.get(rank) ?? []) {
155
+ const node = nodeById[nodeId] ?? { nodeId };
156
+ const chip = el("div", "rank-node-chip");
157
+ if (isNodeActive(node.status)) chip.classList.add("rank-node-active");
158
+ if (isNodeFailed(node.status)) chip.classList.add("rank-node-error");
159
+ chip.appendChild(el("span", "rank-node-id", nodeId));
160
+ if (node.status) chip.appendChild(badge(node.status));
161
+ if (node.durationMs !== undefined) {
162
+ chip.appendChild(
163
+ el("span", "rank-node-dur", formatMs(node.durationMs)),
164
+ );
165
+ }
166
+ chips.appendChild(chip);
167
+ }
168
+ lane.appendChild(chips);
169
+ container.appendChild(lane);
170
+ }
171
+ return container;
172
+ }
@@ -0,0 +1,72 @@
1
+ /** DAG / dashboard pure model helpers (Observe UI R5). */
2
+
3
+ const TERMINAL_DAG_EFFECTIVE_STATUSES = new Set([
4
+ "finished",
5
+ "failed",
6
+ "superseded",
7
+ "abandoned",
8
+ ]);
9
+
10
+ const TERMINAL_RUN_STATUSES = new Set([
11
+ "finished",
12
+ "completed",
13
+ "succeeded",
14
+ "failed",
15
+ "partial_failed",
16
+ "error",
17
+ "cancelled",
18
+ ]);
19
+
20
+ export function dagMetaVisibility(dag) {
21
+ const terminal =
22
+ (dag?.lifecycle ?? "").toLowerCase() === "completed" ||
23
+ TERMINAL_DAG_EFFECTIVE_STATUSES.has(dag?.effectiveStatus);
24
+ return {
25
+ showExecutorStatus: !terminal,
26
+ showConsistencyWarning: dag?.stateConsistent === false,
27
+ showResume: dag?.recoveryEligibility?.canResume === true,
28
+ showReconcile: dag?.recoveryEligibility?.canReconcile === true,
29
+ };
30
+ }
31
+
32
+ function isNodeActive(status) {
33
+ if (!status) return false;
34
+ const s = status.toLowerCase();
35
+ return (
36
+ s === "running" || s === "started" || s === "pending" || s === "queued"
37
+ );
38
+ }
39
+
40
+ export function isDagRunActive(dag) {
41
+ if (
42
+ ["running", "running-quiet", "remote-unknown", "pending"].includes(
43
+ dag.effectiveStatus,
44
+ )
45
+ )
46
+ return true;
47
+ if (dag.effectiveStatus && dag.effectiveStatus !== "unknown") return false;
48
+ const status = (dag.status ?? "").toLowerCase();
49
+ if ((dag.lifecycle ?? "").toLowerCase() === "paused") return false;
50
+ if (["orphaned", "stale"].includes((dag.liveness ?? "").toLowerCase()))
51
+ return false;
52
+ if (TERMINAL_RUN_STATUSES.has(status)) return false;
53
+ if (["running", "pending", "started"].includes(status)) return true;
54
+ return (dag.nodes ?? []).some((n) => isNodeActive(n.status));
55
+ }
56
+
57
+ const DASHBOARD_FEATURE_STATUSES = new Set(["running", "needs_action"]);
58
+
59
+ export function dashboardVisibleFeatures(features) {
60
+ return (features ?? []).filter((feature) =>
61
+ DASHBOARD_FEATURE_STATUSES.has(feature?.status),
62
+ );
63
+ }
64
+
65
+ export function findDagForRun(snapshot, _workerRunId, task) {
66
+ const dagRuns = snapshot?.dagRuns ?? [];
67
+ if (task?.dagRunId) {
68
+ const byTask = dagRuns.find((d) => d.dagRunId === task.dagRunId);
69
+ if (byTask) return byTask;
70
+ }
71
+ return undefined;
72
+ }
@@ -0,0 +1,61 @@
1
+ /** DOM helpers (Observe UI R5). */
2
+
3
+ export function clearNode(node) {
4
+ while (node.firstChild) node.removeChild(node.firstChild);
5
+ }
6
+
7
+ export function el(tag, className, text) {
8
+ const node = document.createElement(tag);
9
+ if (className) node.className = className;
10
+ if (text !== undefined) node.textContent = text;
11
+ return node;
12
+ }
13
+
14
+ export function metaGrid(entries) {
15
+ const dl = el("dl", "meta-grid");
16
+ for (const [label, value] of entries) {
17
+ const item = document.createElement("div");
18
+ item.className = "meta-item";
19
+ const dt = document.createElement("dt");
20
+ dt.textContent = label;
21
+ const dd = document.createElement("dd");
22
+ if (value instanceof Node) dd.appendChild(value);
23
+ else dd.textContent = value ?? "—";
24
+ item.appendChild(dt);
25
+ item.appendChild(dd);
26
+ dl.appendChild(item);
27
+ }
28
+ return dl;
29
+ }
30
+
31
+ export function buildTable(headers, rows) {
32
+ const table = document.createElement("table");
33
+ const thead = document.createElement("thead");
34
+ const headRow = document.createElement("tr");
35
+ for (const h of headers) {
36
+ const th = document.createElement("th");
37
+ th.textContent = h;
38
+ headRow.appendChild(th);
39
+ }
40
+ thead.appendChild(headRow);
41
+ table.appendChild(thead);
42
+
43
+ const tbody = document.createElement("tbody");
44
+ for (const row of rows) {
45
+ const tr = document.createElement("tr");
46
+ if (row.className) tr.className = row.className;
47
+ if (row.onClick) {
48
+ tr.classList.add("clickable");
49
+ tr.addEventListener("click", row.onClick);
50
+ }
51
+ for (const cell of row.cells) {
52
+ const td = document.createElement("td");
53
+ if (cell instanceof Node) td.appendChild(cell);
54
+ else td.textContent = cell ?? "—";
55
+ tr.appendChild(td);
56
+ }
57
+ tbody.appendChild(tr);
58
+ }
59
+ table.appendChild(tbody);
60
+ return table;
61
+ }
@@ -0,0 +1,67 @@
1
+ /** Pool task sorting and status filter buckets (Observe UI R5). */
2
+
3
+ export function sortPoolTasks(tasks) {
4
+ const list = Array.isArray(tasks) ? [...tasks] : [];
5
+ const active = new Set(["running", "pending", "blocked", "stale", "unknown"]);
6
+ const rank = (status) => {
7
+ const key = (status ?? "unknown").toLowerCase();
8
+ if (key === "running") return 0;
9
+ if (key === "pending" || key === "queued") return 1;
10
+ if (key === "blocked") return 2;
11
+ if (key === "stale") return 3;
12
+ if (active.has(key)) return 4;
13
+ return 10;
14
+ };
15
+ const timeKey = (task) =>
16
+ task.updatedAt || task.finishedAt || task.startedAt || "";
17
+ list.sort((a, b) => {
18
+ const ra = rank(a.status);
19
+ const rb = rank(b.status);
20
+ if (ra !== rb) return ra - rb;
21
+ if (ra >= 10) {
22
+ const tb = timeKey(b).localeCompare(timeKey(a));
23
+ if (tb !== 0) return tb;
24
+ }
25
+ return String(a.taskId ?? "").localeCompare(String(b.taskId ?? ""));
26
+ });
27
+ return list;
28
+ }
29
+
30
+ /**
31
+ * Match task status against a hash filter token (same buckets as KPI counts).
32
+ * pending ⊇ queued; succeeded ⊇ reused|done; blocked ⊇ stale.
33
+ */
34
+ export function matchesPoolStatusFilter(taskStatus, filter) {
35
+ const st = (taskStatus ?? "").toLowerCase();
36
+ const f = (filter ?? "").toLowerCase();
37
+ if (!f) return true;
38
+ if (f === "pending") return st === "pending" || st === "queued";
39
+ if (f === "succeeded")
40
+ return st === "succeeded" || st === "reused" || st === "done";
41
+ if (f === "blocked") return st === "blocked" || st === "stale";
42
+ if (f === "failed") return st === "failed" || st === "error";
43
+ if (f === "running") return st === "running";
44
+ return st === f;
45
+ }
46
+
47
+ export function poolStatusCounts(tasks) {
48
+ const counts = {
49
+ running: 0,
50
+ pending: 0,
51
+ blocked: 0,
52
+ failed: 0,
53
+ succeeded: 0,
54
+ other: 0,
55
+ };
56
+ for (const task of tasks) {
57
+ const key = (task.status ?? "unknown").toLowerCase();
58
+ if (key === "running") counts.running += 1;
59
+ else if (key === "pending" || key === "queued") counts.pending += 1;
60
+ else if (key === "blocked" || key === "stale") counts.blocked += 1;
61
+ else if (key === "failed" || key === "error") counts.failed += 1;
62
+ else if (key === "succeeded" || key === "reused" || key === "done")
63
+ counts.succeeded += 1;
64
+ else counts.other += 1;
65
+ }
66
+ return counts;
67
+ }