@tea-agent/loop-agent 0.24.5 → 0.24.6

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.
@@ -34,6 +34,13 @@ export const uiState = {
34
34
  // previous node can never mutate the current node's event cache.
35
35
  sessionEventIdentity: null,
36
36
  sessionEventIdentitySeq: 0,
37
+ /** Node-input request identity ("<dagRunId>:<nodeId>#<seq>"). */
38
+ dagNodeInputIdentity: null,
39
+ dagNodeInputRequestSeq: 0,
40
+ /** Last successful / in-flight node-input payload for the active identity. */
41
+ dagNodeInputData: null,
42
+ dagNodeInputError: null,
43
+ dagNodeInputLoading: false,
37
44
  /** When true,「执行过程」展开 message_start/end 原始协议事件。 */
38
45
  sessionTimelineShowProtocol: false,
39
46
  /** Open <details> keys restored across timeline re-renders. */
@@ -45,6 +52,7 @@ export const uiState = {
45
52
  dagGraphViewportState: null,
46
53
  dagTimelineViewportState: null,
47
54
  dagNodeOutputViewportState: null,
55
+ dagNodeInputViewportState: null,
48
56
  runOutputViewportState: null,
49
57
  /**
50
58
  * Spec-evidence file preview drill-down. null = list view; otherwise
@@ -65,6 +73,12 @@ export function makeSessionEventIdentity(dagRunId, nodeId) {
65
73
  return `${dagRunId}:${nodeId}#${uiState.sessionEventIdentitySeq}`;
66
74
  }
67
75
 
76
+ /** Build a fresh node-input identity token and bump its sequence. */
77
+ export function makeDagNodeInputIdentity(dagRunId, nodeId) {
78
+ uiState.dagNodeInputRequestSeq += 1;
79
+ return `${dagRunId}:${nodeId}#${uiState.dagNodeInputRequestSeq}`;
80
+ }
81
+
68
82
  export function isPageVisible() {
69
83
  return (
70
84
  typeof document === "undefined" || document.visibilityState !== "hidden"
@@ -2010,6 +2010,80 @@ body.is-resizing-dag-graph {
2010
2010
  .spec-evidence-section { padding-bottom: var(--space-4); border-bottom: 1px solid var(--hairline); }
2011
2011
  .spec-evidence-section + .spec-evidence-section { margin-top: var(--space-4); }
2012
2012
  .spec-evidence-section h4 { margin: 0 0 var(--space-2); color: var(--ink); font-size: 12px; font-weight: 700; }
2013
+
2014
+ .node-input-root { display: flex; flex-direction: column; gap: var(--space-3); }
2015
+ .node-input-semantics {
2016
+ margin: 0;
2017
+ font-size: 12px;
2018
+ font-weight: 600;
2019
+ color: var(--muted);
2020
+ }
2021
+ .node-input-section {
2022
+ padding-bottom: var(--space-3);
2023
+ border-bottom: 1px solid var(--hairline);
2024
+ }
2025
+ .node-input-section h4 {
2026
+ margin: 0 0 var(--space-2);
2027
+ color: var(--ink);
2028
+ font-size: 12px;
2029
+ font-weight: 700;
2030
+ }
2031
+ .node-input-section h5 {
2032
+ margin: var(--space-2) 0;
2033
+ font-size: 12px;
2034
+ font-weight: 600;
2035
+ }
2036
+ .node-input-summary {
2037
+ display: grid;
2038
+ grid-template-columns: max-content 1fr;
2039
+ gap: 4px 12px;
2040
+ margin: 0;
2041
+ font-size: 12px;
2042
+ }
2043
+ .node-input-summary dt {
2044
+ margin: 0;
2045
+ color: var(--muted);
2046
+ font-weight: 600;
2047
+ }
2048
+ .node-input-summary dd {
2049
+ margin: 0;
2050
+ color: var(--ink);
2051
+ word-break: break-word;
2052
+ }
2053
+ .node-input-inherited {
2054
+ display: inline-block;
2055
+ margin-left: 4px;
2056
+ padding: 0 6px;
2057
+ border-radius: 999px;
2058
+ background: var(--surface-2, #f3f1ec);
2059
+ color: var(--muted);
2060
+ font-size: 11px;
2061
+ font-weight: 600;
2062
+ }
2063
+ .node-input-code-list,
2064
+ .node-input-command-list {
2065
+ margin: var(--space-2) 0 0;
2066
+ padding-left: 1.2rem;
2067
+ }
2068
+ .node-input-command-list pre {
2069
+ margin: 4px 0;
2070
+ padding: 8px;
2071
+ overflow: auto;
2072
+ background: var(--surface-2, #f3f1ec);
2073
+ border-radius: 6px;
2074
+ font-size: 12px;
2075
+ }
2076
+ .node-input-truncated,
2077
+ .node-input-warning {
2078
+ margin: var(--space-2) 0 0;
2079
+ font-size: 12px;
2080
+ color: var(--muted);
2081
+ }
2082
+ .node-input-error {
2083
+ margin: 0;
2084
+ color: var(--danger, #b42318);
2085
+ font-size: 13px;
2086
+ }
2013
2087
  .spec-evidence-summary { margin: var(--space-2) 0 0; color: var(--body); font-size: 12px; line-height: 1.6; }
2014
2088
  .spec-evidence-list { display: grid; gap: 6px; margin: var(--space-2) 0 0; padding: 0; list-style: none; }
2015
2089
  .spec-evidence-list li { display: flex; align-items: flex-start; gap: 6px; color: var(--body); font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; }
@@ -10,12 +10,13 @@ import {
10
10
  UI_TEXT,
11
11
  } from "../constants.js";
12
12
  import { clearNode, el } from "../dom.js";
13
- import { fetchJson, fetchSpecEvidenceFile } from "../api.js";
13
+ import { fetchJson, fetchSpecEvidenceFile, fetchDagNodeInput } from "../api.js";
14
14
  import { badge } from "../format.js";
15
15
  import { renderMarkdown } from "../markdown-render.js";
16
16
  import {
17
17
  uiState,
18
18
  makeSessionEventIdentity,
19
+ makeDagNodeInputIdentity,
19
20
  openSpecEvidenceDetail,
20
21
  closeSpecEvidenceDetail,
21
22
  updateDagTimelineViewportState,
@@ -50,9 +51,13 @@ export function selectDagNode(dagRunId, nodeId) {
50
51
  // into node B's spec-evidence panel (AC-007 regression guard).
51
52
  uiState.specEvidenceDetail = null;
52
53
  uiState.specEvidenceListEvidence = null;
54
+ uiState.dagNodeInputData = null;
55
+ uiState.dagNodeInputError = null;
56
+ uiState.dagNodeInputLoading = false;
53
57
  // Bump the session-event identity token so any in-flight response for the
54
58
  // previous node is rejected before it can mutate the current node cache.
55
59
  uiState.sessionEventIdentity = makeSessionEventIdentity(dagRunId, nodeId);
60
+ uiState.dagNodeInputIdentity = makeDagNodeInputIdentity(dagRunId, nodeId);
56
61
  }
57
62
  void import("./dag.js").then((m) => m.renderDagDetail(dagRunId, false));
58
63
  }
@@ -530,16 +535,337 @@ function renderSpecEvidenceDetail(content, dagRunId, nodeId) {
530
535
  content.appendChild(wrapper);
531
536
  }
532
537
 
538
+ function sourceLabel(source) {
539
+ if (source === "defaults") return "继承默认值";
540
+ if (source === "schema-default") return "schema 默认";
541
+ if (source === "merged") return "合并";
542
+ return null;
543
+ }
544
+
545
+ function appendMetaRow(dl, label, value, source) {
546
+ if (value == null || value === "") return;
547
+ const dt = el("dt", null, label);
548
+ const dd = el("dd", null, String(value));
549
+ const inherited = sourceLabel(source);
550
+ if (inherited) {
551
+ dd.appendChild(document.createTextNode(" "));
552
+ dd.appendChild(el("span", "node-input-inherited", inherited));
553
+ }
554
+ dl.appendChild(dt);
555
+ dl.appendChild(dd);
556
+ }
557
+
558
+ function appendCodeList(parent, title, values) {
559
+ if (!values || values.length === 0) return;
560
+ const section = el("section", "node-input-section");
561
+ section.appendChild(el("h4", null, title));
562
+ const list = el("ul", "node-input-code-list");
563
+ for (const value of values) {
564
+ const item = document.createElement("li");
565
+ const code = document.createElement("code");
566
+ code.textContent = String(value);
567
+ item.appendChild(code);
568
+ list.appendChild(item);
569
+ }
570
+ section.appendChild(list);
571
+ parent.appendChild(section);
572
+ }
573
+
574
+ function renderDagNodeInputDetail(content, data) {
575
+ const wrap = el("div", "node-input-root");
576
+ wrap.appendChild(
577
+ el("p", "node-input-semantics", UI_TEXT.inputFrozenLabel),
578
+ );
579
+
580
+ if (data.summary) {
581
+ const section = el("section", "node-input-section");
582
+ section.appendChild(el("h4", null, "摘要"));
583
+ const dl = el("dl", "node-input-summary");
584
+ appendMetaRow(
585
+ dl,
586
+ "executor",
587
+ data.summary.executor,
588
+ data.summary.executorSource,
589
+ );
590
+ appendMetaRow(dl, "role", data.summary.role, null);
591
+ appendMetaRow(dl, "complexity", data.summary.complexity, null);
592
+ appendMetaRow(
593
+ dl,
594
+ "writePolicy",
595
+ data.summary.writePolicy,
596
+ data.summary.writePolicySource,
597
+ );
598
+ appendMetaRow(dl, "toolProfile", data.summary.toolProfile, null);
599
+ section.appendChild(dl);
600
+ wrap.appendChild(section);
601
+ }
602
+
603
+ if (data.dependency) {
604
+ const section = el("section", "node-input-section");
605
+ section.appendChild(el("h4", null, "依赖"));
606
+ const dl = el("dl", "node-input-summary");
607
+ appendMetaRow(
608
+ dl,
609
+ "depends_on",
610
+ (data.dependency.dependsOn ?? []).join(", ") || "(none)",
611
+ null,
612
+ );
613
+ if (data.dependency.dependsPolicy) {
614
+ appendMetaRow(dl, "dependsPolicy", data.dependency.dependsPolicy, null);
615
+ }
616
+ if ((data.dependency.failureAwareDependsOn ?? []).length > 0) {
617
+ appendMetaRow(
618
+ dl,
619
+ "failureAwareDependsOn",
620
+ data.dependency.failureAwareDependsOn.join(", "),
621
+ null,
622
+ );
623
+ }
624
+ if (data.dependency.runIf) {
625
+ appendMetaRow(dl, "runIf", data.dependency.runIf, null);
626
+ }
627
+ section.appendChild(dl);
628
+ wrap.appendChild(section);
629
+ }
630
+
631
+ if (data.prompt) {
632
+ const section = el("section", "node-input-section");
633
+ section.appendChild(el("h4", null, "任务正文"));
634
+ if (data.prompt.source?.path) {
635
+ const meta = el("p", "muted");
636
+ meta.textContent = `source: ${data.prompt.source.path}${
637
+ data.prompt.source.sha256Prefix
638
+ ? ` (sha256 ${data.prompt.source.sha256Prefix}…)`
639
+ : ""
640
+ }`;
641
+ section.appendChild(meta);
642
+ }
643
+ section.appendChild(renderMarkdown(data.prompt.text ?? ""));
644
+ if (data.prompt.truncated) {
645
+ section.appendChild(
646
+ el(
647
+ "p",
648
+ "node-input-truncated",
649
+ `已截断,仅显示前 ${data.prompt.maxBytes ?? 0} 字节(UTF-8)。`,
650
+ ),
651
+ );
652
+ }
653
+ wrap.appendChild(section);
654
+ }
655
+
656
+ if (data.executorInput) {
657
+ const section = el("section", "node-input-section");
658
+ section.appendChild(el("h4", null, "执行器输入摘要"));
659
+ const dl = el("dl", "node-input-summary");
660
+ appendMetaRow(dl, "kind", data.executorInput.kind, null);
661
+ if (data.executorInput.modelHint) {
662
+ appendMetaRow(dl, "modelHint", data.executorInput.modelHint, null);
663
+ }
664
+ section.appendChild(dl);
665
+ appendCodeList(section, "skills", data.executorInput.skills);
666
+ if (data.executorInput.shell) {
667
+ const shell = data.executorInput.shell;
668
+ const shellDl = el("dl", "node-input-summary");
669
+ appendMetaRow(shellDl, "preset", shell.preset, null);
670
+ appendMetaRow(shellDl, "cwd", shell.cwd, null);
671
+ appendMetaRow(shellDl, "timeoutMs", shell.timeoutMs, null);
672
+ appendMetaRow(
673
+ shellDl,
674
+ "nonZeroExitPolicy",
675
+ shell.nonZeroExitPolicy,
676
+ null,
677
+ );
678
+ appendMetaRow(
679
+ shellDl,
680
+ "gates",
681
+ (shell.gates ?? []).join(", ") || "(none)",
682
+ null,
683
+ );
684
+ section.appendChild(shellDl);
685
+ if ((shell.commands ?? []).length > 0) {
686
+ const list = el("ol", "node-input-command-list");
687
+ for (const command of shell.commands) {
688
+ const item = document.createElement("li");
689
+ const pre = document.createElement("pre");
690
+ const code = document.createElement("code");
691
+ code.textContent = String(command);
692
+ pre.appendChild(code);
693
+ item.appendChild(pre);
694
+ list.appendChild(item);
695
+ }
696
+ section.appendChild(list);
697
+ }
698
+ }
699
+ if (data.executorInput.static) {
700
+ const staticBlock = data.executorInput.static;
701
+ const staticDl = el("dl", "node-input-summary");
702
+ appendMetaRow(staticDl, "status", staticBlock.status, null);
703
+ section.appendChild(staticDl);
704
+ section.appendChild(renderMarkdown(staticBlock.resultPreview ?? ""));
705
+ if (staticBlock.truncated) {
706
+ section.appendChild(
707
+ el(
708
+ "p",
709
+ "node-input-truncated",
710
+ `已截断,仅显示前 ${staticBlock.maxBytes ?? 0} 字节(UTF-8)。`,
711
+ ),
712
+ );
713
+ }
714
+ }
715
+ wrap.appendChild(section);
716
+ }
717
+
718
+ if (data.boundaries) {
719
+ const section = el("section", "node-input-section");
720
+ section.appendChild(el("h4", null, "边界与输出契约"));
721
+ const dl = el("dl", "node-input-summary");
722
+ appendMetaRow(dl, "outputMode", data.boundaries.outputMode, null);
723
+ appendMetaRow(
724
+ dl,
725
+ "firstProtocolLine",
726
+ data.boundaries.firstProtocolLine,
727
+ null,
728
+ );
729
+ section.appendChild(dl);
730
+ if (data.boundaries.outputContract) {
731
+ section.appendChild(el("h5", null, "outputContract"));
732
+ section.appendChild(renderMarkdown(data.boundaries.outputContract));
733
+ }
734
+ appendCodeList(section, "allowedPaths", data.boundaries.allowedPaths);
735
+ appendCodeList(section, "forbiddenPaths", data.boundaries.forbiddenPaths);
736
+ appendCodeList(section, "writeSet", data.boundaries.writeSet);
737
+ wrap.appendChild(section);
738
+ }
739
+
740
+ if (data.promptFingerprint?.exists) {
741
+ const section = el("section", "node-input-section");
742
+ section.appendChild(el("h4", null, "Assembled prompt 指纹"));
743
+ const dl = el("dl", "node-input-summary");
744
+ appendMetaRow(
745
+ dl,
746
+ "sha256",
747
+ data.promptFingerprint.sha256Prefix ?? "(parse failed)",
748
+ null,
749
+ );
750
+ appendMetaRow(
751
+ dl,
752
+ "lengthChars",
753
+ data.promptFingerprint.lengthChars,
754
+ null,
755
+ );
756
+ appendMetaRow(dl, "artifact", data.promptFingerprint.artifact, null);
757
+ section.appendChild(dl);
758
+ section.appendChild(
759
+ el("p", "muted", UI_TEXT.inputFingerprintNote),
760
+ );
761
+ wrap.appendChild(section);
762
+ }
763
+
764
+ if ((data.warnings ?? []).length > 0) {
765
+ const section = el("section", "node-input-section");
766
+ section.appendChild(el("h4", null, "警告"));
767
+ for (const warning of data.warnings) {
768
+ section.appendChild(el("p", "node-input-warning", String(warning)));
769
+ }
770
+ wrap.appendChild(section);
771
+ }
772
+
773
+ content.appendChild(wrap);
774
+ }
775
+
776
+ function unavailableMessage(reason) {
777
+ if (reason === "unsupported-dynamic-node") {
778
+ return UI_TEXT.inputDynamicUnsupported;
779
+ }
780
+ if (reason === "run-spec-unavailable") {
781
+ return UI_TEXT.inputRunSpecUnavailable;
782
+ }
783
+ if (reason === "task-not-found") {
784
+ return UI_TEXT.inputUnavailable;
785
+ }
786
+ return UI_TEXT.noInput;
787
+ }
788
+
789
+ async function renderDagNodeInput(content, dagRunId, node) {
790
+ const nodeId = node.nodeId;
791
+ const identity = makeDagNodeInputIdentity(dagRunId, nodeId);
792
+ uiState.dagNodeInputIdentity = identity;
793
+ uiState.dagNodeInputLoading = true;
794
+ uiState.dagNodeInputError = null;
795
+
796
+ clearNode(content);
797
+ const loading = el("p", "muted", UI_TEXT.inputLoading);
798
+ loading.setAttribute("role", "status");
799
+ loading.setAttribute("aria-live", "polite");
800
+ content.appendChild(loading);
801
+
802
+ const result = await fetchDagNodeInput(dagRunId, nodeId);
803
+ if (
804
+ uiState.dagNodeInputIdentity !== identity ||
805
+ uiState.selectedDagNodeId !== nodeId ||
806
+ uiState.currentDagRunId !== dagRunId ||
807
+ uiState.dagInspectorTab !== "input" ||
808
+ !content.isConnected ||
809
+ content.dataset.dagNodeId !== String(nodeId) ||
810
+ content.dataset.dagRunId !== String(dagRunId)
811
+ ) {
812
+ return;
813
+ }
814
+
815
+ uiState.dagNodeInputLoading = false;
816
+ clearNode(content);
817
+
818
+ if (!result.ok) {
819
+ const message =
820
+ result.body?.error ||
821
+ (result.status === 0
822
+ ? UI_TEXT.inputError
823
+ : `${UI_TEXT.inputError}(HTTP ${result.status})`);
824
+ uiState.dagNodeInputError = message;
825
+ uiState.dagNodeInputData = null;
826
+ const error = el("p", "node-input-error", message);
827
+ error.setAttribute("role", "alert");
828
+ content.appendChild(error);
829
+ return;
830
+ }
831
+
832
+ const data = result.body;
833
+ uiState.dagNodeInputData = data;
834
+ uiState.dagNodeInputError = null;
835
+ if (!data?.available) {
836
+ const empty = el(
837
+ "p",
838
+ "empty",
839
+ unavailableMessage(data?.availabilityReason),
840
+ );
841
+ empty.setAttribute("role", "status");
842
+ content.appendChild(empty);
843
+ if ((data?.warnings ?? []).length > 0) {
844
+ for (const warning of data.warnings) {
845
+ content.appendChild(el("p", "node-input-warning", String(warning)));
846
+ }
847
+ }
848
+ return;
849
+ }
850
+
851
+ renderDagNodeInputDetail(content, data);
852
+ }
853
+
533
854
  function fillDagInspectorContent(content, dagRunId, node) {
534
855
  const previousRenderedNode = content.dataset.dagNodeId ?? "";
535
- const nodeChanged = previousRenderedNode !== String(node.nodeId ?? "");
856
+ const previousRenderedRun = content.dataset.dagRunId ?? "";
857
+ const nodeChanged =
858
+ previousRenderedNode !== String(node.nodeId ?? "") ||
859
+ previousRenderedRun !== String(dagRunId ?? "");
536
860
  if (nodeChanged) {
537
861
  // Drop any open spec-evidence preview + cached list so a stale A detail
538
862
  // cannot survive into node B (AC-007 regression guard).
539
863
  uiState.specEvidenceDetail = null;
540
864
  uiState.specEvidenceListEvidence = null;
865
+ uiState.dagNodeInputData = null;
866
+ uiState.dagNodeInputError = null;
541
867
  }
542
- const activeTab = ["timeline", "spec-evidence"].includes(
868
+ const activeTab = ["input", "timeline", "spec-evidence"].includes(
543
869
  uiState.dagInspectorTab,
544
870
  )
545
871
  ? uiState.dagInspectorTab
@@ -548,12 +874,15 @@ function renderSpecEvidenceDetail(content, dagRunId, nodeId) {
548
874
  content.setAttribute("role", "tabpanel");
549
875
  content.setAttribute("aria-labelledby", `dag-inspector-tab-${activeTab}`);
550
876
  content.dataset.dagNodeId = String(node.nodeId ?? "");
877
+ content.dataset.dagRunId = String(dagRunId ?? "");
551
878
  // Drop timeline-only classes when switching tabs so output layout stays clean.
552
879
  content.className = "dag-inspector-content";
553
880
  if (activeTab === "spec-evidence") {
554
881
  void renderSpecEvidence(content, dagRunId, node.nodeId);
555
882
  } else if (activeTab === "timeline") {
556
883
  renderSessionTimeline(content, node.nodeId);
884
+ } else if (activeTab === "input") {
885
+ void renderDagNodeInput(content, dagRunId, node);
557
886
  } else if (!node.outputPreview && !node.errorPreview) {
558
887
  content.appendChild(el("p", "empty", UI_TEXT.noOutput));
559
888
  } else {
@@ -592,6 +921,7 @@ function buildDagInspectorHeader(dagRunId, node) {
592
921
  header.appendChild(controls);
593
922
 
594
923
  const tabDefs = [
924
+ ["input", "节点输入", "ri-login-box-line"],
595
925
  ["output", "节点输出", "ri-file-text-line"],
596
926
  ["timeline", "执行过程", "ri-route-line"],
597
927
  ["spec-evidence", "规范证据", "ri-book-open-line"],
@@ -660,6 +990,17 @@ function syncDagInspectorHeader(panel, dagRunId, node) {
660
990
  }
661
991
 
662
992
  function dagInspectorSignature(dagRunId, node) {
993
+ const inputRevision =
994
+ uiState.dagInspectorTab === "input"
995
+ ? [
996
+ uiState.dagNodeInputIdentity,
997
+ uiState.dagNodeInputLoading,
998
+ uiState.dagNodeInputError ?? "",
999
+ uiState.dagNodeInputData?.availabilityReason ?? "",
1000
+ uiState.dagNodeInputData?.promptFingerprint?.sha256Prefix ?? "",
1001
+ node.status ?? "",
1002
+ ]
1003
+ : null;
663
1004
  return JSON.stringify([
664
1005
  dagRunId,
665
1006
  node.nodeId,
@@ -667,6 +1008,7 @@ function dagInspectorSignature(dagRunId, node) {
667
1008
  node.outputPreview ?? "",
668
1009
  node.errorPreview ?? "",
669
1010
  uiState.dagInspectorTab === "timeline" ? uiState.sessionEventOffset : null,
1011
+ inputRevision,
670
1012
  ]);
671
1013
  }
672
1014
 
@@ -702,17 +1044,30 @@ export function renderDagInspector(dagRunId, node, initial = true) {
702
1044
  syncDagInspectorHeader(panel, dagRunId, node);
703
1045
  if (panel.dataset.contentSignature === nextContentSignature) return;
704
1046
  const previousNode = existingContent.dataset.dagNodeId ?? "";
705
- const nodeChanged = previousNode !== String(node.nodeId ?? "");
706
- const followLatest =
707
- nodeChanged ||
708
- computeFollowLatest(
709
- existingContent.scrollTop,
710
- existingContent.scrollHeight,
711
- existingContent.clientHeight,
712
- );
713
- const regionKey = `dag:${dagRunId}:${node.nodeId}:output`;
714
- uiState.dagNodeOutputViewportState = updateDagOutputViewportState(
715
- uiState.dagNodeOutputViewportState,
1047
+ const previousRun = existingContent.dataset.dagRunId ?? "";
1048
+ const nodeChanged =
1049
+ previousNode !== String(node.nodeId ?? "") ||
1050
+ previousRun !== String(dagRunId ?? "");
1051
+ const activeTab = ["input", "timeline", "spec-evidence"].includes(
1052
+ uiState.dagInspectorTab,
1053
+ )
1054
+ ? uiState.dagInspectorTab
1055
+ : "output";
1056
+ const isInputTab = activeTab === "input";
1057
+ const followLatest = isInputTab
1058
+ ? false
1059
+ : nodeChanged ||
1060
+ computeFollowLatest(
1061
+ existingContent.scrollTop,
1062
+ existingContent.scrollHeight,
1063
+ existingContent.clientHeight,
1064
+ );
1065
+ const regionKey = `dag:${dagRunId}:${node.nodeId}:${isInputTab ? "input" : "output"}`;
1066
+ const viewportUpdater = isInputTab
1067
+ ? "dagNodeInputViewportState"
1068
+ : "dagNodeOutputViewportState";
1069
+ uiState[viewportUpdater] = updateDagOutputViewportState(
1070
+ uiState[viewportUpdater],
716
1071
  regionKey,
717
1072
  existingContent.scrollLeft,
718
1073
  existingContent.scrollTop,
@@ -729,7 +1084,8 @@ export function renderDagInspector(dagRunId, node, initial = true) {
729
1084
  if (followLatest) {
730
1085
  existingContent.scrollTop = existingContent.scrollHeight;
731
1086
  } else {
732
- existingContent.scrollTop = uiState.dagNodeOutputViewportState.scrollTop;
1087
+ const saved = uiState[viewportUpdater];
1088
+ existingContent.scrollTop = nodeChanged ? 0 : (saved?.scrollTop ?? 0);
733
1089
  }
734
1090
  for (const [index, pre] of [
735
1091
  ...existingContent.querySelectorAll("pre"),
@@ -773,6 +773,109 @@ async function readPytestExitCode(runDir, fromNodeId, exitRelativePath = "report
773
773
  }
774
774
  throw new Error("missing valid pytest exit evidence");
775
775
  }
776
+ /**
777
+ * Materialize Result v1 from a pytest-html 4.x self-contained report (Markdown-first
778
+ * 8-node pipeline). `junit.relativePath` records the HTML path for integrity hashing
779
+ * (same field used historically for JUnit XML path).
780
+ */
781
+ export async function materializeBackendTestResultFromPytestHtml(input) {
782
+ const htmlRelativePath = input.htmlRelativePath ?? "reports/backend-test.html";
783
+ const artifactName = input.artifactName ?? "backend-test-result.json";
784
+ const outputDir = input.outputDir ?? "contracts";
785
+ if (!/^[a-z0-9][a-z0-9._-]*\.json$/.test(artifactName) ||
786
+ !/^[a-z0-9][a-z0-9._-]*$/.test(outputDir)) {
787
+ throw new Error("unsafe structured artifact path");
788
+ }
789
+ let html = input.htmlContent;
790
+ if (html === undefined) {
791
+ const htmlAbs = path.join(input.runDir, ...htmlRelativePath.split("/"));
792
+ try {
793
+ html = await readFile(htmlAbs, "utf8");
794
+ }
795
+ catch {
796
+ throw new Error(`missing pytest-html report at ${htmlRelativePath} (fail-closed for Result v1)`);
797
+ }
798
+ }
799
+ if (!html.trim()) {
800
+ throw new Error("invalid pytest-html report: empty report");
801
+ }
802
+ let parsed;
803
+ try {
804
+ parsed = parsePytestHtmlReport(html);
805
+ }
806
+ catch (error) {
807
+ throw new Error(`invalid pytest-html report: ${error instanceof Error ? error.message : String(error)}`);
808
+ }
809
+ const sha256 = createHash("sha256").update(html).digest("hex");
810
+ const exit = input.pytestExitCode;
811
+ let executionStatus = "completed";
812
+ let collectionStatus = "ok";
813
+ let outcome = "passed";
814
+ const looksLikeCollection = exit === 2 ||
815
+ (parsed.errors > 0 && parsed.passed + parsed.failed === 0) ||
816
+ parsed.failures.some((f) => f.kind === "error" &&
817
+ /collect|import|syntax/i.test(`${f.name} ${f.message}`));
818
+ if (looksLikeCollection && (parsed.errors > 0 || exit >= 2)) {
819
+ executionStatus = "collection-error";
820
+ collectionStatus = "error";
821
+ outcome = "collection-error";
822
+ }
823
+ else if (exit >= 2 && parsed.failed === 0 && parsed.errors === 0) {
824
+ executionStatus = "command-error";
825
+ collectionStatus = "unknown";
826
+ outcome = "command-error";
827
+ }
828
+ else if (parsed.failed > 0 || parsed.errors > 0 || exit === 1) {
829
+ executionStatus = "completed";
830
+ collectionStatus = "ok";
831
+ outcome = "completed-with-failures";
832
+ }
833
+ else if (exit === 0 && parsed.failed === 0 && parsed.errors === 0) {
834
+ executionStatus = "completed";
835
+ collectionStatus = "ok";
836
+ outcome = "passed";
837
+ }
838
+ else {
839
+ executionStatus = "command-error";
840
+ collectionStatus = "unknown";
841
+ outcome = "command-error";
842
+ }
843
+ const commandSummary = input.commandSummary ??
844
+ `PYTHONUTF8=1 PYTHONIOENCODING=utf-8 PYTHONDONTWRITEBYTECODE=1 python -m pytest -v -p no:cacheprovider --html=${htmlRelativePath} --self-contained-html`;
845
+ assertNoSecrets("commandSummary", commandSummary);
846
+ const result = backendTestResultContractSchema.parse({
847
+ schemaVersion: 1,
848
+ executionStatus,
849
+ pytestExitCode: exit,
850
+ collectionStatus,
851
+ tests: parsed.tests,
852
+ passed: parsed.passed,
853
+ failed: parsed.failed,
854
+ error: parsed.errors,
855
+ skipped: parsed.skipped,
856
+ durationMs: parsed.durationMs,
857
+ junit: {
858
+ relativePath: htmlRelativePath,
859
+ sha256,
860
+ },
861
+ commandSummary,
862
+ failures: parsed.failures.map((f) => ({
863
+ classname: f.classname || "unknown",
864
+ name: f.name || "unknown",
865
+ message: truncate(f.message || "failure"),
866
+ kind: f.kind,
867
+ })),
868
+ outcome,
869
+ });
870
+ const relativePath = path.posix.join(outputDir, artifactName);
871
+ const artifactPath = await writeDagRunJsonArtifact(input.runDir, relativePath, result);
872
+ const serialized = `${JSON.stringify(result, null, 2)}\n`;
873
+ return {
874
+ path: artifactPath,
875
+ sha256: createHash("sha256").update(serialized).digest("hex"),
876
+ schemaId: BACKEND_TEST_RESULT_SCHEMA_ID,
877
+ };
878
+ }
776
879
  export async function materializeBackendTestResultFromRunDir(input) {
777
880
  if (!/^[a-z0-9][a-z0-9._-]*\.json$/.test(input.artifactName) ||
778
881
  !/^[a-z0-9][a-z0-9._-]*$/.test(input.outputDir)) {