@tea-agent/loop-agent 0.43.0-next.1 → 0.43.0-next.2

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 (78) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/application/evaluation/budget.js +19 -1
  3. package/dist/build-stamp.json +3 -3
  4. package/dist/executors/dag-pi-executor.js +762 -252
  5. package/dist/executors/pi-executor.js +20 -1
  6. package/dist/executors/pi-sdk-executor.js +64 -1
  7. package/dist/executors/shell-executor.js +5 -2
  8. package/dist/shared/frontend-execution-policy.js +22 -0
  9. package/dist/task/config-types.js +4 -0
  10. package/dist/task/source-prepare/ledger-reconciliation.js +2 -2
  11. package/dist/task/source-prepare/ledger-review.js +6 -9
  12. package/dist/task/source-prepare/semantic-intake.js +16 -26
  13. package/dist/task/source-prepare/source-fidelity-pi.js +26 -7
  14. package/dist/worker/observe/node-transparency.js +81 -72
  15. package/dist/worker/observe/routes.js +20 -1
  16. package/dist/worker/observe/static/dag-inspector-humanize.js +3 -0
  17. package/dist/worker/observe/static/dom.js +20 -1
  18. package/dist/worker/observe/static/format-pool.d.ts +2 -0
  19. package/dist/worker/observe/static/format-pool.js +6 -0
  20. package/dist/worker/observe/static/format.js +7 -0
  21. package/dist/worker/observe/static/inspect-workspace.js +34 -7
  22. package/dist/worker/observe/static/inspector-submission.js +32 -0
  23. package/dist/worker/observe/static/kpi.js +1 -0
  24. package/dist/worker/observe/static/relations.js +2 -0
  25. package/dist/worker/observe/static/router.js +13 -0
  26. package/dist/worker/observe/static/run-processing.js +2 -0
  27. package/dist/worker/observe/static/shell-chrome.js +36 -3
  28. package/dist/worker/observe/static/state.js +35 -2
  29. package/dist/worker/observe/static/styles.css +260 -39
  30. package/dist/worker/observe/static/task-failure-labels.d.ts +4 -0
  31. package/dist/worker/observe/static/task-failure-labels.js +67 -0
  32. package/dist/worker/observe/static/task-history.js +12 -0
  33. package/dist/worker/observe/static/views/batch.js +6 -13
  34. package/dist/worker/observe/static/views/dag-graph.js +50 -3
  35. package/dist/worker/observe/static/views/dag-inspector.js +746 -265
  36. package/dist/worker/observe/static/views/dag-trajectory.js +3 -0
  37. package/dist/worker/observe/static/views/dag.d.ts +6 -0
  38. package/dist/worker/observe/static/views/dag.js +48 -10
  39. package/dist/worker/observe/static/views/dags.js +2 -0
  40. package/dist/worker/observe/static/views/dashboard.js +21 -12
  41. package/dist/worker/observe/static/views/failures.js +21 -11
  42. package/dist/worker/observe/static/views/feature.js +11 -29
  43. package/dist/worker/observe/static/views/pool.js +37 -28
  44. package/dist/worker/observe/static/views/run.js +48 -5
  45. package/dist/worker/observe/static/views/session-timeline.js +189 -240
  46. package/dist/worker/observe/static/views/task.js +81 -62
  47. package/dist/workflows/dag/budget-enforcement.js +53 -3
  48. package/dist/workflows/dag/frontend-durable-tools.js +193 -0
  49. package/dist/workflows/dag/frontend-execution-groups.js +24 -0
  50. package/dist/workflows/dag/frontend-implementation-contract.js +9 -0
  51. package/dist/workflows/dag/frontend-input-projection.js +76 -0
  52. package/dist/workflows/dag/frontend-plan-render.js +10 -3
  53. package/dist/workflows/dag/frontend-recovery-controller.js +7 -7
  54. package/dist/workflows/dag/frontend-recovery-lineage.js +13 -0
  55. package/dist/workflows/dag/frontend-recovery-run.js +4 -0
  56. package/dist/workflows/dag/frontend-review-scopes.js +117 -0
  57. package/dist/workflows/dag/frontend-session-budget.js +249 -0
  58. package/dist/workflows/dag/frontend-shadow-dual-write.js +20 -2
  59. package/dist/workflows/dag/frontend-test-execution-evidence.js +3 -2
  60. package/dist/workflows/dag/frontend-typed-event-store.js +11 -0
  61. package/dist/workflows/dag/init-hybrid.js +16 -10
  62. package/dist/workflows/dag/node-execution.js +32 -155
  63. package/dist/workflows/dag/prompt.js +4 -0
  64. package/dist/workflows/dag/rerun-plan.js +7 -1
  65. package/dist/workflows/dag/runner.js +26 -1
  66. package/dist/workflows/dag/types.js +6 -0
  67. package/docs/operations/README.md +1 -0
  68. package/docs/templates/frontend-design-contract.md +4 -4
  69. package/docs/templates/frontend-implementation-contract.schema.json +34 -2
  70. package/docs/templates/frontend-implementation-dag.json +5 -5
  71. package/package.json +1 -1
  72. package/skills/frontend-contract/SKILL.md +2 -1
  73. package/skills/frontend-contract/references/contract-protocol.md +19 -3
  74. package/skills/frontend-design-review/SKILL.md +12 -11
  75. package/skills/frontend-plan/SKILL.md +2 -2
  76. package/skills/frontend-plan/references/decision-contract.md +18 -5
  77. package/skills/frontend-review/SKILL.md +10 -11
  78. package/skills/frontend-scout/references/scout-evidence.md +4 -0
@@ -1,8 +1,9 @@
1
+ import { readFileBoundarySafe } from "../../shared/path-safety.js";
1
2
  import { createHash } from "node:crypto";
2
- import { lstat, readFile, realpath } from "node:fs/promises";
3
+ import { readFile } from "node:fs/promises";
3
4
  import path from "node:path";
4
- import { redactSecrets, truncateUtf8Preview } from "../../shared/preview.js";
5
- import { effectiveNodeContextV1Schema, } from "../../shared/context-transfer.js";
5
+ import { redactSecrets, truncateUtf8Preview, redactSensitivePathsWithFindings } from "../../shared/preview.js";
6
+ import { effectiveNodeContextV1Schema, computeContextDigest, } from "../../shared/context-transfer.js";
6
7
  import { buildArtifactBindingGraph, } from "../../workflows/dag/artifact-bindings.js";
7
8
  import { dagSpecSchema } from "../../workflows/dag/types.js";
8
9
  import { isSafeObservabilityIdentifier } from "../observability/event-store.js";
@@ -133,8 +134,7 @@ export async function loadDagNodeContextSummary(repoRoot, dagRunId, nodeId, atte
133
134
  };
134
135
  }
135
136
  else {
136
- const rawReceipt = await readJsonObject(receiptResolved.result.absolutePath);
137
- const parsedReceipt = effectiveNodeContextV1Schema.safeParse(rawReceipt);
137
+ const parsedReceipt = await readValidatedContextReceipt(loaded.facts.runDir, receiptResolved.result.absolutePath);
138
138
  if (!parsedReceipt.success ||
139
139
  parsedReceipt.data.identity.runId !== dagRunId ||
140
140
  parsedReceipt.data.identity.nodeId !== nodeId ||
@@ -160,6 +160,14 @@ export async function loadDagNodeContextSummary(repoRoot, dagRunId, nodeId, atte
160
160
  contextDigest: receipt.contextDigest,
161
161
  capturedAt: receipt.identity.capturedAt,
162
162
  componentCount: receipt.orderedComponents.length,
163
+ components: receipt.orderedComponents.map((component) => ({
164
+ index: component.index, kind: component.kind,
165
+ logicalName: redactSensitivePathsWithFindings(redactSecrets(component.logicalName)).redacted,
166
+ sourceIdentity: redactSensitivePathsWithFindings(redactSecrets(component.source.identity)).redacted,
167
+ inclusion: component.inclusion, bytes: component.bytes, mediaType: component.mediaType,
168
+ previewAvailable: Boolean(component.materializedPath && !["reference-only", "omitted"].includes(component.inclusion)),
169
+ ...(component.omissionReason ? { omissionReason: redactSecrets(component.omissionReason) } : {}),
170
+ })),
163
171
  componentKinds,
164
172
  inclusionCounts,
165
173
  truncationCount: receipt.promptAssembly.truncations.length,
@@ -353,11 +361,7 @@ function isTextPreviewMediaType(mediaType) {
353
361
  "application/sql",
354
362
  ].includes(normalized) || normalized.endsWith("+json") || normalized.endsWith("+xml");
355
363
  }
356
- function isInsideDirectory(root, candidate) {
357
- const normalizedRoot = root.endsWith(path.sep) ? root : `${root}${path.sep}`;
358
- return candidate.startsWith(normalizedRoot);
359
- }
360
- export async function loadDagNodeArtifactPreview(repoRoot, dagRunId, nodeId, artifactId) {
364
+ async function loadVerifiedArtifactBytes(repoRoot, dagRunId, nodeId, artifactId) {
361
365
  if (!isSafeObservabilityIdentifier(nodeId) || !isSafeObservabilityIdentifier(artifactId)) {
362
366
  return {
363
367
  status: 400,
@@ -410,21 +414,7 @@ export async function loadDagNodeArtifactPreview(repoRoot, dagRunId, nodeId, art
410
414
  if (!resolved.ok)
411
415
  return failureForResolution(resolved.reason);
412
416
  try {
413
- const [runRoot, artifactRealPath, fileStat] = await Promise.all([
414
- realpath(resolved.result.runDir),
415
- realpath(resolved.result.absolutePath),
416
- lstat(resolved.result.absolutePath),
417
- ]);
418
- if (!isInsideDirectory(runRoot, artifactRealPath) ||
419
- !fileStat.isFile() ||
420
- fileStat.isSymbolicLink() ||
421
- fileStat.nlink !== 1) {
422
- return {
423
- status: 422,
424
- body: { error: "Artifact is not a safe regular file", reason: "unsafe-path" },
425
- };
426
- }
427
- const bytes = await readFile(artifactRealPath);
417
+ const bytes = await readFileBoundarySafe({ boundaryRoot: resolved.result.runDir, targetPath: resolved.result.absolutePath, label: "recorded artifact" });
428
418
  const actualSha256 = createHash("sha256").update(bytes).digest("hex");
429
419
  if (actualSha256 !== fact.sha256 ||
430
420
  (typeof fact.bytes === "number" && fact.bytes !== bytes.byteLength)) {
@@ -436,57 +426,76 @@ export async function loadDagNodeArtifactPreview(repoRoot, dagRunId, nodeId, art
436
426
  },
437
427
  };
438
428
  }
439
- if (!isTextPreviewMediaType(artifact.mediaType)) {
440
- return {
441
- status: 200,
442
- body: {
443
- schemaVersion: NODE_TRANSPARENCY_SCHEMA_VERSION,
444
- dagRunId,
445
- nodeId,
446
- producerNodeId: artifact.producerNodeId,
447
- artifactId,
448
- path: artifact.path,
449
- mediaType: artifact.mediaType,
450
- kind: artifact.kind,
451
- sha256: actualSha256,
452
- sourceBytes: bytes.byteLength,
453
- maxBytes: DAG_ARTIFACT_PREVIEW_MAX_BYTES,
454
- previewKind: "unsupported",
455
- content: null,
456
- truncated: false,
457
- redacted: false,
458
- },
459
- };
460
- }
461
- const raw = bytes.toString("utf8");
462
- const redacted = redactSecrets(raw);
463
- const content = truncateUtf8Preview(redacted, DAG_ARTIFACT_PREVIEW_MAX_BYTES);
429
+ return { status: 200, artifact, bytes, sha256: actualSha256 };
430
+ }
431
+ catch (error) {
464
432
  return {
465
- status: 200,
466
- body: {
467
- schemaVersion: NODE_TRANSPARENCY_SCHEMA_VERSION,
468
- dagRunId,
469
- nodeId,
470
- producerNodeId: artifact.producerNodeId,
471
- artifactId,
472
- path: artifact.path,
473
- mediaType: artifact.mediaType,
474
- kind: artifact.kind,
475
- sha256: actualSha256,
476
- sourceBytes: bytes.byteLength,
477
- maxBytes: DAG_ARTIFACT_PREVIEW_MAX_BYTES,
478
- previewKind: "text",
479
- content,
480
- truncated: content !== redacted,
481
- redacted: redacted !== raw,
482
- },
433
+ status: error.code === "ENOENT" ? 404 : 422,
434
+ body: { error: "Artifact file is unavailable or unsafe", reason: error.code === "ENOENT" ? "not-found" : "unsafe-path" },
483
435
  };
484
436
  }
437
+ }
438
+ export async function loadDagNodeArtifactPreview(repoRoot, dagRunId, nodeId, artifactId) {
439
+ const verified = await loadVerifiedArtifactBytes(repoRoot, dagRunId, nodeId, artifactId);
440
+ if (verified.status !== 200)
441
+ return verified;
442
+ const { artifact, bytes, sha256 } = verified;
443
+ const supported = isTextPreviewMediaType(artifact.mediaType);
444
+ const raw = supported ? bytes.toString("utf8") : "";
445
+ const safe = redactSecrets(raw);
446
+ const content = supported ? truncateUtf8Preview(safe, DAG_ARTIFACT_PREVIEW_MAX_BYTES) : null;
447
+ return { status: 200, body: { schemaVersion: 1, dagRunId, nodeId, producerNodeId: artifact.producerNodeId, artifactId, path: artifact.path, mediaType: artifact.mediaType, kind: artifact.kind, sha256, sourceBytes: bytes.byteLength, maxBytes: DAG_ARTIFACT_PREVIEW_MAX_BYTES, previewKind: supported ? "text" : "unsupported", content, truncated: supported && content !== safe, redacted: safe !== raw } };
448
+ }
449
+ /** Full text uses the same binding and safety checks as the bounded preview. */
450
+ export async function loadDagNodeArtifactDownload(repoRoot, dagRunId, nodeId, artifactId) {
451
+ const verified = await loadVerifiedArtifactBytes(repoRoot, dagRunId, nodeId, artifactId);
452
+ if (verified.status !== 200)
453
+ return verified;
454
+ if (!isTextPreviewMediaType(verified.artifact.mediaType))
455
+ return { status: 422, body: { error: "Only text artifacts support redacted download", reason: "unsupported-media" } };
456
+ return { status: 200, download: { filename: `${artifactId}.redacted.txt`, bytes: Buffer.from(redactSecrets(verified.bytes.toString("utf8")), "utf8") } };
457
+ }
458
+ async function readValidatedContextReceipt(boundaryRoot, targetPath) {
459
+ try {
460
+ const bytes = await readFileBoundarySafe({ boundaryRoot, targetPath, label: "recorded context receipt" });
461
+ const parsed = effectiveNodeContextV1Schema.safeParse(JSON.parse(bytes.toString("utf8")));
462
+ if (parsed.success) {
463
+ const { contextDigest, ...context } = parsed.data;
464
+ if (computeContextDigest(context) === contextDigest)
465
+ return parsed;
466
+ }
467
+ }
468
+ catch { /* Missing, changed and unsafe receipts are unavailable. */ }
469
+ return effectiveNodeContextV1Schema.safeParse(null);
470
+ }
471
+ export async function loadDagNodeContextComponent(repoRoot, dagRunId, nodeId, attempt, componentIndex) {
472
+ if (!isSafeObservabilityIdentifier(nodeId) || !Number.isSafeInteger(attempt) || attempt < 1 || !Number.isSafeInteger(componentIndex) || componentIndex < 0)
473
+ return { status: 400, body: { error: "Invalid context selection", reason: "invalid-id" } };
474
+ const loaded = await loadRunFacts(repoRoot, dagRunId);
475
+ if (!loaded.ok)
476
+ return loaded.failure;
477
+ if (!loaded.facts.spec.tasks.some((task) => task.id === nodeId))
478
+ return { status: 404, body: { error: "DAG node not found", reason: "not-found" } };
479
+ const resolved = resolveDagRunArtifact(repoRoot, dagRunId, [nodeId, "context-receipts", `attempt-${attempt}.json`]);
480
+ if (!resolved.ok)
481
+ return failureForResolution(resolved.reason);
482
+ try {
483
+ const parsed = await readValidatedContextReceipt(loaded.facts.runDir, resolved.result.absolutePath);
484
+ if (!parsed.success || parsed.data.identity.runId !== dagRunId || parsed.data.identity.nodeId !== nodeId || parsed.data.identity.attempt !== attempt)
485
+ return { status: 422, body: { error: "Invalid recorded context", reason: "CONTEXT_COMPONENT_UNAVAILABLE" } };
486
+ const component = parsed.data.orderedComponents.find((entry) => entry.index === componentIndex);
487
+ if (!component?.materializedPath || ["reference-only", "omitted"].includes(component.inclusion))
488
+ return { status: 404, body: { error: "No recorded body for this component", reason: "CONTEXT_COMPONENT_UNAVAILABLE" } };
489
+ const bytes = await readFileBoundarySafe({ boundaryRoot: loaded.facts.runDir, targetPath: path.resolve(loaded.facts.runDir, ...component.materializedPath.split("/")), label: "recorded context component" });
490
+ if (bytes.byteLength !== component.bytes || createHash("sha256").update(bytes).digest("hex") !== component.sha256)
491
+ return { status: 422, body: { error: "Recorded context component has changed", reason: "CONTEXT_COMPONENT_UNAVAILABLE" } };
492
+ const raw = bytes.toString("utf8");
493
+ const safe = redactSensitivePathsWithFindings(redactSecrets(raw)).redacted;
494
+ const content = truncateUtf8Preview(safe, DAG_ARTIFACT_PREVIEW_MAX_BYTES);
495
+ return { status: 200, body: { dagRunId, nodeId, attempt, componentIndex, sourceBytes: bytes.byteLength, content, redacted: safe !== raw, truncated: content !== safe, maxBytes: DAG_ARTIFACT_PREVIEW_MAX_BYTES } };
496
+ }
485
497
  catch {
486
- return {
487
- status: 404,
488
- body: { error: "Artifact file is unavailable", reason: "not-found" },
489
- };
498
+ return { status: 422, body: { error: "Recorded context is unavailable or unsafe", reason: "CONTEXT_COMPONENT_UNAVAILABLE" } };
490
499
  }
491
500
  }
492
501
  function stringField(value, key) {
@@ -21,7 +21,7 @@ import { extractSpecEvidence, extractSpecReadContent, } from "./spec-evidence.js
21
21
  import { buildDagNodeInput } from "./node-input.js";
22
22
  import { loadDagNodeExecutionOutput } from "./dag-node-execution-output.js";
23
23
  import { loadDagNodeProcess } from "./node-process.js";
24
- import { loadDagNodeArtifactInventory, loadDagNodeArtifactPreview, loadDagNodeContextSummary, loadDagRunLineage, } from "./node-transparency.js";
24
+ import { loadDagNodeArtifactInventory, loadDagNodeArtifactPreview, loadDagNodeArtifactDownload, loadDagNodeContextComponent, loadDagNodeContextSummary, loadDagRunLineage, } from "./node-transparency.js";
25
25
  import { buildObserveHealthV1 } from "./health.js";
26
26
  import { buildNightJobDetail, buildNightJobsDoctor, buildNightJobsList, buildNightJobsMorning, } from "./night-jobs.js";
27
27
  const ARTIFACT_PREVIEW_MAX_BYTES = 64 * 1024;
@@ -125,6 +125,8 @@ export const ROUTES = [
125
125
  pattern: /^\/api\/dag-runs\/([^/]+)\/nodes\/([^/]+)\/artifacts$/,
126
126
  handler: handleDagNodeArtifacts,
127
127
  },
128
+ { method: "GET", pattern: /^\/api\/dag-runs\/([^/]+)\/nodes\/([^/]+)\/context\/component$/, handler: handleDagNodeContextComponent },
129
+ { method: "GET", pattern: /^\/api\/dag-runs\/([^/]+)\/nodes\/([^/]+)\/artifacts\/download$/, handler: handleDagNodeArtifactDownload },
128
130
  {
129
131
  method: "GET",
130
132
  pattern: /^\/api\/dag-runs\/([^/]+)\/nodes\/([^/]+)\/execution-output$/,
@@ -795,6 +797,23 @@ async function handleDagNodeContext(_req, res, match, ctx) {
795
797
  const result = await loadDagNodeContextSummary(ctx.repoRoot, match.params.id, match.params.sub, attempt);
796
798
  sendJson(res, result.status, result.body);
797
799
  }
800
+ async function handleDagNodeContextComponent(_req, res, match, ctx) {
801
+ if (!/^[1-9]\d*$/.test(match.query.get("attempt") ?? "") || !/^(0|[1-9]\d*)$/.test(match.query.get("componentIndex") ?? "") || match.query.has("path") || match.query.has("artifactPath")) {
802
+ sendJson(res, 400, { error: "Select a recorded attempt and component index" });
803
+ return;
804
+ }
805
+ const result = await loadDagNodeContextComponent(ctx.repoRoot, match.params.id, match.params.sub, Number(match.query.get("attempt")), Number(match.query.get("componentIndex")));
806
+ sendJson(res, result.status, result.body);
807
+ }
808
+ async function handleDagNodeArtifactDownload(_req, res, match, ctx) {
809
+ const result = await loadDagNodeArtifactDownload(ctx.repoRoot, match.params.id, match.params.sub, match.query.get("artifactId") ?? "");
810
+ if (result.status !== 200) {
811
+ sendJson(res, result.status, result.body);
812
+ return;
813
+ }
814
+ res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8", "Content-Disposition": `attachment; filename="${result.download.filename}"`, "Content-Length": result.download.bytes.byteLength, "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" });
815
+ res.end(result.download.bytes);
816
+ }
798
817
  async function handleDagNodeArtifacts(_req, res, match, ctx) {
799
818
  const result = await loadDagNodeArtifactInventory(ctx.repoRoot, match.params.id, match.params.sub);
800
819
  sendJson(res, result.status, result.body);
@@ -278,6 +278,9 @@ const COMMON_VALUE_LABELS = Object.freeze({
278
278
  true: "是",
279
279
  false: "否",
280
280
  "system-prompt": "系统说明",
281
+ "dag-pi-system-prompt": "系统说明",
282
+ "dag-pi-user-message": "任务消息",
283
+ "managed-task-contract": "任务约定",
281
284
  "developer-prompt": "开发约束",
282
285
  "user-prompt": "用户要求",
283
286
  "task-contract": "任务约定",
@@ -1,5 +1,24 @@
1
+ import { isRowNavigationClick } from "./router.js";
1
2
  /** DOM helpers (Observe UI R5). */
2
3
 
4
+ /** Compact action with a visible icon and an accessible, focusable tooltip. */
5
+ export function iconButton(label, icon, className = "") {
6
+ const button = el("button", `observe-icon-button ${className}`.trim());
7
+ button.type = "button";
8
+ setIconButton(button, label, icon);
9
+ return button;
10
+ }
11
+
12
+ export function setIconButton(button, label, icon) {
13
+ button.setAttribute("aria-label", label);
14
+ button.setAttribute("data-tooltip", label);
15
+ button.setAttribute("title", label);
16
+ while (button.firstChild) button.removeChild(button.firstChild);
17
+ const glyph = el("i", icon);
18
+ glyph.setAttribute("aria-hidden", "true");
19
+ button.appendChild(glyph);
20
+ }
21
+
3
22
  /**
4
23
  * Stable key → node maps, kept per (parent, scope) so reconcileKeyed never
5
24
  * depends on dataset support. A WeakMap keyed by parent holds a map of
@@ -205,7 +224,7 @@ export function buildTable(headers, rows) {
205
224
  if (row.className) tr.className = row.className;
206
225
  if (row.onClick) {
207
226
  tr.classList.add("clickable");
208
- tr.addEventListener("click", row.onClick);
227
+ tr.addEventListener("click", (event) => { if (isRowNavigationClick(event)) row.onClick(event); });
209
228
  }
210
229
  for (const cell of row.cells) {
211
230
  const td = document.createElement("td");
@@ -69,3 +69,5 @@ export function taskIdentityKey(
69
69
  featureId: string | null | undefined,
70
70
  taskId: string | null | undefined,
71
71
  ): string;
72
+
73
+ export function matchesPoolTaskFilter(task: Record<string, unknown>, filters?: { status?: string; q?: string ;}): boolean;
@@ -151,3 +151,9 @@ export function collectPlannerReadyTasks(features) {
151
151
  export function taskIdentityKey(featureId, taskId) {
152
152
  return `${featureId ?? ""}\0${taskId ?? ""}`;
153
153
  }
154
+
155
+ export function matchesPoolTaskFilter(task, filters = {}) {
156
+ if (!matchesPoolStatusFilter(task.status, filters.status)) return false;
157
+ const query = String(filters.q ?? "").trim().toLocaleLowerCase();
158
+ return !query || [task.title, task.taskId, task.featureId, task.workerRunId, task.failureCategory, task.recommendedFollowUp].join(" ").toLocaleLowerCase().includes(query);
159
+ }
@@ -405,3 +405,10 @@ export function formatLedgerSummary(summary) {
405
405
  }
406
406
  return lines.join("\n");
407
407
  }
408
+
409
+ /** A batch ending does not imply all child tasks passed. */
410
+ export function batchStatusBadge(batch) {
411
+ const status = String(batch?.status ?? "").toLowerCase();
412
+ if (["succeeded", "completed"].includes(status)) return el("span", "badge", "已结束");
413
+ return badge(batch?.status);
414
+ }
@@ -1,3 +1,4 @@
1
+ import { inspectRouteStates } from "./state.js";
1
2
  /**
2
3
  * Hosted Inspect workspace adapter.
3
4
  *
@@ -44,6 +45,9 @@ import {
44
45
 
45
46
  let instance = null;
46
47
  let lastInspectHash = "";
48
+ let renderedHash = "";
49
+ let renderedScope = null;
50
+ let routeToken = 0;
47
51
  let listenersBound = false;
48
52
 
49
53
  export function getInspectWorkspaceInstance() {
@@ -129,7 +133,7 @@ function startView(routeValue) {
129
133
  if (routeValue.view === "dashboard") {
130
134
  startDashboardPolling(routeValue.scrollTo);
131
135
  } else if (routeValue.view === "dags") {
132
- void startDagsView();
136
+ return startDagsView();
133
137
  } else if (routeValue.view === "batch") {
134
138
  void renderBatch(routeValue.batchRunId);
135
139
  } else if (routeValue.view === "run") {
@@ -137,9 +141,9 @@ function startView(routeValue) {
137
141
  } else if (routeValue.view === "dag") {
138
142
  startDagPolling(routeValue.dagRunId);
139
143
  } else if (routeValue.view === "failures") {
140
- void renderFailures();
144
+ return renderFailures();
141
145
  } else if (routeValue.view === "pool") {
142
- startPoolView();
146
+ return startPoolView();
143
147
  } else if (routeValue.view === "night") {
144
148
  void renderNightJobs(routeValue.scheduleId);
145
149
  } else if (routeValue.view === "task") {
@@ -150,11 +154,34 @@ function startView(routeValue) {
150
154
  }
151
155
 
152
156
  export function route(hashValue) {
157
+ const scope = workspaceScope();
158
+ const scrollRoot = instance?.root ?? document.scrollingElement;
159
+ const destination = hashValue || (typeof location !== "undefined" ? location.hash : "#/inspect");
160
+ if (renderedHash && renderedScope === scope) {
161
+ const previous = parseHashRouteWithFilters(renderedHash);
162
+ if (["pool", "dags", "failures"].includes(previous.view)) {
163
+ inspectRouteStates.set(`list:${renderedHash}`, { top: scrollRoot?.scrollTop ?? 0, left: scrollRoot?.scrollLeft ?? 0 });
164
+ inspectRouteStates.set("return-list", renderedHash);
165
+ }
166
+ if (previous.view === "dag" && uiState.currentDagRunId) inspectRouteStates.set(`dag:${uiState.currentDagRunId}`, {
167
+ nodeId: uiState.selectedDagNodeId, tab: uiState.dagInspectorTab,
168
+ executionKey: uiState.dagInspectorAttempt?.key ?? null, open: uiState.dagInspectorOpen,
169
+ });
170
+ }
171
+ renderedHash = destination;
172
+ renderedScope = scope;
173
+ const token = ++routeToken;
153
174
  stopTimers();
154
- const parsed = hashValue
155
- ? parseHashRouteWithFilters(hashValue)
156
- : parseRoute();
157
- startView(parsed);
175
+ const parsed = parseHashRouteWithFilters(destination);
176
+ const saved = inspectRouteStates.get(`list:${destination}`);
177
+ Promise.resolve(startView(parsed)).then(() => {
178
+ if (!saved || token !== routeToken || scope !== workspaceScope()) return;
179
+ requestAnimationFrame(() => {
180
+ if (token !== routeToken || scope !== workspaceScope() || !scrollRoot) return;
181
+ scrollRoot.scrollTop = saved.top;
182
+ scrollRoot.scrollLeft = saved.left;
183
+ });
184
+ });
158
185
  }
159
186
 
160
187
  function resumeView(hashValue) {
@@ -0,0 +1,32 @@
1
+ /** A retry always replays the exact reviewed payload and idempotency key. */
2
+ export function createInspectorSubmission(action, payload) {
3
+ return {
4
+ action,
5
+ payload: JSON.parse(JSON.stringify(payload)),
6
+ requestId: `inspect-${action}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
7
+ inFlight: false,
8
+ operationId: null,
9
+ rejected: false,
10
+ };
11
+ }
12
+
13
+ export async function sendInspectorSubmission(submission, send) {
14
+ if (submission.pending) return submission.pending;
15
+ if (submission.operationId) return submission.response;
16
+ submission.inFlight = true;
17
+ submission.pending = (async () => {
18
+ const response = await send(submission.action, submission.payload, submission.requestId);
19
+ submission.response = response;
20
+ submission.operationId = response.ok ? response.body?.operationId ?? null : null;
21
+ // Only a definitive client rejection permits a different payload. Transport
22
+ // failure and server failure can follow acceptance, so they require replay.
23
+ submission.rejected = !response.ok && response.status >= 400 && response.status < 500;
24
+ return response;
25
+ })();
26
+ try { return await submission.pending; }
27
+ finally { submission.inFlight = false; submission.pending = null; }
28
+ }
29
+
30
+ export function hasUnresolvedSubmission(submission) {
31
+ return Boolean(submission?.payload && !submission.operationId && !submission.rejected);
32
+ }
@@ -21,6 +21,7 @@ export function artifactLink(label, refPath) {
21
21
  const a = el("a", "artifact-link", label);
22
22
  a.href = `/api/artifacts?path=${encodeURIComponent(refPath)}&tail=200`;
23
23
  a.target = "_blank";
24
+ a.addEventListener("click", (event) => event.stopPropagation());
24
25
  return a;
25
26
  }
26
27
 
@@ -1,3 +1,4 @@
1
+ import { isPlainNavigationClick, isRowNavigationClick } from "./router.js";
1
2
  /** Object relation navigation (Observe UI R5). */
2
3
  import { el } from "./dom.js";
3
4
  import { inspectPathToCanonicalHref, navigate } from "./router.js";
@@ -50,6 +51,7 @@ export function objectRouteLink(kind, id, snapshot, featureId) {
50
51
  const a = el("a", "object-link", id);
51
52
  a.href = inspectPathToCanonicalHref(path);
52
53
  a.addEventListener("click", (e) => {
54
+ if (!isPlainNavigationClick(e)) return;
53
55
  e.preventDefault();
54
56
  e.stopPropagation();
55
57
  navigate(path);
@@ -201,6 +201,11 @@ export function parseHashRouteWithFilters(hashValue) {
201
201
  const raw = String(hashValue ?? "");
202
202
  const withoutQuery = raw.replace(/^#/, "").split("?")[0];
203
203
  const base = parseHashRoute("#" + (withoutQuery || "/"));
204
+ if (base.view === "dag") {
205
+ const params = new URLSearchParams(raw.includes("?") ? raw.slice(raw.indexOf("?") + 1) : "");
206
+ const nodeId = params.get("node");
207
+ if (nodeId) return { ...base, inspector: { nodeId, tab: params.get("tab") || "output", executionKey: /^(attempt|pass)-[1-9]\d*$/.test(params.get("attempt") || "") ? params.get("attempt") : null } };
208
+ }
204
209
  if (base.view === "dags") {
205
210
  const history = parseDagHistoryQuery(raw);
206
211
  return { ...base, page: history.page, pageSize: history.pageSize };
@@ -220,3 +225,11 @@ export function buildHashPath(path, filters = {}) {
220
225
  const qs = params.toString();
221
226
  return qs ? `${base}?${qs}` : base;
222
227
  }
228
+
229
+ /** Leave browser-native modified clicks and text selection intact. */
230
+ export function isPlainNavigationClick(event) {
231
+ return !event.defaultPrevented && (event.button == null || event.button === 0) && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey;
232
+ }
233
+ export function isRowNavigationClick(event) {
234
+ return isPlainNavigationClick(event) && !event.target?.closest?.("a, button, input, select, textarea, summary, [role='button']") && !String(globalThis.getSelection?.() ?? "").trim();
235
+ }
@@ -1,3 +1,4 @@
1
+ import { isPlainNavigationClick, isRowNavigationClick } from "./router.js";
1
2
  /** Run step/command/artifact processing and task history rows. */
2
3
  import {
3
4
  STEP_LABELS,
@@ -132,6 +133,7 @@ export function taskRunHistoryRow(run) {
132
133
  );
133
134
  a.textContent = run.workerRunId;
134
135
  a.addEventListener("click", (e) => {
136
+ if (!isPlainNavigationClick(e)) return;
135
137
  e.preventDefault();
136
138
  navigate(`#/run/${encodeURIComponent(run.workerRunId)}`);
137
139
  });
@@ -1,6 +1,7 @@
1
+ import { isPlainNavigationClick, isRowNavigationClick } from "./router.js";
1
2
  /** Shell chrome: view switching, breadcrumb, header refresh, relation bar. */
2
3
  import { UI_TEXT } from "./constants.js";
3
- import { clearNode, el } from "./dom.js";
4
+ import { clearNode, el, iconButton } from "./dom.js";
4
5
  import { formatAgo } from "./format.js";
5
6
  import { inspectPathToCanonicalHref, navigate } from "./router.js";
6
7
  import { buildObjectRelations, renderObjectRelationBar } from "./relations.js";
@@ -26,12 +27,15 @@ export function setBreadcrumb(parts) {
26
27
  a.href = inspectPathToCanonicalHref(part.href);
27
28
  a.textContent = part.label;
28
29
  a.addEventListener("click", (e) => {
30
+ if (!isPlainNavigationClick(e)) return;
29
31
  e.preventDefault();
30
32
  navigate(part.href);
31
33
  });
32
34
  nav.appendChild(a);
33
35
  } else {
34
- nav.appendChild(document.createTextNode(part.label));
36
+ // 当前位置包一层 span:窄屏可对末级做 ellipsis 截断。
37
+ const current = el("span", "breadcrumb-current", part.label);
38
+ nav.appendChild(current);
35
39
  }
36
40
  });
37
41
  }
@@ -43,7 +47,7 @@ export function updateHeaderRefresh(generatedAt, options = {}) {
43
47
  elRefresh.textContent = "";
44
48
  return;
45
49
  }
46
- const modeLabel =
50
+ const modeLabel = options.mode === "ended" ? "已结束 · 按需刷新" : options.mode === "paused" ? "刷新已暂停" :
47
51
  options.mode === "manual"
48
52
  ? UI_TEXT.manualRefresh
49
53
  : options.mode === "auto"
@@ -63,3 +67,32 @@ export function mountRelationBar(containerId, ids, snapshot) {
63
67
  elBar.appendChild(el("p", "empty", "关联投影暂时不可用。"));
64
68
  }
65
69
  }
70
+
71
+ export function bindManualViewRefresh(view, refresh) {
72
+ const heading = document.querySelector(`#view-${view} .view-heading`);
73
+ if (!heading) return;
74
+ let button = heading.querySelector(".view-manual-refresh");
75
+ if (!button) { button = iconButton("刷新本页", "ri-refresh-line", "view-manual-refresh"); button.type = "button"; heading.appendChild(button); }
76
+ button.onclick = async () => { button.disabled = true; try { await refresh(); } finally { button.disabled = false; } };
77
+ }
78
+
79
+ export function mountTaskRelationPicker(containerId, ids, tasks, snapshot) {
80
+ const host = document.getElementById(containerId);
81
+ if (!host) return;
82
+ const previous = host.dataset.selectedTask;
83
+ const taskKey = (task) => JSON.stringify([task.featureId ?? ids.featureId ?? "", task.taskId]);
84
+ clearNode(host);
85
+ const select = el("select", "relation-task-picker");
86
+ select.setAttribute("aria-label", `选择关联任务,共 ${tasks.length} 个`);
87
+ for (const task of tasks) { const option = el("option", null, `${task.title || task.taskId} · ${task.status ?? "未记录"}`); option.value = taskKey(task); select.appendChild(option); }
88
+ select.value = tasks.some((task) => taskKey(task) === previous) ? previous : (tasks[0] ? taskKey(tasks[0]) : "");
89
+ const relations = el("div");
90
+ const render = () => {
91
+ host.dataset.selectedTask = select.value;
92
+ const task = tasks.find((task) => taskKey(task) === select.value) ?? tasks[0];
93
+ clearNode(relations);
94
+ relations.appendChild(renderObjectRelationBar(buildObjectRelations({ ...ids, taskId: task?.taskId, featureId: task?.featureId ?? ids.featureId, workerRunId: task?.workerRunId, dagRunId: task?.dagRunId, batchRunId: ids.batchRunId ?? task?.batchRunId }, snapshot), snapshot));
95
+ };
96
+ if (tasks.length > 1) { host.appendChild(el("span", "muted", `${tasks.length} 个关联任务 `)); host.appendChild(select); }
97
+ host.appendChild(relations); select.addEventListener("change", render); render();
98
+ }
@@ -173,8 +173,25 @@ export function updateDagTrajectoryViewportState(
173
173
  export const dagInspectorViewportStates = new Map();
174
174
 
175
175
  /** Build a stable inspector scroll region key (AC-005 context isolation). */
176
- export function inspectorViewportRegionKey(dagRunId, nodeId, tab) {
177
- return `dag:${dagRunId ?? ""}:${nodeId ?? ""}:${tab ?? "output"}`;
176
+ export function inspectorViewportRegionKey(dagRunId, nodeId, tab, executionKey = "") {
177
+ return `dag:${dagRunId ?? ""}:${nodeId ?? ""}:${tab ?? "output"}${executionKey ? `:${executionKey}` : ""}`;
178
+ }
179
+
180
+ /** Session-only reading/draft state. Never persists prompt text to disk. */
181
+ export const inspectorReadingStates = new Map();
182
+ export const inspectorNodeSelections = new Map();
183
+ export const inspectorDrafts = new Map();
184
+ export const inspectorOperations = new Map();
185
+ export const inspectRouteStates = new Map();
186
+ export const taskHistoryStates = new Map();
187
+ export function inspectorReadingState(dagRunId, nodeId, tab, executionKey, followLatest = false) {
188
+ const key = inspectorViewportRegionKey(dagRunId, nodeId, tab, executionKey || "latest");
189
+ if (!inspectorReadingStates.has(key)) inspectorReadingStates.set(key, {
190
+ key, scrollTop: 0, scrollLeft: 0, followLatest,
191
+ filter: "all", query: "", errorsOnly: false, showProtocol: false,
192
+ openDetails: new Set(), seenCount: 0, eventCount: 0,
193
+ });
194
+ return inspectorReadingStates.get(key);
178
195
  }
179
196
 
180
197
  export function updateDagInspectorViewportState(
@@ -403,6 +420,7 @@ export function applyWorkspaceScopeSwitch(
403
420
  trace.push("abort-old");
404
421
  // 2. clear old
405
422
  uiState.lastSnapshot = null;
423
+ for (const cache of [dagInspectorViewportStates, inspectorReadingStates, inspectorNodeSelections, inspectorDrafts, inspectorOperations, inspectRouteStates, taskHistoryStates]) cache.clear();
406
424
  uiState.specEvidenceDetail = null;
407
425
  uiState.specEvidenceListEvidence = null;
408
426
  clearDagNodeExecutionOutputCache();
@@ -531,6 +549,21 @@ export function computeFollowLatest(
531
549
  return height - top - client < Math.max(0, Number(threshold) || 0);
532
550
  }
533
551
 
552
+ // Chat-style jump-to-bottom visibility: the floating control is only useful
553
+ // when the region can scroll and the viewport is away from the bottom. It
554
+ // applies to every execution-record viewport, running or ended alike.
555
+ export function isScrollRegionAboveBottom(content, overflowPx = 4) {
556
+ const scrollHeight = Number(content?.scrollHeight ?? 0);
557
+ const clientHeight = Number(content?.clientHeight ?? 0);
558
+ if (!Number.isFinite(scrollHeight) || !Number.isFinite(clientHeight)) {
559
+ return false;
560
+ }
561
+ if (scrollHeight - clientHeight <= Math.max(0, Number(overflowPx) || 0)) {
562
+ return false;
563
+ }
564
+ return !computeFollowLatest(content?.scrollTop ?? 0, scrollHeight, clientHeight);
565
+ }
566
+
534
567
  // Generalised viewport-state record keyed by `regionKey`, so the same helper
535
568
  // serves the DAG node inspector output ("dag:<runId>:<nodeId>:output") and the
536
569
  // Worker run output ("run:<runId>:output").