@tea-agent/loop-agent 0.32.1 → 0.33.1

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 (40) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/dist/executors/model-routing.js +14 -4
  3. package/dist/governance/manifest-types.js +34 -7
  4. package/dist/worker/console/chat/model-resolver.js +114 -34
  5. package/dist/worker/console/chat/workspace-landing.js +58 -22
  6. package/dist/worker/console/doctor.js +1 -0
  7. package/dist/worker/console/night-aux-ticker.js +5 -0
  8. package/dist/worker/console/operator-surface-health.js +1 -0
  9. package/dist/worker/console/pi-readiness.js +26 -17
  10. package/dist/worker/console/server.js +2 -0
  11. package/dist/worker/console/static/assets/index-CnUXAqxG.css +1 -0
  12. package/dist/worker/console/static/assets/index-PzYzcuFG.js +29 -0
  13. package/dist/worker/console/static/index.html +3 -2
  14. package/dist/worker/console/static-src/app/useRecoveryConsole.js +3 -2
  15. package/dist/worker/console/static-src/night/useNightBoard.js +0 -31
  16. package/dist/worker/observability/read-model.js +106 -0
  17. package/dist/worker/observe/routes.js +22 -9
  18. package/dist/worker/observe/static/api.js +42 -3
  19. package/dist/worker/observe/static/app.js +15 -0
  20. package/dist/worker/observe/static/constants.js +10 -0
  21. package/dist/worker/observe/static/custom-select.js +567 -0
  22. package/dist/worker/observe/static/index.html +56 -47
  23. package/dist/worker/observe/static/kpi.js +2 -24
  24. package/dist/worker/observe/static/operator-chrome.css +480 -0
  25. package/dist/worker/observe/static/operator-chrome.d.ts +82 -0
  26. package/dist/worker/observe/static/operator-chrome.js +554 -0
  27. package/dist/worker/observe/static/router.js +54 -1
  28. package/dist/worker/observe/static/shell-chrome.js +1 -11
  29. package/dist/worker/observe/static/state.js +20 -0
  30. package/dist/worker/observe/static/styles.css +680 -299
  31. package/dist/worker/observe/static/views/dag-inspector.js +20 -17
  32. package/dist/worker/observe/static/views/dag.js +136 -59
  33. package/dist/worker/observe/static/views/dags.js +877 -0
  34. package/dist/worker/observe/static/views/dashboard.js +67 -8
  35. package/dist/workflows/dag/init-hybrid.js +58 -29
  36. package/docs/templates/harness.schema.json +5 -0
  37. package/harness.json +4 -4
  38. package/package.json +1 -1
  39. package/dist/worker/console/static/assets/index-Bpa2qrc-.js +0 -29
  40. package/dist/worker/console/static/assets/index-BqfFDdnG.css +0 -1
@@ -0,0 +1,554 @@
1
+ /**
2
+ * Operator Chrome — 统一壳(Operate React 与 Inspect vanilla 共用唯一实现)。
3
+ *
4
+ * 设计真源:docs/design/active/2026-08-09-unified-operator-chrome.md
5
+ * - 一条 chrome 两条带:Band1 全局(品牌/仓库身份/模式分段控件/对象上下文/状态格),
6
+ * Band2 工作区(下划线 tab,随模式切换内容)。
7
+ * - 模式语义:操作=做,观测=看同一对象;模式切换在持有 DAG 选中时不丢对象。
8
+ * - Inspect 永不提供写入口;需要动作时跳回操作侧。
9
+ *
10
+ * 纯函数(parse/build/resolve)不触碰 DOM,node 侧可直接单测;
11
+ * mountOperatorChrome 仅在浏览器环境调用。
12
+ */
13
+
14
+ export const OPERATE_TABS = Object.freeze([
15
+ { id: "chat", label: "对话", href: "#/chat" },
16
+ { id: "tasks", label: "任务", href: "#/tasks" },
17
+ { id: "recovery", label: "恢复", href: "#/recovery" },
18
+ { id: "night", label: "夜间", href: "#/night" },
19
+ { id: "models", label: "模型与认证", href: "#/models" },
20
+ { id: "settings", label: "设置", href: "#/settings" },
21
+ ]);
22
+
23
+ export const INSPECT_TABS = Object.freeze([
24
+ { id: "dashboard", label: "总览", href: "#/" },
25
+ { id: "pool", label: "资源池", href: "#/pool" },
26
+ { id: "failures", label: "异常 Task", href: "#/failures" },
27
+ ]);
28
+
29
+ export const READINESS_LABELS = Object.freeze({
30
+ "setup-required": "需要设置",
31
+ ready: "已就绪",
32
+ degraded: "降级可用",
33
+ unknown: "未知",
34
+ });
35
+
36
+ const CONTEXT_KIND_LABELS = Object.freeze({
37
+ dag: "DAG",
38
+ run: "Run",
39
+ task: "Task",
40
+ feature: "Feature",
41
+ batch: "Batch",
42
+ night: "夜间",
43
+ });
44
+
45
+ /** 支持返回操作侧的对象视图 → 返回落地工作区。v1 仅接通 recovery 闭环。 */
46
+ const RETURNABLE_VIEWS = new Set(["dag", "run"]);
47
+
48
+ function splitHash(hashValue) {
49
+ const raw = String(hashValue ?? "").replace(/^#/, "");
50
+ const qIndex = raw.indexOf("?");
51
+ const path = (qIndex >= 0 ? raw.slice(0, qIndex) : raw) || "/";
52
+ const query = qIndex >= 0 ? new URLSearchParams(raw.slice(qIndex + 1)) : null;
53
+ return { path, query };
54
+ }
55
+
56
+ function decode(value) {
57
+ try {
58
+ return decodeURIComponent(value);
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ /** 与 router.js 同形的最小对象视图解析(只读 chrome 用,不替代路由)。 */
65
+ export function parseInspectObjectView(hashValue) {
66
+ const { path } = splitHash(hashValue);
67
+ const match = /^\/(dag|run|task|feature|batch|night)\/([^/]+)$/.exec(path);
68
+ if (!match) return null;
69
+ const id = decode(match[2]);
70
+ if (!id) return null;
71
+ return { view: match[1], id };
72
+ }
73
+
74
+ /**
75
+ * Inspect 当前 hash 应高亮的工作区 tab href。
76
+ * 三个一级集合视图映射到各自 tab;下钻/对象页(dag/dags/run/night 等)
77
+ * 不高亮任何 tab——位置由面包屑与上下文 chip 表达(`#/dags` 自 0.33.0
78
+ * 起是独立历史分页页,不再是总览滚动别名)。
79
+ */
80
+ export function activeInspectTabHref(hashValue) {
81
+ const { path } = splitHash(hashValue);
82
+ if (path === "/" || path === "") return "#/";
83
+ if (path === "/pool" || path.startsWith("/task/")) return "#/pool";
84
+ if (path === "/failures") return "#/failures";
85
+ if (path.startsWith("/feature/")) return "#/";
86
+ return null;
87
+ }
88
+
89
+ /**
90
+ * 对象上下文 chip 的选中模型。仅当 URL 携带 from 来源(如 from=recovery)
91
+ * 且当前为可返回对象视图时返回;否则为 null(chip 不出现)。
92
+ */
93
+ export function parseInspectContext(hashValue) {
94
+ const objectView = parseInspectObjectView(hashValue);
95
+ if (!objectView || !RETURNABLE_VIEWS.has(objectView.view)) return null;
96
+ const { query } = splitHash(hashValue);
97
+ const from = query?.get("from") ?? null;
98
+ if (from !== "recovery") return null;
99
+ const returnParams = new URLSearchParams();
100
+ if (objectView.view === "dag") returnParams.set("dagRunId", objectView.id);
101
+ const suffix = returnParams.toString();
102
+ return {
103
+ view: objectView.view,
104
+ id: objectView.id,
105
+ from,
106
+ kindLabel: CONTEXT_KIND_LABELS[objectView.view] ?? objectView.view,
107
+ returnHref: `/#/recovery${suffix ? `?${suffix}` : ""}`,
108
+ returnLabel: "返回 恢复",
109
+ };
110
+ }
111
+
112
+ /** 从 Inspect 切回操作侧:持有 DAG 选中时不丢对象(换动作不换对象)。 */
113
+ export function resolveOperateHrefFromInspectHash(hashValue) {
114
+ const objectView = parseInspectObjectView(hashValue);
115
+ if (objectView?.view === "dag") {
116
+ return `/#/recovery?dagRunId=${encodeURIComponent(objectView.id)}`;
117
+ }
118
+ return "/#/";
119
+ }
120
+
121
+ /** 操作侧 → Inspect 的 canonical 深链(可携带 from 以激活上下文 chip)。 */
122
+ export function buildInspectDagHref(dagRunId, options = {}) {
123
+ const base = `/inspect/#/dag/${encodeURIComponent(dagRunId)}`;
124
+ return options.from === "recovery" ? `${base}?from=recovery` : base;
125
+ }
126
+
127
+ /** 清除跨模式来源标记,但保留对象页上的其它查询参数与当前历史位置。 */
128
+ export function clearInspectContextSource(hashValue) {
129
+ const { path, query } = splitHash(hashValue);
130
+ const nextQuery = new URLSearchParams(query ?? undefined);
131
+ nextQuery.delete("from");
132
+ const suffix = nextQuery.toString();
133
+ return `#${path}${suffix ? `?${suffix}` : ""}`;
134
+ }
135
+
136
+ export function repoBaseName(repoRoot) {
137
+ const parts = String(repoRoot ?? "")
138
+ .split(/[\\/]/)
139
+ .filter(Boolean);
140
+ return parts.at(-1) ?? "";
141
+ }
142
+
143
+ export function shortFingerprint(fingerprint) {
144
+ const raw = String(fingerprint ?? "").trim();
145
+ return raw ? raw.slice(0, 6) : "";
146
+ }
147
+
148
+ /** 统一/兼容 health 负载 → chrome 状态槽。缺字段时降级为 unknown。 */
149
+ export function chromeStateFromHealth(body) {
150
+ const health = body && typeof body === "object" ? body : {};
151
+ const readiness =
152
+ typeof health.piReadiness === "string" ? health.piReadiness : "unknown";
153
+ return {
154
+ readiness,
155
+ readinessLabel: READINESS_LABELS[readiness] ?? readiness,
156
+ repo: {
157
+ name: health.repoName || repoBaseName(health.repoRoot) || "repository",
158
+ fingerprint: health.repoFingerprint ?? "",
159
+ version: health.packageVersion ?? "",
160
+ product: health.productName ?? health.product ?? "",
161
+ inspectReady: health.inspect?.ready,
162
+ },
163
+ };
164
+ }
165
+
166
+ /**
167
+ * 统一 chrome 的声明式模型。Operate(React 包装)与 Inspect(vanilla 挂载)
168
+ * 共用同一份模型结构,保证 DOM 契约一致。
169
+ */
170
+ export function buildOperatorChromeModel(config) {
171
+ const mode = config.mode === "inspect" ? "inspect" : "operate";
172
+ const tabs = (mode === "inspect" ? INSPECT_TABS : OPERATE_TABS).map(
173
+ (tab) => ({
174
+ ...tab,
175
+ active: tab.href === (config.activeTabHref ?? null),
176
+ }),
177
+ );
178
+ const state = config.readinessState ?? "unknown";
179
+ return {
180
+ mode,
181
+ homeHref: mode === "inspect" ? "/inspect/#/" : "/#/",
182
+ brand: "Loop Agent",
183
+ repo: config.repo ?? null,
184
+ modeButtons: [
185
+ {
186
+ id: "operate",
187
+ label: "操作",
188
+ href: config.operateHref ?? "/#/",
189
+ active: mode === "operate",
190
+ },
191
+ {
192
+ id: "inspect",
193
+ label: "观测",
194
+ href: config.inspectHref ?? "/inspect/#/",
195
+ active: mode === "inspect",
196
+ },
197
+ ],
198
+ context: config.context ?? null,
199
+ freshness: mode === "inspect" && config.freshness !== false,
200
+ pi: {
201
+ state,
202
+ label: READINESS_LABELS[state] ?? state,
203
+ href:
204
+ config.readinessHref ?? (mode === "inspect" ? "/#/models" : "#/models"),
205
+ title: config.readinessTitle ?? "",
206
+ },
207
+ tabs,
208
+ tabsAriaLabel: mode === "inspect" ? "观测看板" : "操作工作区",
209
+ };
210
+ }
211
+
212
+ /* ---------------- 以下为浏览器 DOM 挂载层 ---------------- */
213
+
214
+ const SVG_NS = "http:" + "//www.w3.org/2000/svg";
215
+ const ICONS = Object.freeze({
216
+ radar: {
217
+ size: 17,
218
+ parts: [
219
+ ["circle", { cx: 12, cy: 12, r: 9 }],
220
+ ["circle", { cx: 12, cy: 12, r: 4.5 }],
221
+ ["path", { d: "M12 12 L18 6" }],
222
+ ],
223
+ },
224
+ zap: {
225
+ size: 13,
226
+ join: "round",
227
+ parts: [["path", { d: "M13 2 4.5 13.5H11L10 22l8.5-11.5H12L13 2z" }]],
228
+ },
229
+ eye: {
230
+ size: 13,
231
+ parts: [
232
+ [
233
+ "path",
234
+ {
235
+ d: "M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12z",
236
+ },
237
+ ],
238
+ ["circle", { cx: 12, cy: 12, r: 2.8 }],
239
+ ],
240
+ },
241
+ branch: {
242
+ size: 13,
243
+ parts: [
244
+ ["circle", { cx: 6, cy: 6, r: 2.4 }],
245
+ ["circle", { cx: 6, cy: 18, r: 2.4 }],
246
+ ["circle", { cx: 18, cy: 8, r: 2.4 }],
247
+ ["path", { d: "M6 8.4v7.2M8.2 6.9 15.8 8" }],
248
+ ],
249
+ },
250
+ chevron: {
251
+ size: 10,
252
+ strokeWidth: 2.4,
253
+ parts: [["path", { d: "m6 9 6 6 6-6" }]],
254
+ },
255
+ arrowLeft: {
256
+ size: 11,
257
+ strokeWidth: 2.4,
258
+ join: "round",
259
+ parts: [["path", { d: "M19 12H5m6-7-7 7 7 7" }]],
260
+ },
261
+ close: {
262
+ size: 10,
263
+ strokeWidth: 2.4,
264
+ parts: [["path", { d: "M18 6 6 18M6 6l12 12" }]],
265
+ },
266
+ });
267
+
268
+ function el(tag, className, text) {
269
+ const node = document.createElement(tag);
270
+ if (className) node.className = className;
271
+ if (text !== undefined && text !== null) node.textContent = String(text);
272
+ return node;
273
+ }
274
+
275
+ function makeIcon(name) {
276
+ const span = el("span", "och-icon");
277
+ const definition = ICONS[name];
278
+ if (definition) {
279
+ const svg = document.createElementNS(SVG_NS, "svg");
280
+ svg.setAttribute("width", String(definition.size));
281
+ svg.setAttribute("height", String(definition.size));
282
+ svg.setAttribute("viewBox", "0 0 24 24");
283
+ svg.setAttribute("fill", "none");
284
+ svg.setAttribute("stroke", "currentColor");
285
+ svg.setAttribute("stroke-width", String(definition.strokeWidth ?? 2));
286
+ svg.setAttribute("stroke-linecap", "round");
287
+ if (definition.join) svg.setAttribute("stroke-linejoin", definition.join);
288
+ svg.setAttribute("aria-hidden", "true");
289
+ for (const [tag, attributes] of definition.parts) {
290
+ const part = document.createElementNS(SVG_NS, tag);
291
+ for (const [key, value] of Object.entries(attributes)) {
292
+ part.setAttribute(key, String(value));
293
+ }
294
+ svg.appendChild(part);
295
+ }
296
+ span.appendChild(svg);
297
+ }
298
+ span.style.display = "inline-flex";
299
+ return span;
300
+ }
301
+
302
+ function icon(parent, name) {
303
+ const span = makeIcon(name);
304
+ parent.appendChild(span);
305
+ return span;
306
+ }
307
+
308
+ let chromeInstanceSequence = 0;
309
+
310
+ /**
311
+ * 挂载统一壳。config 见 buildOperatorChromeModel;另外支持:
312
+ * - healthUrl:提供则挂载后拉取一次,填充仓库身份与 Pi 状态(Inspect 侧用)。
313
+ * - liveLocation:提供则监听 hashchange,自选高亮 tab、自更新 operateHref 与
314
+ * 上下文 chip(Inspect 侧用;Operate 侧由 React 包裹层驱动 update)。
315
+ */
316
+ export function mountOperatorChrome(root, config = {}) {
317
+ if (!root) throw new Error("mountOperatorChrome: root element is required");
318
+ root.classList.add("och-host");
319
+
320
+ let currentConfig = { ...config };
321
+ let popoverOpen = false;
322
+ let healthState = null;
323
+ let destroyed = false;
324
+ let freshnessText = "";
325
+ const popoverId = `och-repo-popover-${++chromeInstanceSequence}`;
326
+
327
+ function currentModel() {
328
+ const base = { ...currentConfig };
329
+ if (base.liveLocation) {
330
+ const hash = window.location.hash;
331
+ base.activeTabHref = activeInspectTabHref(hash);
332
+ base.operateHref = resolveOperateHrefFromInspectHash(hash);
333
+ base.context = parseInspectContext(hash);
334
+ }
335
+ if (healthState) {
336
+ base.readinessState = healthState.readiness;
337
+ base.repo = healthState.repo;
338
+ }
339
+ return buildOperatorChromeModel(base);
340
+ }
341
+
342
+ function build(model) {
343
+ freshnessText =
344
+ root.querySelector("#header-refresh")?.textContent ?? freshnessText;
345
+ root.textContent = "";
346
+ const chrome = el("div", "och");
347
+ const inner = el("div", "och-inner");
348
+ chrome.appendChild(inner);
349
+
350
+ /* Band 1 */
351
+ const global = el("div", "och-global");
352
+ inner.appendChild(global);
353
+
354
+ const brand = el("a", "och-brand");
355
+ brand.href = model.homeHref;
356
+ icon(brand, "radar");
357
+ brand.appendChild(el("span", null, model.brand));
358
+ global.appendChild(brand);
359
+
360
+ if (model.repo) {
361
+ const repoBtn = el("button", "och-repo");
362
+ repoBtn.type = "button";
363
+ repoBtn.setAttribute("aria-haspopup", "dialog");
364
+ repoBtn.setAttribute("aria-controls", popoverId);
365
+ repoBtn.setAttribute("aria-expanded", String(popoverOpen));
366
+ icon(repoBtn, "branch");
367
+ repoBtn.appendChild(el("span", "och-repo-name", model.repo.name));
368
+ const fp = shortFingerprint(model.repo.fingerprint);
369
+ if (fp) repoBtn.appendChild(el("span", "och-repo-fp", fp));
370
+ icon(repoBtn, "chevron");
371
+ global.appendChild(repoBtn);
372
+
373
+ const pop = el("div", "och-pop");
374
+ pop.id = popoverId;
375
+ pop.hidden = !popoverOpen;
376
+ pop.setAttribute("role", "dialog");
377
+ pop.setAttribute("aria-label", "仓库身份");
378
+ pop.append(el("p", "och-pop-title", "仓库身份"));
379
+ let inspectState = "—";
380
+ if (model.repo.inspectReady !== undefined) {
381
+ inspectState = model.repo.inspectReady ? "ready" : "degraded";
382
+ }
383
+ const rows = [
384
+ ["repo", model.repo.name],
385
+ ["fingerprint", model.repo.fingerprint || "—"],
386
+ ["package", model.repo.version || "—"],
387
+ ["surface", "operate + inspect · 同进程"],
388
+ ["inspect", inspectState],
389
+ ];
390
+ for (const [k, v] of rows) {
391
+ const kv = el("div", "och-pop-kv");
392
+ kv.append(el("b", null, k), el("span", null, v));
393
+ pop.appendChild(kv);
394
+ }
395
+ global.appendChild(pop);
396
+ repoBtn.addEventListener("click", (event) => {
397
+ event.stopPropagation();
398
+ popoverOpen = !popoverOpen;
399
+ repoBtn.setAttribute("aria-expanded", String(popoverOpen));
400
+ pop.hidden = !popoverOpen;
401
+ });
402
+ }
403
+
404
+ const modeNav = el("nav", "och-mode");
405
+ modeNav.setAttribute("aria-label", "产品模式");
406
+ for (const btn of model.modeButtons) {
407
+ const a = el("a", `och-mode-btn${btn.active ? " och-on" : ""}`);
408
+ a.href = btn.href;
409
+ a.dataset.mode = btn.id;
410
+ if (btn.active) a.setAttribute("aria-current", "page");
411
+ a.appendChild(makeIcon(btn.id === "operate" ? "zap" : "eye"));
412
+ a.appendChild(document.createTextNode(btn.label));
413
+ modeNav.appendChild(a);
414
+ }
415
+ global.appendChild(modeNav);
416
+
417
+ if (model.context) {
418
+ const chip = el("div", "och-context");
419
+ chip.appendChild(el("span", "och-context-kind", model.context.kindLabel));
420
+ chip.appendChild(el("span", "och-context-id", model.context.id));
421
+ const back = el("a", "och-context-return");
422
+ back.href = model.context.returnHref;
423
+ back.appendChild(makeIcon("arrowLeft"));
424
+ back.appendChild(document.createTextNode(model.context.returnLabel));
425
+ chip.appendChild(back);
426
+ const close = el("button", "och-context-close");
427
+ close.type = "button";
428
+ close.setAttribute("aria-label", "清除选中上下文");
429
+ icon(close, "close");
430
+ close.addEventListener("click", () => {
431
+ currentConfig = { ...currentConfig, context: null };
432
+ if (currentConfig.liveLocation) {
433
+ history.replaceState(
434
+ null,
435
+ "",
436
+ clearInspectContextSource(window.location.hash),
437
+ );
438
+ }
439
+ render();
440
+ });
441
+ chip.appendChild(close);
442
+ global.appendChild(chip);
443
+ }
444
+
445
+ global.appendChild(el("span", "och-spacer"));
446
+
447
+ if (model.freshness) {
448
+ const fresh = el("span", "och-fresh");
449
+ fresh.setAttribute("aria-label", "数据新鲜度");
450
+ fresh.appendChild(el("span", "och-fresh-dot"));
451
+ const text = el("span", "och-fresh-text", freshnessText);
452
+ /* id 保留给 shell-chrome.updateHeaderRefresh 回写。 */
453
+ text.id = "header-refresh";
454
+ text.setAttribute("aria-live", "polite");
455
+ fresh.appendChild(text);
456
+ global.appendChild(fresh);
457
+ }
458
+
459
+ const pi = el("a", "och-pi");
460
+ pi.dataset.state = model.pi.state;
461
+ pi.href = model.pi.href;
462
+ if (model.pi.title) pi.title = model.pi.title;
463
+ pi.setAttribute("aria-label", `Pi 就绪:${model.pi.label}`);
464
+ pi.appendChild(el("span", "och-pi-label", "Pi"));
465
+ const piState = document.createElement("strong");
466
+ piState.textContent = model.pi.label;
467
+ pi.appendChild(piState);
468
+ global.appendChild(pi);
469
+
470
+ /* Band 2 */
471
+ const workspace = el("div", "och-workspace");
472
+ inner.appendChild(workspace);
473
+ const tabs = el("nav", "och-tabs");
474
+ tabs.setAttribute("aria-label", model.tabsAriaLabel);
475
+ for (const tab of model.tabs) {
476
+ const a = el(
477
+ "a",
478
+ `och-tab${tab.active ? " och-tab-active" : ""}`,
479
+ tab.label,
480
+ );
481
+ a.href = tab.href;
482
+ if (tab.active) a.setAttribute("aria-current", "page");
483
+ tabs.appendChild(a);
484
+ }
485
+ workspace.appendChild(tabs);
486
+
487
+ root.appendChild(chrome);
488
+ }
489
+
490
+ function render() {
491
+ build(currentModel());
492
+ }
493
+
494
+ function closePopover({ restoreFocus = false } = {}) {
495
+ if (!popoverOpen) return;
496
+ popoverOpen = false;
497
+ const repoBtn = root.querySelector(".och-repo");
498
+ const pop = root.querySelector(".och-pop");
499
+ repoBtn?.setAttribute("aria-expanded", "false");
500
+ if (pop) pop.hidden = true;
501
+ if (restoreFocus && repoBtn instanceof HTMLElement) repoBtn.focus();
502
+ }
503
+
504
+ function onHashChange() {
505
+ if (destroyed || !currentConfig.liveLocation) return;
506
+ popoverOpen = false;
507
+ render();
508
+ }
509
+
510
+ function onDocumentClick(event) {
511
+ if (!popoverOpen || !(event.target instanceof Node)) return;
512
+ const repoBtn = root.querySelector(".och-repo");
513
+ const pop = root.querySelector(".och-pop");
514
+ if (repoBtn?.contains(event.target) || pop?.contains(event.target)) return;
515
+ closePopover();
516
+ }
517
+
518
+ function onDocumentKeydown(event) {
519
+ if (event.key === "Escape") closePopover({ restoreFocus: true });
520
+ }
521
+
522
+ window.addEventListener("hashchange", onHashChange);
523
+ document.addEventListener("click", onDocumentClick);
524
+ document.addEventListener("keydown", onDocumentKeydown);
525
+
526
+ render();
527
+
528
+ if (currentConfig.healthUrl && typeof fetch === "function") {
529
+ fetch(currentConfig.healthUrl, { credentials: "same-origin" })
530
+ .then((res) => (res.ok ? res.json() : null))
531
+ .then((body) => {
532
+ if (destroyed || !body) return;
533
+ healthState = chromeStateFromHealth(body);
534
+ render();
535
+ })
536
+ .catch(() => {});
537
+ }
538
+
539
+ return {
540
+ update(nextConfig = {}) {
541
+ if (destroyed) return;
542
+ currentConfig = { ...currentConfig, ...nextConfig };
543
+ render();
544
+ },
545
+ destroy() {
546
+ destroyed = true;
547
+ window.removeEventListener("hashchange", onHashChange);
548
+ document.removeEventListener("click", onDocumentClick);
549
+ document.removeEventListener("keydown", onDocumentKeydown);
550
+ root.textContent = "";
551
+ root.classList.remove("och-host");
552
+ },
553
+ };
554
+ }
@@ -50,7 +50,7 @@ export function parseHashRoute(hashValue) {
50
50
  };
51
51
  }
52
52
  if (hash === "/dags") {
53
- return { view: "dashboard", scrollTo: "dags" };
53
+ return { view: "dags" };
54
54
  }
55
55
  return { view: "dashboard" };
56
56
  }
@@ -70,6 +70,53 @@ export function navigate(path) {
70
70
  * Returns { filters: { status?, q? } } merged into route; unknown keys ignored.
71
71
  * Base parseHashRoute shape is preserved for all existing views.
72
72
  */
73
+ export const DAG_HISTORY_DEFAULT_PAGE = 1;
74
+ export const DAG_HISTORY_DEFAULT_PAGE_SIZE = 20;
75
+ export const DAG_HISTORY_ALLOWED_PAGE_SIZES = [20, 50, 100];
76
+
77
+ export function clampDagHistoryPageSize(value) {
78
+ const n = Number.parseInt(String(value ?? ""), 10);
79
+ if (!Number.isFinite(n) || n <= 0) return DAG_HISTORY_DEFAULT_PAGE_SIZE;
80
+ if (DAG_HISTORY_ALLOWED_PAGE_SIZES.includes(n)) return n;
81
+ let best = DAG_HISTORY_DEFAULT_PAGE_SIZE;
82
+ let bestDist = Number.POSITIVE_INFINITY;
83
+ for (const allowed of DAG_HISTORY_ALLOWED_PAGE_SIZES) {
84
+ const dist = Math.abs(allowed - n);
85
+ if (dist < bestDist || (dist === bestDist && allowed < best)) {
86
+ best = allowed;
87
+ bestDist = dist;
88
+ }
89
+ }
90
+ return best;
91
+ }
92
+
93
+ export function clampDagHistoryPage(value, totalPages = Number.POSITIVE_INFINITY) {
94
+ const n = Number.parseInt(String(value ?? ""), 10);
95
+ const safeTotal = Number.isFinite(totalPages)
96
+ ? Math.max(1, Math.floor(totalPages))
97
+ : Number.POSITIVE_INFINITY;
98
+ if (!Number.isFinite(n) || n <= 0) return DAG_HISTORY_DEFAULT_PAGE;
99
+ return Math.min(Math.floor(n), safeTotal);
100
+ }
101
+
102
+ /** Parse page/pageSize for #/dags history route; illegal values fall back to defaults. */
103
+ export function parseDagHistoryQuery(hashValue) {
104
+ const raw = String(hashValue ?? "").replace(/^#/, "");
105
+ const qIndex = raw.indexOf("?");
106
+ const params =
107
+ qIndex >= 0 ? new URLSearchParams(raw.slice(qIndex + 1)) : new URLSearchParams();
108
+ return {
109
+ page: clampDagHistoryPage(params.get("page")),
110
+ pageSize: clampDagHistoryPageSize(params.get("pageSize")),
111
+ };
112
+ }
113
+
114
+ export function buildDagHistoryHash(page, pageSize) {
115
+ const p = clampDagHistoryPage(page);
116
+ const size = clampDagHistoryPageSize(pageSize);
117
+ return `/dags?page=${p}&pageSize=${size}`;
118
+ }
119
+
73
120
  export function parseHashFilters(hashValue) {
74
121
  const raw = String(hashValue ?? "").replace(/^#/, "");
75
122
  const qIndex = raw.indexOf("?");
@@ -88,6 +135,10 @@ export function parseHashRouteWithFilters(hashValue) {
88
135
  const raw = String(hashValue ?? "");
89
136
  const withoutQuery = raw.replace(/^#/, "").split("?")[0];
90
137
  const base = parseHashRoute("#" + (withoutQuery || "/"));
138
+ if (base.view === "dags") {
139
+ const history = parseDagHistoryQuery(raw);
140
+ return { ...base, page: history.page, pageSize: history.pageSize };
141
+ }
91
142
  const filters = parseHashFilters(raw);
92
143
  if (Object.keys(filters).length === 0) return base;
93
144
  return { ...base, filters };
@@ -98,6 +149,8 @@ export function buildHashPath(path, filters = {}) {
98
149
  const params = new URLSearchParams();
99
150
  if (filters.status) params.set("status", filters.status);
100
151
  if (filters.q) params.set("q", filters.q);
152
+ if (filters.page != null) params.set("page", String(filters.page));
153
+ if (filters.pageSize != null) params.set("pageSize", String(filters.pageSize));
101
154
  const qs = params.toString();
102
155
  return qs ? `${base}?${qs}` : base;
103
156
  }
@@ -12,17 +12,7 @@ export function showView(name) {
12
12
  document.querySelectorAll("[data-view]").forEach((section) => {
13
13
  section.hidden = section.dataset.view !== name;
14
14
  });
15
- let activeHref = "#/";
16
- if (name === "failures") activeHref = "#/failures";
17
- else if (name === "pool" || name === "task") activeHref = "#/pool";
18
- else if (name === "night") activeHref = "#/night";
19
- else if (name === "feature") activeHref = "#/";
20
- document.querySelectorAll(".nav-link").forEach((link) => {
21
- link.classList.toggle(
22
- "nav-link-active",
23
- link.getAttribute("href") === activeHref,
24
- );
25
- });
15
+ // 工作区 tab 高亮由统一 Operator Chrome 依据 location.hash 自行维护。
26
16
  }
27
17
 
28
18
  export function setBreadcrumb(parts) {
@@ -19,6 +19,26 @@ export const uiState = {
19
19
  poolTimer: null,
20
20
  /** Pool page: auto-refresh opt-in (default false). */
21
21
  poolAutoRefresh: false,
22
+ /** DAG history page request generation (drop late responses). */
23
+ dagHistoryRequestSeq: 0,
24
+ /** Last successful formal DAG history page payload. */
25
+ dagHistoryPage: null,
26
+ dagHistoryError: null,
27
+ dagHistoryLoading: false,
28
+ /** Light busy for cache-miss navigate / manual refresh (keeps old table). */
29
+ dagHistoryBusy: false,
30
+ dagHistoryDegraded: false,
31
+ /** Session-only page cache keyed by `${pageSize}:${page}`. */
32
+ dagHistoryCache: null,
33
+ /** In-flight adjacent prefetch map: cacheKey → generation (same-gen dedupe). */
34
+ dagHistoryPrefetchInFlight: null,
35
+ /**
36
+ * Prefetch generation; manual refresh bumps so older prefetches cannot
37
+ * write neighbor cache entries after invalidation.
38
+ */
39
+ dagHistoryPrefetchGeneration: 0,
40
+ /** AbortController for the active formal history request. */
41
+ dagHistoryAbortController: null,
22
42
  taskPollTimer: null,
23
43
  runEventOffset: 0,
24
44
  runEvents: [],