agentic-dev-loop 0.10.5__tar.gz → 0.11.0__tar.gz

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 (138) hide show
  1. {agentic_dev_loop-0.10.5/src/agentic_dev_loop.egg-info → agentic_dev_loop-0.11.0}/PKG-INFO +1 -1
  2. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/pyproject.toml +1 -1
  3. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/engine/graph.py +4 -2
  4. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/viz/__init__.py +2 -2
  5. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/viz/__main__.py +5 -3
  6. agentic_dev_loop-0.11.0/src/agentic_dev_loop/viz/layout.py +233 -0
  7. agentic_dev_loop-0.11.0/src/agentic_dev_loop/viz/page.py +9 -0
  8. agentic_dev_loop-0.11.0/src/agentic_dev_loop/viz/server.py +470 -0
  9. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0/src/agentic_dev_loop.egg-info}/PKG-INFO +1 -1
  10. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop.egg-info/SOURCES.txt +6 -1
  11. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_029_merge_on_conflict.py +0 -5
  12. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_033_brief_step.py +0 -2
  13. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_035_finalize_nudge.py +6 -5
  14. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_036_banned_commands_and_lane_boundary.py +0 -11
  15. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_041_brief_coverage.py +1 -40
  16. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_046_brief_prompt.py +1 -15
  17. agentic_dev_loop-0.11.0/tests/test_055_cli.py +23 -0
  18. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_actions.py +0 -6
  19. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_agents.py +0 -22
  20. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_aidlc.py +0 -15
  21. agentic_dev_loop-0.11.0/tests/test_build_viz_page.py +68 -0
  22. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_cli.py +4 -61
  23. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_config.py +1 -90
  24. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_containers.py +1 -25
  25. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_deps.py +2 -3
  26. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_dry_run.py +0 -8
  27. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_e2e_fixtures.py +0 -28
  28. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_graph.py +0 -31
  29. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_isolation.py +1 -65
  30. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_isolation_pbt.py +3 -50
  31. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_llm.py +0 -1
  32. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_loaders.py +0 -3
  33. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_proc.py +4 -32
  34. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_publish.py +0 -23
  35. agentic_dev_loop-0.11.0/tests/test_qa_prompt.py +30 -0
  36. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_registry.py +0 -5
  37. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_results.py +0 -22
  38. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_secrets.py +2 -11
  39. agentic_dev_loop-0.11.0/tests/test_source_resolution.py +62 -0
  40. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_stages.py +0 -84
  41. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_sync_back.py +0 -9
  42. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_trigger_script.py +0 -6
  43. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_unit5_pbt.py +10 -32
  44. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_viz.py +335 -320
  45. agentic_dev_loop-0.11.0/tests/test_viz_demo.py +126 -0
  46. agentic_dev_loop-0.11.0/tests/test_viz_layout.py +275 -0
  47. agentic_dev_loop-0.10.5/src/agentic_dev_loop/viz/server.py +0 -1273
  48. agentic_dev_loop-0.10.5/tests/test_055_cli.py +0 -58
  49. agentic_dev_loop-0.10.5/tests/test_qa_prompt.py +0 -53
  50. agentic_dev_loop-0.10.5/tests/test_source_resolution.py +0 -21
  51. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/LICENSE +0 -0
  52. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/README.md +0 -0
  53. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/setup.cfg +0 -0
  54. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/__init__.py +0 -0
  55. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/core/__init__.py +0 -0
  56. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/core/config.py +0 -0
  57. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/core/paths.py +0 -0
  58. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/core/profiles/__init__.py +0 -0
  59. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/core/profiles/data/dotnet-node.yaml +0 -0
  60. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/core/profiles/data/python.yaml +0 -0
  61. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/core/results.py +0 -0
  62. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/core/verdict.py +0 -0
  63. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/engine/__init__.py +0 -0
  64. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/engine/actions.py +0 -0
  65. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/engine/agent_error_guide.md +0 -0
  66. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/engine/agent_error_guide.py +0 -0
  67. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/engine/agents.py +0 -0
  68. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/engine/allowlist.py +0 -0
  69. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/engine/brief_coverage.py +0 -0
  70. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/engine/brief_prompt.md +0 -0
  71. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/engine/brief_prompt.py +0 -0
  72. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/engine/cli.py +0 -0
  73. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/engine/containers.py +0 -0
  74. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/engine/deps.py +0 -0
  75. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/engine/dryrun.py +0 -0
  76. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/engine/egress_policy.py +0 -0
  77. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/engine/egress_proxy.py +0 -0
  78. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/engine/extensions/__init__.py +0 -0
  79. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/engine/extensions/aidlc.py +0 -0
  80. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/engine/forbidden.py +0 -0
  81. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/engine/hostboundary.py +0 -0
  82. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/engine/isolation.py +0 -0
  83. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/engine/llm.py +0 -0
  84. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/engine/loaders.py +0 -0
  85. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/engine/mounts.py +0 -0
  86. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/engine/onboarding/__init__.py +0 -0
  87. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/engine/onboarding/checks.py +0 -0
  88. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/engine/onboarding/doctor.py +0 -0
  89. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/engine/onboarding/init.py +0 -0
  90. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/engine/onboarding/recipes/dotnet-node.yaml +0 -0
  91. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/engine/onboarding/recipes/python.yaml +0 -0
  92. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/engine/onboarding/recipes.py +0 -0
  93. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/engine/onboarding/templates/dev.agent.md +0 -0
  94. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/engine/onboarding/templates/env.example +0 -0
  95. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/engine/onboarding/templates/qa.agent.md +0 -0
  96. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/engine/onboarding/templates/review.agent.md +0 -0
  97. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/engine/onboarding/templates/trigger-agentic-dev-loop.sh +0 -0
  98. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/engine/onboarding/templates.py +0 -0
  99. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/engine/onboarding/upgrade.py +0 -0
  100. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/engine/outputs.py +0 -0
  101. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/engine/presets.py +0 -0
  102. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/engine/proc.py +0 -0
  103. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/engine/registry.py +0 -0
  104. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/engine/seccomp/__init__.py +0 -0
  105. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/engine/seccomp/chromium-userns.json +0 -0
  106. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/engine/secrets.py +0 -0
  107. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/engine/stages.py +0 -0
  108. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/engine/step_workspace.py +0 -0
  109. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/engine/sync_back.py +0 -0
  110. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop/py.typed +0 -0
  111. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
  112. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
  113. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop.egg-info/requires.txt +0 -0
  114. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
  115. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_055_doctor.py +0 -0
  116. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_055_init.py +0 -0
  117. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_055_profile_loading.py +0 -0
  118. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_055_profiles.py +0 -0
  119. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_055_recipes.py +0 -0
  120. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_055_schema_lock.py +0 -0
  121. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_055_templates.py +0 -0
  122. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_055_upgrade.py +0 -0
  123. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_agent_error_guide.py +0 -0
  124. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_egress_proxy.py +0 -0
  125. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_hostboundary.py +0 -0
  126. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_hostboundary_integration.py +0 -0
  127. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_isolation_integration.py +0 -0
  128. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_module_contract.py +0 -0
  129. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_outputs.py +0 -0
  130. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_outputs_pbt.py +0 -0
  131. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_repo_ascii.py +0 -0
  132. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_sdk_compat.py +0 -0
  133. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_secrets_integration.py +0 -0
  134. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_secrets_pipeline.py +0 -0
  135. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_step_workspace.py +0 -0
  136. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_unit5_config.py +0 -0
  137. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_unit5_pure.py +0 -0
  138. {agentic_dev_loop-0.10.5 → agentic_dev_loop-0.11.0}/tests/test_verdict.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentic-dev-loop
3
- Version: 0.10.5
3
+ Version: 0.11.0
4
4
  Summary: Turn an LLM into a gated, sandboxed, resumable construction loop over your codebase.
5
5
  Author: Luke Wilson
6
6
  License-Expression: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agentic-dev-loop"
7
- version = "0.10.5"
7
+ version = "0.11.0"
8
8
  description = "Turn an LLM into a gated, sandboxed, resumable construction loop over your codebase."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -275,13 +275,15 @@ def _run_pipeline(cfg, base, registry, state_dir, max_attempts, args, viz_bus,
275
275
  if bus is None and not getattr(args, "no_viz", True):
276
276
  start_state = initial if initial is not None \
277
277
  else graph.get_state(config).values
278
- topology, nodes = viz.topology_mermaid(cfg)
278
+ topology = viz.topology_graph(cfg)
279
279
  bus = viz.VizBus()
280
280
  if args.viz_port:
281
281
  kill_listeners_on_port(args.viz_port, match="agentic-dev-loop",
282
282
  cwd=runtime.workspace)
283
283
  httpd, _thread = viz.serve_viz(
284
- bus, topology, start_state["subject"], args.viz_port, nodes)
284
+ bus, topology["mermaid"], start_state["subject"],
285
+ args.viz_port, topology["nodes"], topology["flow"],
286
+ topology["edges"])
285
287
  bound = httpd.server_address[1]
286
288
  url = f"http://127.0.0.1:{bound}"
287
289
  if args.viz_port and bound != args.viz_port:
@@ -4,10 +4,10 @@ import time # noqa: F401 - re-exported so tests can pin NodeWatch's clock
4
4
 
5
5
  from .server import (MAX_LINE, PAGE, ConsoleCapture, HeartbeatThread,
6
6
  NodeWatch, VizBus, _ReuseSafeServer, _Tee,
7
- _handler_class, serve_viz, topology_mermaid, translate)
7
+ _handler_class, serve_viz, topology_graph, topology_mermaid, translate)
8
8
 
9
9
  __all__ = [
10
10
  "MAX_LINE", "PAGE", "ConsoleCapture", "HeartbeatThread", "NodeWatch",
11
11
  "VizBus", "_ReuseSafeServer", "_Tee", "_handler_class", "serve_viz",
12
- "topology_mermaid", "translate",
12
+ "topology_graph", "topology_mermaid", "translate",
13
13
  ]
@@ -8,7 +8,7 @@ import sys
8
8
 
9
9
  from agentic_dev_loop.core.config import load_config
10
10
 
11
- from . import VizBus, serve_viz, topology_mermaid
11
+ from . import VizBus, serve_viz, topology_graph
12
12
 
13
13
 
14
14
  def main(argv: list[str] | None = None) -> int:
@@ -23,9 +23,11 @@ def main(argv: list[str] | None = None) -> int:
23
23
  args = parser.parse_args(argv)
24
24
 
25
25
  cfg = load_config(args.pipeline)
26
- topology, nodes = topology_mermaid(cfg)
26
+ topology = topology_graph(cfg)
27
27
  bus = VizBus()
28
- httpd, _thread = serve_viz(bus, topology, cfg.name, args.port, nodes)
28
+ httpd, _thread = serve_viz(bus, topology["mermaid"], cfg.name, args.port,
29
+ topology["nodes"], topology["flow"],
30
+ topology["edges"])
29
31
  bound = httpd.server_address[1]
30
32
  url = f"http://127.0.0.1:{bound}"
31
33
  print(f"Live graph viz: {url}", flush=True)
@@ -0,0 +1,233 @@
1
+ """Pure-Python port of the viz pipeline layout algorithm.
2
+
3
+ Mirrors ``planning-docs/design/pipeline-layout.js`` one-to-one (contract in
4
+ its header and in the unit-9 business-logic model): no DOM, no I/O, same
5
+ input/output shapes and the same crossing-free ordering rules. The reference
6
+ ES5 implementation remains the browser-side copy; this port is the tested
7
+ source of truth (decision Q3=A).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any
13
+
14
+ NW = 112
15
+ NH = 76
16
+ GAP = 24
17
+ STEP = NW + GAP
18
+ LANE = 18
19
+ LANE_TOP = 14
20
+ LANE_PAD = 16
21
+ CH = 12
22
+
23
+ METRICS = {"NW": NW, "NH": NH, "GAP": GAP, "STEP": STEP, "LANE": LANE}
24
+
25
+
26
+ class _Edge:
27
+ """Mutable routing record, mirroring the JS object the reference mutates."""
28
+
29
+ __slots__ = ("src", "dst", "label", "kind", "si", "ti", "rs", "rt",
30
+ "lane_s", "lane_t", "j", "k", "xe")
31
+
32
+ def __init__(self, src: str, dst: str, label: str | None, kind: str):
33
+ self.src = src
34
+ self.dst = dst
35
+ self.label = label
36
+ self.kind = kind
37
+ self.si = 0
38
+ self.ti = 0
39
+ self.rs = 0
40
+ self.rt = 0
41
+ self.lane_s = 0
42
+ self.lane_t = 0
43
+ self.j = 0
44
+ self.k = 0
45
+ self.xe = 0
46
+
47
+
48
+ def _plan(names: list[str], routed: list[_Edge], idx: dict[str, int], r: int) -> dict[str, Any]:
49
+ n = len(names)
50
+ per = max(1, (n + r - 1) // r)
51
+ rows = max(1, (n + per - 1) // per)
52
+ pos = [{"r": i // per, "c": i % per} for i in range(n)]
53
+ edges = []
54
+ for e in routed:
55
+ rec = _Edge(e.src, e.dst, e.label, e.kind)
56
+ rec.si = idx[e.src]
57
+ rec.ti = idx[e.dst]
58
+ rec.rs = pos[rec.si]["r"]
59
+ rec.rt = pos[rec.ti]["r"]
60
+ edges.append(rec)
61
+ same = sorted((e for e in edges if e.rs == e.rt),
62
+ key=lambda e: abs(e.si - e.ti))
63
+ back = sorted((e for e in edges if e.rs > e.rt),
64
+ key=lambda e: (e.rs, e.si))
65
+ forward = sorted((e for e in edges if e.rs < e.rt),
66
+ key=lambda e: (e.rt, -e.si))
67
+ for j, e in enumerate(back):
68
+ e.j = j
69
+ for k, e in enumerate(forward):
70
+ e.k = k
71
+ multi = rows > 1
72
+ left = CH + CH * len(back) + 8 if multi else 0
73
+ core = per * STEP - GAP
74
+ right = (14 if multi else 0) + (8 + CH * len(forward) if forward else 0)
75
+ return {"per": per, "rows": rows, "pos": pos, "U": same, "C": back,
76
+ "F": forward, "O": left, "core": core, "width": left + core + right}
77
+
78
+
79
+ def layout_pipeline(opts: dict[str, Any]) -> dict[str, Any]:
80
+ """Lay out stations and routes for ``opts`` (contract: FD section 1.1)."""
81
+ names = list(opts.get("nodes") or [])
82
+ n = len(names)
83
+ status = opts.get("status") or {}
84
+ avail = max(0, opts.get("availWidth") or 0)
85
+ flow = opts.get("flow") or []
86
+ cond_edges = opts.get("edges") or []
87
+ idx = {name: i for i, name in enumerate(names)}
88
+
89
+ def reached(name: str) -> bool:
90
+ return (status.get(name) or "pending") != "pending"
91
+
92
+ def failed(name: str) -> bool:
93
+ state = status.get(name) or "pending"
94
+ return state == "fail" or state == "fatal"
95
+
96
+ rails: list[dict[str, Any]] = []
97
+ extra_flow: list[_Edge] = []
98
+ for f in flow:
99
+ if f["src"] not in idx or f["dst"] not in idx:
100
+ continue
101
+ if idx[f["dst"]] == idx[f["src"]] + 1:
102
+ rails.append(f)
103
+ else:
104
+ extra_flow.append(_Edge(f["src"], f["dst"], None, "flow"))
105
+ routed = [
106
+ _Edge(e["src"], e["dst"], e.get("label") or None, "back")
107
+ for e in cond_edges
108
+ if e["src"] in idx and e["dst"] in idx
109
+ ] + extra_flow
110
+
111
+ layout = _plan(names, routed, idx, 1)
112
+ r = 2
113
+ while layout["width"] > avail and layout["per"] > 2:
114
+ layout = _plan(names, routed, idx, r)
115
+ r += 1
116
+ offset = layout["O"]
117
+
118
+ gutters: list[dict[str, int]] = []
119
+ for g in range(layout["rows"]):
120
+ items = 0
121
+
122
+ def take() -> int:
123
+ nonlocal items
124
+ value = items
125
+ items += 1
126
+ return value
127
+
128
+ for e in layout["U"]:
129
+ if e.rt == g:
130
+ e.lane_s = take()
131
+ for e in sorted((e for e in layout["C"] if e.rt == g),
132
+ key=lambda e: -e.j):
133
+ e.lane_t = take()
134
+ for e in layout["F"]:
135
+ if e.rt == g:
136
+ e.lane_t = take()
137
+ for e in sorted((e for e in layout["C"] if e.rs == g),
138
+ key=lambda e: e.si):
139
+ e.lane_s = take()
140
+ for e in layout["F"]:
141
+ if e.rs == g:
142
+ e.lane_s = take()
143
+ wrap = take() if g < layout["rows"] - 1 else -1
144
+ gutters.append({"n": items, "wrapLane": wrap})
145
+
146
+ row_y = [0]
147
+ for g, gutter in enumerate(gutters):
148
+ gutter["h"] = (LANE_TOP + (gutter["n"] - 1) * LANE + LANE_PAD
149
+ if gutter["n"] else 4)
150
+ row_y.append(row_y[g] + NH + gutter["h"])
151
+
152
+ def lane_y(g: int, lane: int) -> int:
153
+ return row_y[g] + NH + LANE_TOP + lane * LANE
154
+
155
+ def node_x(i: int) -> int:
156
+ return offset + layout["pos"][i]["c"] * STEP
157
+
158
+ def node_y(i: int) -> int:
159
+ return row_y[layout["pos"][i]["r"]]
160
+
161
+ entry_count: dict[int, int] = {}
162
+ for e in layout["U"] + layout["C"] + layout["F"]:
163
+ m = entry_count.get(e.ti, 0)
164
+ entry_count[e.ti] = m + 1
165
+ e.xe = max(node_x(e.ti) + 8, node_x(e.ti) + NW - 14 - m * 14)
166
+
167
+ lines: list[dict[str, Any]] = []
168
+ arrows: list[dict[str, Any]] = []
169
+ labels: list[dict[str, Any]] = []
170
+
171
+ def horizontal(x1: int, x2: int, y: int, kind: str, on: bool = False) -> None:
172
+ lines.append({"x": min(x1, x2), "y": y, "w": abs(x2 - x1), "h": 0,
173
+ "dir": "h", "kind": kind, "on": bool(on)})
174
+
175
+ def vertical(x: int, y1: int, y2: int, kind: str, on: bool = False) -> None:
176
+ lines.append({"x": x, "y": min(y1, y2), "w": 0, "h": abs(y2 - y1),
177
+ "dir": "v", "kind": kind, "on": bool(on)})
178
+
179
+ for f in rails:
180
+ i = idx[f["src"]]
181
+ on = reached(f["dst"])
182
+ if layout["pos"][i + 1]["r"] == layout["pos"][i]["r"]:
183
+ horizontal(node_x(i) + NW, node_x(i + 1), node_y(i) + 37, "flow", on)
184
+ arrows.append({"x": node_x(i + 1) - 6, "y": node_y(i) + 33,
185
+ "dir": "right", "kind": "flow", "on": on})
186
+ else:
187
+ r = layout["pos"][i]["r"]
188
+ xr = node_x(i) + NW + 12
189
+ yw = lane_y(r, gutters[r]["wrapLane"])
190
+ yn = node_y(i + 1) + 37
191
+ horizontal(node_x(i) + NW, xr + 2, node_y(i) + 37, "flow", on)
192
+ vertical(xr, node_y(i) + 37, yw, "flow", on)
193
+ horizontal(offset - 12, xr + 2, yw - 1, "flow", on)
194
+ vertical(offset - 12, yw - 1, yn + 2, "flow", on)
195
+ horizontal(offset - 12, offset, yn, "flow", on)
196
+ arrows.append({"x": offset - 6, "y": yn - 4, "dir": "right",
197
+ "kind": "flow", "on": on})
198
+
199
+ for e in layout["U"] + layout["C"] + layout["F"]:
200
+ xs = node_x(e.si) + NW // 2
201
+ top = node_y(e.ti) + NH
202
+ y1 = lane_y(e.rs, e.lane_s)
203
+ on = e.kind == "back" and failed(e.src)
204
+ vertical(xs, node_y(e.si) + NH, y1, e.kind, on)
205
+ if e.rs == e.rt:
206
+ horizontal(e.xe, xs, y1, e.kind, on)
207
+ vertical(e.xe, top + 6, y1, e.kind, on)
208
+ else:
209
+ if e.rs > e.rt:
210
+ xc = offset - CH - CH * (e.j + 1)
211
+ else:
212
+ xc = offset + layout["core"] + 14 + 8 + CH * e.k
213
+ y2 = lane_y(e.rt, e.lane_t)
214
+ horizontal(xc, xs, y1, e.kind, on)
215
+ vertical(xc, y2, y1, e.kind, on)
216
+ horizontal(xc, e.xe, y2, e.kind, on)
217
+ vertical(e.xe, top + 6, y2, e.kind, on)
218
+ arrows.append({"x": e.xe - 5, "y": top, "dir": "up", "kind": e.kind,
219
+ "on": on})
220
+ if e.label:
221
+ labels.append({"x": xs - 6, "y": y1 - 9, "text": e.label})
222
+
223
+ return {
224
+ "width": layout["width"],
225
+ "height": row_y[layout["rows"]],
226
+ "rows": layout["rows"],
227
+ "perRow": layout["per"],
228
+ "nodes": [{"id": name, "index": i, "x": node_x(i), "y": node_y(i),
229
+ "w": NW, "h": NH} for i, name in enumerate(names)],
230
+ "lines": lines,
231
+ "arrows": arrows,
232
+ "labels": labels,
233
+ }
@@ -0,0 +1,9 @@
1
+ """The shipped viz page.
2
+
3
+ Generated by ``tools/build_viz_page.py`` from ``viz/assets/*`` - edit the
4
+ assets, not this file, and rebuild.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ PAGE = "<!doctype html>\n<html lang=\"en\" data-theme=\"dark\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>agentic-dev-loop viz</title>\n<style>\n/* agentic-dev-loop viz - Carbon re-skin. Tokens are the exact values from\n planning-docs/design/tokens.css (data-theme scoped). */\n\n:root,\n:root[data-theme=\"dark\"] {\n color-scheme: dark;\n --cds-background: #0F1214;\n --cds-layer-01: #171B1F;\n --cds-layer-02: #20262B;\n --cds-layer-03: #2A3137;\n --cds-layer-hover-01: #20262B;\n --cds-border-subtle-01: #2E353B;\n --cds-border-strong-01: #4D565E;\n --cds-text-primary: #EEF1F3;\n --cds-text-secondary: #A3ACB4;\n --cds-text-helper: #8A949C;\n --cds-icon-primary: #EEF1F3;\n --cds-icon-secondary: #A3ACB4;\n --cds-interactive: #08BDBA;\n --cds-focus: #3DDBD9;\n --cds-link-primary: #3DDBD9;\n --cds-support-success: #42BE65;\n --cds-support-warning: #F1C21B;\n --cds-support-error: #FF6B72;\n --cds-support-info: #08BDBA;\n --adl-support-unverifiable: #BE95FF;\n --adl-tint-run: rgba(8, 189, 186, .14);\n --adl-tint-ok: rgba(66, 190, 101, .11);\n --adl-tint-warn: rgba(241, 194, 27, .12);\n --adl-tint-err: rgba(250, 77, 86, .14);\n --adl-tint-unv: rgba(165, 110, 255, .14);\n --adl-console: #0B0E10;\n}\n\n:root[data-theme=\"light\"] {\n color-scheme: light;\n --cds-background: #F2F4F5;\n --cds-layer-01: #FFFFFF;\n --cds-layer-02: #F4F6F7;\n --cds-layer-03: #E6E9EB;\n --cds-layer-hover-01: #F4F6F7;\n --cds-border-subtle-01: #DDE1E4;\n --cds-border-strong-01: #8D969E;\n --cds-text-primary: #15191C;\n --cds-text-secondary: #4C555D;\n --cds-text-helper: #5F6870;\n --cds-icon-primary: #15191C;\n --cds-icon-secondary: #4C555D;\n --cds-interactive: #007D79;\n --cds-focus: #007D79;\n --cds-link-primary: #007D79;\n --cds-support-success: #198038;\n --cds-support-warning: #8E6A00;\n --cds-support-error: #DA1E28;\n --cds-support-info: #007D79;\n --adl-support-unverifiable: #8A3FFC;\n --adl-tint-run: rgba(0, 125, 121, .09);\n --adl-tint-ok: rgba(25, 128, 56, .08);\n --adl-tint-warn: rgba(241, 194, 27, .18);\n --adl-tint-err: rgba(218, 30, 40, .08);\n --adl-tint-unv: rgba(138, 63, 252, .08);\n --adl-console: #121619;\n}\n\n:root {\n --adl-console-text: #D6DCE0;\n --adl-console-muted: #8A949C;\n --adl-console-control: #20262B;\n --adl-console-control-hover: #2A3137;\n --adl-console-scroll-thumb: #4D565E;\n --adl-font-sans: \"IBM Plex Sans\", system-ui, sans-serif;\n --adl-font-mono: \"IBM Plex Mono\", ui-monospace, SFMono-Regular, Menlo, monospace;\n --adl-radius: 0;\n --adl-motion-productive: cubic-bezier(.2, 0, .38, .9);\n}\n\n* { box-sizing: border-box; }\nhtml, body { height: 100%; }\nbody {\n margin: 0; background: var(--cds-background); color: var(--cds-text-primary);\n font-family: var(--adl-font-sans); font-size: 13px;\n display: flex; flex-direction: column; overflow: hidden;\n}\nbutton { font-family: inherit; border-radius: var(--adl-radius); }\n:focus-visible { outline: 2px solid var(--cds-focus); outline-offset: -2px; }\n.mono, .big, .badge, .elabel, .stage-index, .stage-time, .stage-status,\n.stage-verdict, .nidx, .ntime, .nlabel, .stream, #run-log, .stage-console {\n font-family: var(--adl-font-mono); font-variant-numeric: tabular-nums;\n}\n.skip { position: absolute; left: -9999px; }\n.skip:focus { left: 8px; top: 8px; z-index: 100; background: var(--cds-layer-01);\n padding: 8px; }\n\n/* Header */\n.hdr {\n flex: 0 0 48px; display: flex; align-items: center; gap: 16px;\n padding: 0 16px; background: var(--cds-layer-01);\n border-bottom: 1px solid var(--cds-border-subtle-01);\n}\n.hdr-brand { display: flex; align-items: center; gap: 10px; font-weight: 600; }\n.mark { width: 10px; height: 10px; background: var(--cds-interactive); display: block; }\n.hdr-right { margin-left: auto; display: flex; align-items: center; gap: 12px; }\n.stream { display: inline-flex; align-items: center; gap: 6px; font-size: 11px;\n letter-spacing: .08em; text-transform: uppercase; color: var(--cds-text-secondary); }\n.led { width: 8px; height: 8px; display: inline-block; background: currentColor; }\n.stream .led { background: var(--cds-interactive); }\n.stream.reconnecting { color: var(--cds-support-warning); }\n.stream.reconnecting .led { background: var(--cds-support-warning); }\n.hdr-action {\n min-width: 44px; min-height: 44px; display: inline-flex; align-items: center;\n justify-content: center; background: transparent; color: var(--cds-icon-primary);\n border: 1px solid transparent; cursor: pointer;\n}\n.hdr-action:hover { background: var(--cds-layer-hover-01); }\n.ic { width: 16px; height: 16px; display: block; position: relative; }\n.ic-sun, .ic-moon { position: absolute; top: 0; left: 0; display: block; }\n/* dark theme shows the sun (click to switch to light); light shows the moon */\n:root[data-theme=\"light\"] .ic-sun { display: none; }\n:root[data-theme=\"dark\"] .ic-moon { display: none; }\n\n/* Banners */\n#stream-banner {\n display: none; padding: 8px 16px; background: var(--adl-tint-warn);\n color: var(--cds-text-primary);\n border-bottom: 1px solid var(--cds-support-warning); font-size: 12px;\n}\n.outcome { display: none; padding: 10px 14px; border: 1px solid; font-size: 12px; }\n.outcome-success { background: var(--adl-tint-ok); border-color: var(--cds-support-success); }\n.outcome-failure { background: var(--adl-tint-err); border-color: var(--cds-support-error); }\n.outcome-title { font-weight: 700; letter-spacing: .08em; }\n.outcome-sub { color: var(--cds-text-secondary); margin-top: 2px; }\n\n/* Main column */\n.shell {\n flex: 1 1 auto; min-height: 0; width: 100%; max-width: 1600px; margin: 0 auto;\n padding: 12px 24px 16px; display: flex; flex-direction: column; gap: 12px;\n}\n.tiles { display: flex; gap: 1px; background: var(--cds-border-subtle-01);\n border: 1px solid var(--cds-border-subtle-01); }\n.tile {\n display: block; flex: 1 1 0; min-width: 0; padding: 8px 10px;\n background: var(--cds-layer-01);\n}\n.lbl { font-size: 11px; text-transform: uppercase; letter-spacing: .08em;\n color: var(--cds-text-helper); margin-bottom: 4px; }\n.big { font-size: 16px; color: var(--cds-text-primary); overflow-wrap: break-word; }\n#thread-id { cursor: pointer; user-select: all; background: var(--cds-layer-02);\n padding: 1px 4px; border: 1px solid var(--cds-border-subtle-01); font-size: 12px; }\n#thread-id.copied { border-color: var(--cds-support-success);\n color: var(--cds-support-success); }\n#total { display: none; }\n.total-note { font-size: 11px; color: var(--cds-text-helper); margin-left: 6px; }\n.meter { display: flex; gap: 2px; margin-top: 5px; }\n.meter span { width: 8px; height: 6px; background: var(--cds-border-strong-01); }\n.meter span.on { background: var(--cds-interactive); }\n.meter.exhausted span.on { background: var(--cds-support-error); }\n\n.verdicts { display: flex; flex-wrap: wrap; gap: 6px; }\n.badge { font-size: 11px; padding: 2px 6px; border: 1px solid var(--cds-border-strong-01);\n color: var(--cds-text-secondary); background: var(--cds-layer-01); }\n.badge-pass { border-color: var(--cds-support-success); color: var(--cds-support-success); }\n.badge-fail { border-color: var(--cds-support-warning); color: var(--cds-support-warning); }\n.badge-unverifiable { border-color: var(--adl-support-unverifiable);\n color: var(--adl-support-unverifiable); }\n.badge-unclear { border-color: var(--cds-border-strong-01); }\n\n/* Panels and pipeline */\n.panel { border: 1px solid var(--cds-border-subtle-01); background: var(--cds-layer-01);\n display: flex; flex-direction: column; min-height: 0; }\n.panel-h { display: flex; align-items: center; justify-content: space-between;\n padding: 6px 10px; border-bottom: 1px solid var(--cds-border-subtle-01);\n font-size: 11px; text-transform: uppercase; letter-spacing: .08em;\n color: var(--cds-text-helper); }\n#pipeline-panel { flex: 0 0 auto; min-height: 96px; max-height: 60vh; overflow: auto; }\n.graph-wrap { padding: 12px; display: flex; justify-content: center; }\n#graph { position: relative; margin: 0 auto; min-width: 112px; min-height: 80px; }\n.graph-msg { color: var(--cds-text-helper); font-size: 12px; margin: 8px; }\n\n.lower { flex: 1 1 auto; min-height: 0; display: flex; gap: 12px; }\n.panel-stages { flex: 999 1 640px; min-width: 0; }\n.panel-log { flex: 1 1 360px; min-width: 0; }\n#stages { overflow-y: auto; min-height: 0; }\n#run-log { list-style: none; margin: 0; padding: 6px 10px; overflow-y: auto;\n min-height: 0; font-size: 12px; }\n#run-log li { padding: 2px 0; border-bottom: 1px solid var(--cds-border-subtle-01);\n color: var(--cds-text-secondary); }\n#run-log li.started { color: var(--cds-text-primary); }\n#run-log li.interrupted { color: var(--cds-support-warning); }\n#run-log li.failed { color: var(--cds-support-error); }\n#run-log li.success { color: var(--cds-support-success); }\n\n/* Stations */\n.node {\n position: absolute; width: 112px; height: 76px; padding: 7px 9px;\n background: var(--cds-layer-01); border: 1px solid var(--cds-border-strong-01);\n display: flex; flex-direction: column; justify-content: space-between;\n overflow: hidden;\n}\n.nrow { display: flex; justify-content: space-between; font-size: 11px; color: var(--cds-text-helper); }\n.ntime { color: var(--cds-text-secondary); }\n.nname { font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden;\n text-overflow: ellipsis; }\n.nstat { display: flex; align-items: center; gap: 5px; font-size: 11px;\n text-transform: uppercase; letter-spacing: .06em; }\n.nlabel { font-size: 11px; }\n.st-pass { background: var(--adl-tint-ok); border-color: var(--cds-support-success); }\n.st-pass .led, .st-pass .nlabel { background: var(--cds-support-success); }\n.st-pass .nlabel { background: transparent; color: var(--cds-support-success); }\n.st-running { background: var(--adl-tint-run); border: 2px solid var(--cds-interactive); padding: 6px 8px; }\n.st-running .led { background: var(--cds-interactive); animation: blink 1s steps(2) infinite; }\n.st-running .nlabel { color: var(--cds-interactive); }\n.st-running::after { content: \"\"; position: absolute; left: 0; bottom: 0; height: 3px;\n width: 100%; background: var(--cds-interactive); transform-origin: left;\n animation: sweep 1.4s var(--adl-motion-productive) infinite; }\n.st-fail { background: var(--adl-tint-warn); border-color: var(--cds-support-warning); }\n.st-fail .led { background: var(--cds-support-warning); }\n.st-fail .nlabel { color: var(--cds-support-warning); }\n.st-fatal { background: var(--adl-tint-err); border: 2px solid var(--cds-support-error); padding: 6px 8px; }\n.st-fatal .led { background: var(--cds-support-error); }\n.st-fatal .nlabel { color: var(--cds-support-error); }\n.st-unverifiable { background: var(--adl-tint-unv); border-color: var(--adl-support-unverifiable); }\n.st-unverifiable .led { background: var(--adl-support-unverifiable); }\n.st-unverifiable .nlabel { color: var(--adl-support-unverifiable); }\n.st-pending { background: var(--cds-layer-01); border: 1px dashed var(--cds-border-strong-01); }\n.st-pending .led { background: transparent; border: 1px solid var(--cds-border-strong-01); }\n.st-pending .nlabel { color: var(--cds-text-secondary); }\n\n.ln { position: absolute; }\n.ln-flow { background: var(--cds-border-strong-01); }\n.ln-flow.on { background: var(--cds-support-success); }\n.ln-back { color: var(--cds-border-strong-01); }\n.ln-back.on { color: var(--cds-support-warning); }\n.ln-back[data-dir=\"h\"] { border-top: 1px dashed currentColor; }\n.ln-back[data-dir=\"v\"] { border-left: 1px dashed currentColor; }\n.arr { position: absolute; width: 0; height: 0; }\n.arr-right { border-top: 5px solid transparent; border-bottom: 5px solid transparent;\n border-left: 7px solid var(--cds-border-strong-01); }\n.arr-flow.on.arr-right { border-left-color: var(--cds-support-success); }\n.arr-back.on.arr-up { border-bottom-color: var(--cds-support-warning); }\n.arr-up { border-left: 5px solid transparent; border-right: 5px solid transparent;\n border-bottom: 7px solid var(--cds-border-strong-01); }\n.elabel { position: absolute; transform: translateX(-100%); background: var(--cds-layer-02);\n border: 1px solid var(--cds-border-subtle-01); color: var(--cds-text-secondary);\n font-size: 11px; padding: 0 4px; white-space: nowrap; }\n\n/* Stages accordion */\n.stage { border-bottom: 1px solid var(--cds-border-subtle-01); }\n.stage-header { display: flex; align-items: center; gap: 6px; width: 100%;\n min-height: 44px; padding: 4px 10px; background: var(--cds-layer-01);\n color: var(--cds-text-primary); border: 0; text-align: left; cursor: pointer;\n border-left: 2px solid transparent; }\n.stage-header:hover { background: var(--cds-layer-hover-01); }\n.stage-header.status-running { border-left-color: var(--cds-interactive); }\n.stage-header.status-pass { border-left-color: var(--cds-support-success); }\n.stage-header.status-fail { border-left-color: var(--cds-support-warning); }\n.stage-header.status-fatal { border-left-color: var(--cds-support-error); }\n.stage-header.status-unverifiable { border-left-color: var(--adl-support-unverifiable); }\n.stage-glyph { width: 10px; color: var(--cds-text-helper); }\n.stage-index { font-size: 11px; color: var(--cds-text-helper); }\n.stage-header .led { color: var(--cds-border-strong-01); }\n.status-running .led { background: var(--cds-interactive); }\n.status-pass .led { background: var(--cds-support-success); }\n.status-fail .led { background: var(--cds-support-warning); }\n.status-fatal .led { background: var(--cds-support-error); }\n.status-unverifiable .led { background: var(--adl-support-unverifiable); }\n.stage-name { font-weight: 600; }\n.stage-status { font-size: 11px; text-transform: uppercase; color: var(--cds-text-secondary); }\n.stage-verdict { font-size: 11px; color: var(--cds-text-helper); }\n.stage-time { margin-left: auto; font-size: 11px; color: var(--cds-text-secondary); }\n.console-wrap { position: relative; max-height: 320px; overflow-y: auto;\n background: var(--adl-console); display: none; }\n.stage:not(.collapsed) .console-wrap { display: block; }\n.stage-console { margin: 0; padding: 8px 10px; color: var(--adl-console-text);\n font-size: 12px; line-height: 1.5; white-space: pre-wrap; word-break: break-all; }\n.stage-console .cline { display: block; min-height: 1em; }\n.stage-console .omitted, .stage-console .stage-placeholder {\n color: var(--adl-console-muted); font-style: italic; }\n.copy-btn { position: sticky; top: 6px; float: right; margin: 6px 6px 0 0;\n width: 44px; height: 44px; display: flex; align-items: center; justify-content: center;\n background: var(--adl-console-control); color: var(--adl-console-text);\n border: 0; cursor: pointer; }\n.copy-btn:hover { background: var(--adl-console-control-hover); }\n\n/* Scrollbars */\n* { scrollbar-width: thin; scrollbar-color: var(--cds-border-strong-01) transparent; }\n*::-webkit-scrollbar { width: 8px; height: 8px; }\n*::-webkit-scrollbar-thumb { background: var(--cds-border-strong-01); }\n*::-webkit-scrollbar-thumb:hover { background: var(--cds-text-helper); }\n.console-wrap { scrollbar-color: var(--adl-console-scroll-thumb) var(--adl-console); }\n\n@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: .25; } }\n@keyframes sweep { 0% { transform: scaleX(0); } 50% { transform: scaleX(1); }\n 100% { transform: scaleX(0); } }\n\n@media (prefers-reduced-motion: reduce) {\n *, *::before, *::after { animation: none !important; transition: none !important; }\n}\n\n@media (max-width: 760px) {\n body { overflow: auto; height: auto; }\n .shell { padding: 12px; }\n .tiles { flex-wrap: wrap; }\n .tiles .tile { flex: 1 1 45%; }\n .lower { flex-direction: column; }\n #pipeline-panel { overflow-x: auto; }\n}\n</style>\n<script>\n/*\n * pipeline-layout.js - reference implementation of the viz pipeline layout.\n *\n * Pure function, no DOM access, ES5 (matches the inline-script style of\n * src/agentic_dev_loop/viz/server.py PAGE). Usable as a <script> (exposes\n * window.layoutPipeline) or via require()/import in tests.\n *\n * Contract\n * layoutPipeline({\n * nodes: [\"brief\", \"deps\", \"dev\", ...], // display order (= /topology nodes)\n * flow: [{src, dst}], // on_pass chain (solid)\n * edges: [{src, dst, label|null}], // on_fail routes (dashed)\n * status: {nodeId: \"pass\"|\"running\"|\"fail\"|\"fatal\"|\"unverifiable\"|\"pending\"},\n * availWidth: 1200 // px available to the graph\n * }) -> {\n * width, height, rows, perRow,\n * nodes: [{id, index, x, y, w, h}],\n * lines: [{x, y, w, h, kind: \"flow\"|\"back\", on: bool, dir: \"h\"|\"v\"}],\n * arrows: [{x, y, dir: \"right\"|\"up\", kind, on}], // x,y = top-left of a CSS-border triangle box\n * labels: [{x, y, text}] // x = right edge (translateX(-100%)), y = top\n * }\n *\n * Algorithm\n * 1. Stations keep natural size (NW x NH). Choose the FEWEST rows R such that\n * the row (perRow = ceil(N/R)) plus routing margins fits availWidth.\n * perRow never drops below 2; if 2 still doesn't fit, the caller scrolls.\n * 2. Consecutive flow edges in a row = rail with arrow. Row change = wrap\n * connector: right stub -> down to the row's deepest gutter lane -> left\n * to the left channel -> down -> right into the next row's first station.\n * 3. Conditional edges route orthogonally:\n * U same row (back or forward): drop into gutter, run, rise into target.\n * C backward across rows: drop into source gutter, run left to a\n * left-hand channel, rise to the target row's gutter, run right,\n * rise into the target.\n * F forward across rows (e.g. -> failure terminal): mirrored through\n * a right-hand channel, descending.\n * 4. Crossing-free ordering (proved for U and C; F is best-effort):\n * gutter lanes, top -> bottom: U (shortest span first), C-into-row\n * (outer channel first), F-into-row, C-leaving-row (leftmost source\n * first), F-leaving-row, wrap connector last;\n * C channels: inner -> outer by (source row asc, source index asc);\n * target entry points: U rightmost, then C inner -> outer, then F,\n * stepping 14px leftwards across the target's bottom edge.\n */\n(function (root) {\n \"use strict\";\n\n var NW = 112, NH = 76, GAP = 24, STEP = NW + GAP;\n var LANE = 18, LANE_TOP = 14, LANE_PAD = 16, CH = 12;\n\n function layoutPipeline(opts) {\n var names = opts.nodes || [];\n var N = names.length;\n var status = opts.status || {};\n var avail = Math.max(0, opts.availWidth || 0);\n var flow = opts.flow || [];\n var condEdges = opts.edges || [];\n var idx = {};\n names.forEach(function (n, i) { idx[n] = i; });\n var reached = function (n) { return (status[n] || \"pending\") !== \"pending\"; };\n var failed = function (n) { var s = status[n] || \"pending\"; return s === \"fail\" || s === \"fatal\"; };\n\n // flow edges that are NOT to the next station are routed like conditional edges (solid)\n var rails = [], extraFlow = [];\n flow.forEach(function (f) {\n if (!(f.src in idx) || !(f.dst in idx)) { return; }\n if (idx[f.dst] === idx[f.src] + 1) { rails.push(f); }\n else { extraFlow.push({ src: f.src, dst: f.dst, label: null, kind: \"flow\" }); }\n });\n var routed = condEdges.filter(function (e) { return (e.src in idx) && (e.dst in idx); })\n .map(function (e) { return { src: e.src, dst: e.dst, label: e.label || null, kind: \"back\" }; })\n .concat(extraFlow);\n\n function plan(R) {\n var per = Math.max(1, Math.ceil(N / R));\n var rows = Math.max(1, Math.ceil(N / per));\n var pos = names.map(function (n, i) { return { r: Math.floor(i / per), c: i % per }; });\n var es = routed.map(function (e) {\n var si = idx[e.src], ti = idx[e.dst];\n return { label: e.label, kind: e.kind, dst: e.dst, si: si, ti: ti, rs: pos[si].r, rt: pos[ti].r };\n });\n var U = es.filter(function (e) { return e.rs === e.rt; })\n .sort(function (a, b) { return Math.abs(a.si - a.ti) - Math.abs(b.si - b.ti); });\n var C = es.filter(function (e) { return e.rs > e.rt; })\n .sort(function (a, b) { return a.rs - b.rs || a.si - b.si; });\n var F = es.filter(function (e) { return e.rs < e.rt; })\n .sort(function (a, b) { return a.rt - b.rt || b.si - a.si; });\n C.forEach(function (e, j) { e.j = j; });\n F.forEach(function (e, k) { e.k = k; });\n var multi = rows > 1;\n var O = multi ? CH + CH * C.length + 8 : 0;\n var core = per * STEP - GAP;\n var right = (multi ? 14 : 0) + (F.length ? 8 + CH * F.length : 0);\n return { per: per, rows: rows, pos: pos, U: U, C: C, F: F, O: O, core: core, width: O + core + right };\n }\n\n var L = plan(1);\n for (var R = 2; L.width > avail && L.per > 2; R++) { L = plan(R); }\n var O = L.O;\n\n // gutter lane assignment\n var gut = [];\n for (var g = 0; g < L.rows; g++) {\n var items = 0;\n var take = function () { return items++; };\n L.U.filter(function (e) { return e.rt === g; }).forEach(function (e) { e.laneS = take(); });\n L.C.filter(function (e) { return e.rt === g; }).sort(function (a, b) { return b.j - a.j; })\n .forEach(function (e) { e.laneT = take(); });\n L.F.filter(function (e) { return e.rt === g; }).forEach(function (e) { e.laneT = take(); });\n L.C.filter(function (e) { return e.rs === g; }).sort(function (a, b) { return a.si - b.si; })\n .forEach(function (e) { e.laneS = take(); });\n L.F.filter(function (e) { return e.rs === g; }).forEach(function (e) { e.laneS = take(); });\n var wrap = g < L.rows - 1 ? take() : -1;\n gut.push({ n: items, wrapLane: wrap });\n }\n var rowY = [0];\n gut.forEach(function (gt, g) {\n gt.h = gt.n ? LANE_TOP + (gt.n - 1) * LANE + LANE_PAD : 4;\n rowY.push(rowY[g] + NH + gt.h);\n });\n var laneY = function (g, l) { return rowY[g] + NH + LANE_TOP + l * LANE; };\n var nx = function (i) { return O + L.pos[i].c * STEP; };\n var ny = function (i) { return rowY[L.pos[i].r]; };\n\n var entryCount = {};\n L.U.concat(L.C, L.F).forEach(function (e) {\n var m = entryCount[e.ti] || 0;\n entryCount[e.ti] = m + 1;\n e.xe = Math.max(nx(e.ti) + 8, nx(e.ti) + NW - 14 - m * 14);\n });\n\n var lines = [], arrows = [], labels = [];\n function H(x1, x2, y, kind, on) { lines.push({ x: Math.min(x1, x2), y: y, w: Math.abs(x2 - x1), h: 0, dir: \"h\", kind: kind, on: !!on }); }\n function V(x, y1, y2, kind, on) { lines.push({ x: x, y: Math.min(y1, y2), w: 0, h: Math.abs(y2 - y1), dir: \"v\", kind: kind, on: !!on }); }\n\n rails.forEach(function (f) {\n var i = idx[f.src], on = reached(f.dst);\n if (L.pos[i + 1].r === L.pos[i].r) {\n H(nx(i) + NW, nx(i + 1), ny(i) + 37, \"flow\", on);\n arrows.push({ x: nx(i + 1) - 6, y: ny(i) + 33, dir: \"right\", kind: \"flow\", on: on });\n } else {\n var r = L.pos[i].r, xr = nx(i) + NW + 12, yw = laneY(r, gut[r].wrapLane), yn = ny(i + 1) + 37;\n H(nx(i) + NW, xr + 2, ny(i) + 37, \"flow\", on);\n V(xr, ny(i) + 37, yw, \"flow\", on);\n H(O - 12, xr + 2, yw - 1, \"flow\", on);\n V(O - 12, yw - 1, yn + 2, \"flow\", on);\n H(O - 12, O, yn, \"flow\", on);\n arrows.push({ x: O - 6, y: yn - 4, dir: \"right\", kind: \"flow\", on: on });\n }\n });\n\n L.U.concat(L.C, L.F).forEach(function (e) {\n var xs = nx(e.si) + NW / 2, top = ny(e.ti) + NH, y1 = laneY(e.rs, e.laneS);\n var on = e.kind === \"back\" && failed(names[e.si]);\n V(xs, ny(e.si) + NH, y1, e.kind, on);\n if (e.rs === e.rt) {\n H(e.xe, xs, y1, e.kind, on);\n V(e.xe, top + 6, y1, e.kind, on);\n } else {\n var xc = e.rs > e.rt\n ? O - CH - CH * (e.j + 1)\n : O + L.core + 14 + 8 + CH * e.k;\n var y2 = laneY(e.rt, e.laneT);\n H(xc, xs, y1, e.kind, on);\n V(xc, y2, y1, e.kind, on);\n H(xc, e.xe, y2, e.kind, on);\n V(e.xe, top + 6, y2, e.kind, on);\n }\n arrows.push({ x: e.xe - 5, y: top, dir: \"up\", kind: e.kind, on: on });\n if (e.label) { labels.push({ x: xs - 6, y: y1 - 9, text: e.label }); }\n });\n\n return {\n width: L.width,\n height: rowY[L.rows],\n rows: L.rows,\n perRow: L.per,\n nodes: names.map(function (n, i) { return { id: n, index: i, x: nx(i), y: ny(i), w: NW, h: NH }; }),\n lines: lines, arrows: arrows, labels: labels\n };\n }\n\n layoutPipeline.metrics = { NW: NW, NH: NH, GAP: GAP, STEP: STEP, LANE: LANE };\n root.layoutPipeline = layoutPipeline;\n if (typeof module !== \"undefined\" && module.exports) { module.exports = layoutPipeline; }\n})(typeof window !== \"undefined\" ? window : this);\n</script>\n</head>\n<body>\n<a class=\"skip\" href=\"#stages\">Skip to stages</a>\n<header class=\"hdr\">\n <div class=\"hdr-brand\"><span class=\"mark\" aria-hidden=\"true\"></span>\n <span class=\"hdr-name\">agentic-dev-loop viz</span></div>\n <div class=\"hdr-right\">\n <span id=\"stream\" class=\"stream\" role=\"status\"><span class=\"led\" aria-hidden=\"true\"></span>\n <span id=\"stream-label\">STREAM LIVE</span></span>\n <button id=\"theme-toggle\" class=\"hdr-action\" type=\"button\"\n data-testid=\"theme-toggle\" aria-label=\"Switch to light theme\">\n <span id=\"theme-icon\" class=\"ic\" aria-hidden=\"true\">\n <svg class=\"ic-sun\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"none\">\n <circle cx=\"8\" cy=\"8\" r=\"3.33\" fill=\"currentColor\"/>\n <path stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\"\n d=\"M8 .67v2M8 13.33v2M.67 8h2M13.33 8h2M2.81 2.81l.95.95M12.24 12.24l.95.95M13.19 2.81l-.95.95M3.76 12.24l-.95.95\"/>\n </svg>\n <svg class=\"ic-moon\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\">\n <path d=\"M14 8.53A6 6 0 1 1 7.47 2 4.67 4.67 0 0 0 14 8.53Z\" fill=\"currentColor\"/>\n </svg>\n </span>\n </button>\n </div>\n</header>\n<div id=\"stream-banner\" role=\"status\" aria-live=\"polite\" data-testid=\"stream-banner\"></div>\n<main class=\"shell\">\n <section class=\"tiles\" aria-label=\"Run summary\">\n <div class=\"tile\"><div class=\"lbl\">Run</div>\n <div id=\"subject\" class=\"big\">agentic-dev-loop pipeline</div></div>\n <div class=\"tile\"><div class=\"lbl\">Attempt</div>\n <div id=\"attempt\" class=\"big\">?/?</div><div id=\"meter\" class=\"meter\"></div></div>\n <div class=\"tile\"><div class=\"lbl\">Thread ID</div>\n <div id=\"thread\" class=\"big\"><code id=\"thread-id\" tabindex=\"0\" role=\"button\" title=\"click to copy\">--</code></div></div>\n <div class=\"tile\"><div class=\"lbl\">Total</div>\n <div id=\"total\" class=\"big\"></div></div>\n </section>\n <div id=\"verdicts\" class=\"verdicts\" aria-label=\"Step verdicts\"></div>\n <div id=\"run-banner\" class=\"outcome\" role=\"status\" aria-live=\"polite\" data-testid=\"run-banner\"></div>\n <section id=\"pipeline-panel\" class=\"panel\" aria-label=\"Pipeline\">\n <div class=\"panel-h\"><span>Pipeline</span>\n <span class=\"legend mono\">solid = on_pass / dashed = on_fail</span></div>\n <div class=\"graph-wrap\"><div id=\"graph\" role=\"img\" aria-label=\"Pipeline graph\"></div></div>\n </section>\n <div class=\"lower\">\n <section class=\"panel panel-stages\" aria-label=\"Stages\">\n <div class=\"panel-h\"><span>Stages</span></div>\n <div id=\"stages\"></div>\n </section>\n <section class=\"panel panel-log\" aria-label=\"Event log\">\n <div class=\"panel-h\"><span>Event log</span></div>\n <ol id=\"run-log\" data-testid=\"run-log\"></ol>\n </section>\n </div>\n</main>\n<script>\n(function () {\n \"use strict\";\n\n var nodeStatus = {};\n var stageWord = {};\n var verdictSteps = new Set();\n var nodeIds = new Set();\n var currentVerdicts = {};\n var verdictFail = new Set();\n var verdictPass = new Set();\n var verdictUnverifiable = new Set();\n var verdictEnv = new Set();\n var fatalNodes = new Set();\n var maxAttempts = null;\n var attempt = null;\n var topology = null;\n var stations = {};\n var pendingCompletion = null;\n var stepTimes = {};\n var liveElapsed = {};\n var runStartWall = null;\n var totalTimer = null;\n var lastEventAt = 0;\n var reconnectAttempts = 0;\n var runActive = false;\n var runFailed = false;\n var topologyReady = false;\n var topologyFailed = false;\n var pendingEvents = [];\n var MAX_CONSOLE = 500;\n var PALETTE = [\"#0c0c0c\", \"#c50f1f\", \"#13a10e\", \"#c19c00\", \"#0037da\",\n \"#881798\", \"#3a96dd\", \"#cccccc\"];\n var BRIGHT = [\"#767676\", \"#e74856\", \"#16c60c\", \"#f9f1a5\", \"#3b78ff\",\n \"#b4009e\", \"#61d6d6\", \"#f2f2f2\"];\n var COPY_ICON = '<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"14\"'\n + ' height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\"'\n + ' stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"'\n + ' aria-hidden=\"true\"><rect x=\"9\" y=\"9\" width=\"13\" height=\"13\"'\n + ' rx=\"2\" ry=\"2\"></rect><path d=\"M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1'\n + ' 2-2h9a2 2 0 0 1 2 2v1\"></path></svg>';\n var CHECK_ICON = '<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"14\"'\n + ' height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\"'\n + ' stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"'\n + ' aria-hidden=\"true\"><polyline points=\"20 6 9 17 4 12\"></polyline></svg>';\n\n function el(id) { return document.getElementById(id); }\n\n function layoutOf(opts) {\n if (typeof layoutPipeline === \"function\") { return layoutPipeline(opts); }\n if (typeof window !== \"undefined\" && typeof window.layoutPipeline === \"function\") {\n return window.layoutPipeline(opts);\n }\n if (typeof globalThis !== \"undefined\"\n && typeof globalThis.layoutPipeline === \"function\") {\n return globalThis.layoutPipeline(opts);\n }\n return null;\n }\n\n function zeroPad(n) { return n < 10 ? \"0\" + n : String(n); }\n function mmss(totalSeconds) {\n var s = totalSeconds % 60;\n var m = Math.floor(totalSeconds / 60);\n return m + \":\" + (s < 10 ? \"0\" : \"\") + s;\n }\n\n /* Theme ------------------------------------------------------------- */\n function applyTheme(theme) {\n var root = document.documentElement;\n if (root && root.setAttribute) { root.setAttribute(\"data-theme\", theme); }\n var btn = el(\"theme-toggle\");\n if (btn) {\n btn.setAttribute(\"aria-label\", theme === \"dark\"\n ? \"Switch to light theme\" : \"Switch to dark theme\");\n }\n }\n\n function initialTheme() {\n try {\n var stored = window.localStorage && window.localStorage.getItem(\"adl-theme\");\n if (stored === \"dark\" || stored === \"light\") { return stored; }\n } catch (err) { /* session-only */ }\n try {\n if (window.matchMedia\n && window.matchMedia(\"(prefers-color-scheme: light)\").matches) {\n return \"light\";\n }\n } catch (err) { /* default dark */ }\n return \"dark\";\n }\n\n var theme = initialTheme();\n applyTheme(theme);\n (function initThemeToggle() {\n var btn = el(\"theme-toggle\");\n if (!btn) { return; }\n btn.addEventListener(\"click\", function () {\n theme = theme === \"dark\" ? \"light\" : \"dark\";\n applyTheme(theme);\n try { window.localStorage.setItem(\"adl-theme\", theme); }\n catch (err) { /* session-only */ }\n });\n })();\n\n /* Run log ----------------------------------------------------------- */\n function logLine(text, cls) {\n var li = document.createElement(\"li\");\n if (cls) { li.className = cls; }\n li.textContent = text;\n var log = el(\"run-log\");\n if (!log) { return; }\n log.appendChild(li);\n log.scrollTop = log.scrollHeight;\n }\n\n function logConsole(html) {\n var li = document.createElement(\"li\");\n li.innerHTML = html;\n var log = el(\"run-log\");\n if (!log) { return; }\n log.appendChild(li);\n log.scrollTop = log.scrollHeight;\n }\n\n /* Summary tiles ----------------------------------------------------- */\n function renderMeter() {\n var box = el(\"meter\");\n if (!box) { return; }\n box.innerHTML = \"\";\n box.className = \"meter\";\n if (maxAttempts === null) { return; }\n var used = attempt || 0;\n for (var i = 0; i < maxAttempts; i++) {\n var seg = document.createElement(\"span\");\n seg.className = i < used ? \"on\" : \"\";\n box.appendChild(seg);\n }\n if (runFailed && used >= maxAttempts) { box.className = \"meter exhausted\"; }\n }\n\n function updateAttempt() {\n var line = el(\"attempt\");\n if (line) {\n line.textContent = attempt === null\n ? \"?/\" + (maxAttempts === null ? \"?\" : maxAttempts)\n : attempt + \"/\" + (maxAttempts === null ? \"?\" : maxAttempts);\n }\n renderMeter();\n }\n\n function renderTotal() {\n var line = el(\"total\");\n if (!line) { return; }\n line.innerHTML = \"\";\n if (runStartWall === null) {\n line.textContent = \"\";\n line.style.display = \"none\";\n } else {\n line.textContent = \"total \" + mmss(Math.max(0,\n Math.round(Date.now() / 1000 - runStartWall)));\n line.style.display = \"block\";\n var note = document.createElement(\"span\");\n note.className = \"total-note\";\n note.textContent = runActive ? \"LIVE\" : \"FINAL\";\n line.appendChild(note);\n }\n if (runActive && Date.now() - lastEventAt > 10000) { recover(); }\n }\n\n /* Status ------------------------------------------------------------ */\n function statusOf(n) {\n if (nodeStatus[n] === \"running\") { return \"running\"; }\n if (fatalNodes.has(n)) { return \"fatal\"; }\n if (nodeStatus[n] === \"fatal\") { return \"fatal\"; }\n if (verdictFail.has(n)) { return \"fail\"; }\n if (verdictPass.has(n)) { return \"pass\"; }\n if (verdictUnverifiable.has(n)) { return \"unverifiable\"; }\n if (verdictEnv.has(n)) { return \"env\"; }\n return nodeStatus[n] || \"pending\";\n }\n\n function renderStatusOf(n) {\n var s = statusOf(n);\n return s === \"env\" ? \"fatal\" : s;\n }\n\n function statusWord(n) {\n var s = renderStatusOf(n);\n if (s === \"pass\") { return currentVerdicts[n] ? \"PASS\" : \"DONE\"; }\n if (s === \"running\") { return \"RUNNING\"; }\n if (s === \"fail\") { return \"RETRY\"; }\n if (s === \"fatal\") { return currentVerdicts[n] === \"fail\" ? \"FAIL\" : \"FATAL\"; }\n if (s === \"unverifiable\") { return \"UNVERIF\"; }\n return \"PENDING\";\n }\n\n function updateBadges() {\n var box = el(\"verdicts\");\n if (!box) { return; }\n box.innerHTML = \"\";\n Array.from(verdictSteps).forEach(function (step) {\n var b = document.createElement(\"span\");\n var v = currentVerdicts[step];\n b.className = \"badge\";\n if (v === \"pass\") { b.className += \" badge-pass\"; }\n else if (v === \"fail\") { b.className += \" badge-fail\"; }\n else if (v === \"unverifiable\") { b.className += \" badge-unverifiable\"; }\n else if (v === \"env\") { b.className += \" badge-fail\"; }\n else { b.className += \" badge-unclear\"; }\n b.textContent = step + \": \"\n + (v === null || v === undefined ? \"no verdict\" : v);\n box.appendChild(b);\n });\n }\n\n function resetVerdicts() {\n verdictSteps.clear();\n verdictPass.clear();\n verdictFail.clear();\n verdictUnverifiable.clear();\n verdictEnv.clear();\n fatalNodes.clear();\n currentVerdicts = {};\n nodeStatus = {};\n stageWord = {};\n pendingCompletion = null;\n updateBadges();\n Array.from(nodeIds).forEach(function (n) { renderStageHeader(n); });\n renderGraph();\n }\n\n /* Pipeline graph ---------------------------------------------------- */\n function ensureStation(id) {\n if (stations[id]) { return stations[id]; }\n var s = document.createElement(\"div\");\n s.className = \"node st-pending\";\n s.id = \"station-\" + id;\n s.setAttribute(\"data-testid\", \"station-\" + id);\n var row = document.createElement(\"div\");\n row.className = \"nrow\";\n var idx = document.createElement(\"span\");\n idx.className = \"nidx\";\n var time = document.createElement(\"span\");\n time.className = \"ntime\";\n row.appendChild(idx);\n row.appendChild(time);\n var name = document.createElement(\"div\");\n name.className = \"nname\";\n var stat = document.createElement(\"div\");\n stat.className = \"nstat\";\n var led = document.createElement(\"span\");\n led.className = \"led\";\n led.setAttribute(\"aria-hidden\", \"true\");\n var label = document.createElement(\"span\");\n label.className = \"nlabel\";\n stat.appendChild(led);\n stat.appendChild(label);\n s.appendChild(row);\n s.appendChild(name);\n s.appendChild(stat);\n stations[id] = s;\n return s;\n }\n\n function nodeIndex(id) {\n if (!topology) { return 0; }\n var i = topology.nodes.indexOf(id);\n return i < 0 ? 0 : i;\n }\n\n function nodeTime(id) {\n if (nodeStatus[id] === \"running\" && liveElapsed[id] !== undefined) {\n return mmss(liveElapsed[id]);\n }\n if (stepTimes[id] !== undefined) { return mmss(stepTimes[id]); }\n return \"-\";\n }\n\n function updateStation(id) {\n var s = ensureStation(id);\n var status = renderStatusOf(id);\n s.className = \"node st-\" + status;\n var name = s.querySelector(\".nname\");\n var idx = s.querySelector(\".nidx\");\n var time = s.querySelector(\".ntime\");\n var label = s.querySelector(\".nlabel\");\n if (name) { name.textContent = id; }\n if (idx) { idx.textContent = zeroPad(nodeIndex(id) + 1); }\n if (time) { time.textContent = nodeTime(id); }\n if (label) { label.textContent = statusWord(id); }\n }\n\n function graphLabel() {\n if (!topology) { return \"Pipeline graph\"; }\n var parts = topology.nodes.map(function (n, i) {\n return zeroPad(i + 1) + \" \" + n + \" \" + statusWord(n);\n });\n var rows = stations.__rows || 1;\n return \"Pipeline graph: \" + parts.join(\", \") + \". \" + rows + \" row\"\n + (rows === 1 ? \"\" : \"s\") + \".\";\n }\n\n function makeLine(l) {\n var d = document.createElement(\"div\");\n d.className = \"ln ln-\" + l.kind + (l.on ? \" on\" : \"\");\n d.setAttribute(\"data-dir\", l.dir);\n d.style.left = l.x + \"px\";\n d.style.top = l.y + \"px\";\n d.style.width = (l.dir === \"h\" ? l.w : 2) + \"px\";\n d.style.height = (l.dir === \"v\" ? l.h : 2) + \"px\";\n return d;\n }\n\n function makeArrow(a) {\n var d = document.createElement(\"div\");\n d.className = \"arr arr-\" + a.dir + \" arr-\" + a.kind + (a.on ? \" on\" : \"\");\n d.style.left = a.x + \"px\";\n d.style.top = a.y + \"px\";\n return d;\n }\n\n function makeLabel(l) {\n var d = document.createElement(\"div\");\n d.className = \"elabel\";\n d.style.left = l.x + \"px\";\n d.style.top = l.y + \"px\";\n d.textContent = l.text;\n return d;\n }\n\n function renderGraph() {\n var box = el(\"graph\");\n if (!box) { return; }\n box.innerHTML = \"\";\n if (topologyFailed) {\n box.style.width = \"\";\n box.style.height = \"\";\n var msg = document.createElement(\"p\");\n msg.className = \"graph-msg\";\n msg.textContent = \"Graph unavailable - the event log below is still live\";\n box.appendChild(msg);\n box.setAttribute(\"aria-label\", \"Pipeline graph unavailable\");\n return;\n }\n if (!topology) {\n var loading = document.createElement(\"p\");\n loading.className = \"graph-msg\";\n loading.textContent = \"Loading graph...\";\n box.appendChild(loading);\n box.setAttribute(\"aria-label\", \"Loading pipeline graph\");\n return;\n }\n var status = {};\n topology.nodes.forEach(function (n) { status[n] = renderStatusOf(n); });\n var avail = (typeof window !== \"undefined\" && window.innerWidth)\n ? window.innerWidth : 1200;\n var res = layoutOf({\n nodes: topology.nodes,\n flow: topology.flow || [],\n edges: topology.edges || [],\n status: status,\n availWidth: avail\n });\n if (!res) { return; }\n box.style.width = res.width + \"px\";\n box.style.height = res.height + \"px\";\n res.lines.forEach(function (l) { box.appendChild(makeLine(l)); });\n res.arrows.forEach(function (a) { box.appendChild(makeArrow(a)); });\n res.labels.forEach(function (l) { box.appendChild(makeLabel(l)); });\n res.nodes.forEach(function (nd) {\n updateStation(nd.id);\n var s = stations[nd.id];\n s.style.left = nd.x + \"px\";\n s.style.top = nd.y + \"px\";\n box.appendChild(s);\n });\n stations.__rows = res.rows;\n box.setAttribute(\"aria-label\", graphLabel());\n }\n\n /* Stages ------------------------------------------------------------ */\n function addPlaceholder(pre) {\n var ph = document.createElement(\"div\");\n ph.className = \"stage-placeholder\";\n ph.textContent = \"No output\";\n pre.appendChild(ph);\n }\n\n function ensureSection(node) {\n var existing = document.getElementById(\"stage-\" + node);\n if (existing) { return existing; }\n var stages = el(\"stages\");\n var section = document.createElement(\"section\");\n section.className = \"stage collapsed\";\n section.id = \"stage-\" + node;\n section.setAttribute(\"data-testid\", \"stage-\" + node);\n\n var header = document.createElement(\"button\");\n header.className = \"stage-header\";\n header.type = \"button\";\n header.setAttribute(\"aria-expanded\", \"false\");\n header.setAttribute(\"aria-controls\", \"console-\" + node);\n header.addEventListener(\"click\", function () {\n var willCollapse = !section.classList.contains(\"collapsed\");\n section.classList.toggle(\"collapsed\");\n header.setAttribute(\"aria-expanded\", willCollapse ? \"false\" : \"true\");\n renderStageHeader(node);\n if (!willCollapse) {\n requestAnimationFrame(function () { tailSection(section); });\n }\n });\n\n var glyph = document.createElement(\"span\");\n glyph.className = \"stage-glyph\";\n glyph.setAttribute(\"aria-hidden\", \"true\");\n var index = document.createElement(\"span\");\n index.className = \"stage-index\";\n var led = document.createElement(\"span\");\n led.className = \"led\";\n led.setAttribute(\"aria-hidden\", \"true\");\n var name = document.createElement(\"span\");\n name.className = \"stage-name\";\n var status = document.createElement(\"span\");\n status.className = \"stage-status\";\n var verdict = document.createElement(\"span\");\n verdict.className = \"stage-verdict\";\n var time = document.createElement(\"span\");\n time.className = \"stage-time\";\n\n header.appendChild(glyph);\n header.appendChild(index);\n header.appendChild(led);\n header.appendChild(name);\n header.appendChild(status);\n header.appendChild(verdict);\n header.appendChild(time);\n\n var console = document.createElement(\"pre\");\n console.className = \"stage-console\";\n addPlaceholder(console);\n\n var wrap = document.createElement(\"div\");\n wrap.className = \"console-wrap\";\n wrap.id = \"console-\" + node;\n var copyBtn = document.createElement(\"button\");\n copyBtn.className = \"copy-btn\";\n copyBtn.type = \"button\";\n copyBtn.setAttribute(\"title\", \"Copy output\");\n copyBtn.setAttribute(\"aria-label\", \"Copy \" + node + \" console output\");\n copyBtn.setAttribute(\"data-testid\", \"copy-console-\" + node);\n copyBtn.innerHTML = COPY_ICON;\n copyBtn.addEventListener(\"click\", function () {\n copyConsole(console, copyBtn);\n });\n wrap.appendChild(copyBtn);\n wrap.appendChild(console);\n\n section.appendChild(header);\n section.appendChild(wrap);\n stages.appendChild(section);\n renderStageHeader(node);\n return section;\n }\n\n function renderStageHeader(node) {\n var section = ensureSection(node);\n var header = section.querySelector(\".stage-header\");\n if (!header) { return; }\n var glyph = header.querySelector(\".stage-glyph\");\n var index = header.querySelector(\".stage-index\");\n var name = header.querySelector(\".stage-name\");\n var status = header.querySelector(\".stage-status\");\n var verdict = header.querySelector(\".stage-verdict\");\n var time = header.querySelector(\".stage-time\");\n var collapsed = section.classList.contains(\"collapsed\");\n if (glyph) { glyph.textContent = collapsed ? \">\" : \"v\"; }\n if (index) { index.textContent = zeroPad(nodeIndex(node) + 1); }\n if (name) { name.textContent = node; }\n var word = stageWord[node] || \"\";\n if (status) { status.textContent = word; }\n var v = currentVerdicts[node];\n if (verdict) {\n verdict.textContent = (word === \"completed\" && v)\n ? \"verdict: \" + String(v).toUpperCase() : \"\";\n }\n if (time) {\n time.textContent = (word === \"completed\" && stepTimes[node])\n ? mmss(stepTimes[node])\n : (word === \"running\" && liveElapsed[node] !== undefined)\n ? mmss(liveElapsed[node]) : \"\";\n }\n header.className = \"stage-header status-\" + renderStatusOf(node);\n header.setAttribute(\"aria-expanded\", collapsed ? \"false\" : \"true\");\n }\n\n function setStageStatus(node, word) {\n stageWord[node] = word;\n renderStageHeader(node);\n }\n\n function flushPending() {\n if (pendingCompletion === null) { return; }\n var p = pendingCompletion;\n pendingCompletion = null;\n setStageStatus(p.node, p.word);\n }\n\n function clearConsoles() {\n stageWord = {};\n Array.from(nodeIds).forEach(function (n) {\n var section = ensureSection(n);\n var pre = consoleOf(section);\n if (pre) {\n pre.innerHTML = \"\";\n addPlaceholder(pre);\n }\n section.classList.add(\"collapsed\");\n renderStageHeader(n);\n });\n var log = el(\"run-log\");\n if (log) { log.innerHTML = \"\"; }\n }\n\n function consoleOf(section) {\n var wrap = section.querySelector(\".console-wrap\");\n return wrap ? wrap.querySelector(\".stage-console\") : null;\n }\n\n function scrollPortOf(section) { return section.querySelector(\".console-wrap\"); }\n\n function copyConsole(pre, btn) {\n var lines = Array.prototype.map.call(pre.querySelectorAll(\".cline\"),\n function (c) { return c.textContent; });\n if (lines.length === 0) {\n btn.setAttribute(\"title\", \"Nothing to copy\");\n return;\n }\n var text = lines.join(\"\\n\");\n function done(ok) {\n btn.innerHTML = ok ? CHECK_ICON : COPY_ICON;\n btn.setAttribute(\"title\", ok ? \"Copied!\" : \"Copy failed\");\n if (ok) {\n setTimeout(function () {\n btn.innerHTML = COPY_ICON;\n btn.setAttribute(\"title\", \"Copy output\");\n }, 1500);\n }\n }\n if (navigator.clipboard && navigator.clipboard.writeText) {\n navigator.clipboard.writeText(text).then(function () { done(true); },\n function () { done(false); });\n } else {\n var ta = document.createElement(\"textarea\");\n ta.value = text;\n ta.style.position = \"fixed\";\n ta.style.top = \"-1000px\";\n document.body.appendChild(ta);\n ta.select();\n var ok = false;\n try { ok = document.execCommand(\"copy\"); } catch (err) { ok = false; }\n document.body.removeChild(ta);\n done(ok);\n }\n }\n\n function nearBottom(pre) {\n return pre.scrollHeight - pre.scrollTop - pre.clientHeight < 24;\n }\n\n function tailSection(section) {\n if (section.classList.contains(\"collapsed\")) { return; }\n var sp = scrollPortOf(section);\n if (sp) { sp.scrollTop = sp.scrollHeight; }\n }\n\n function appendConsole(section, html) {\n var pre = consoleOf(section);\n var sp = scrollPortOf(section);\n if (!pre) { return; }\n var stick = !section.classList.contains(\"collapsed\") && nearBottom(sp);\n var ph = pre.querySelector(\".stage-placeholder\");\n if (ph) { pre.removeChild(ph); }\n if (pre.querySelectorAll(\".cline\").length >= MAX_CONSOLE) {\n var first = pre.querySelector(\".cline\");\n if (first) { pre.removeChild(first); }\n var omitted = pre.querySelector(\".omitted\");\n if (!omitted) {\n omitted = document.createElement(\"div\");\n omitted.className = \"omitted\";\n pre.insertBefore(omitted, pre.firstChild);\n }\n var count = parseInt(omitted.getAttribute(\"data-n\") || \"0\", 10) + 1;\n omitted.setAttribute(\"data-n\", String(count));\n omitted.textContent = \"...\" + count + \" line\"\n + (count === 1 ? \"\" : \"s\") + \" omitted\";\n }\n var line = document.createElement(\"span\");\n line.className = \"cline\";\n line.innerHTML = html;\n pre.appendChild(line);\n if (stick && sp) { sp.scrollTop = sp.scrollHeight; }\n }\n\n function handleConsole(ev) {\n var html = ansiToHtml(ev.text);\n if (ev.node && nodeIds.has(ev.node)) {\n appendConsole(ensureSection(ev.node), html);\n } else {\n logConsole(html);\n }\n }\n\n function ansiToHtml(text) {\n var out = \"\";\n var i = 0;\n var ESC = \"\\x1b\";\n var cur = { fg: null, bg: null, bold: false, italic: false, underline: false };\n var open = false;\n\n function currentStyle() {\n var parts = [];\n if (cur.bold) { parts.push(\"font-weight:bold\"); }\n if (cur.italic) { parts.push(\"font-style:italic\"); }\n if (cur.underline) { parts.push(\"text-decoration:underline\"); }\n if (cur.fg !== null) { parts.push(\"color:\" + cur.fg); }\n if (cur.bg !== null) { parts.push(\"background-color:\" + cur.bg); }\n return parts.join(\";\");\n }\n function closeSpan() {\n if (open) { out += \"</span>\"; open = false; }\n }\n function pushSpan() {\n closeSpan();\n var s = currentStyle();\n if (s) { out += '<span style=\"' + s + '\">'; open = true; }\n }\n\n var html = text.replace(/&/g, \"&amp;\").replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\");\n var n = html.length;\n while (i < n) {\n var idx = html.indexOf(ESC, i);\n if (idx === -1) { out += html.slice(i); break; }\n out += html.slice(i, idx);\n var j = idx + 1;\n if (j >= n) { i = idx + 1; continue; }\n var ch = html.charAt(j);\n if (ch === \"[\") {\n var k = j + 1;\n while (k < n) {\n var cc = html.charCodeAt(k);\n if (cc >= 0x40 && cc <= 0x7e) { break; }\n k++;\n }\n if (k >= n) { i = n; break; }\n var final = html.charAt(k);\n var params = html.slice(j + 1, k);\n if (final === \"m\") {\n var parts = params.split(\";\");\n var codes = [];\n for (var p = 0; p < parts.length; p++) {\n codes.push(parts[p] === \"\" ? 0 : parseInt(parts[p], 10));\n }\n var changed = false;\n for (var c = 0; c < codes.length; c++) {\n var code = codes[c];\n if (code === 0) {\n cur = { fg: null, bg: null, bold: false, italic: false, underline: false };\n changed = true;\n } else if (code === 1) { cur.bold = true; changed = true; }\n else if (code === 22) { cur.bold = false; changed = true; }\n else if (code === 3) { cur.italic = true; changed = true; }\n else if (code === 23) { cur.italic = false; changed = true; }\n else if (code === 4) { cur.underline = true; changed = true; }\n else if (code === 24) { cur.underline = false; changed = true; }\n else if (code >= 30 && code <= 37) { cur.fg = PALETTE[code - 30]; changed = true; }\n else if (code >= 90 && code <= 97) { cur.fg = BRIGHT[code - 90]; changed = true; }\n else if (code >= 40 && code <= 47) { cur.bg = PALETTE[code - 40]; changed = true; }\n else if (code >= 100 && code <= 107) { cur.bg = BRIGHT[code - 100]; changed = true; }\n else if (code === 39) { cur.fg = null; changed = true; }\n else if (code === 49) { cur.bg = null; changed = true; }\n else if (code === 38 || code === 48) {\n if (c + 2 < codes.length && codes[c + 1] === 5) {\n var val = codes[c + 2];\n var col;\n if (val < 8) { col = PALETTE[val]; }\n else if (val < 16) { col = BRIGHT[val - 8]; }\n else if (val < 232) {\n var cube = val - 16;\n var levels = [0, 95, 135, 175, 215, 255];\n col = \"rgb(\" + levels[Math.floor(cube / 36)] + \",\"\n + levels[Math.floor((cube % 36) / 6)] + \",\"\n + levels[cube % 6] + \")\";\n } else {\n var g = 8 + 10 * (val - 232);\n col = \"rgb(\" + g + \",\" + g + \",\" + g + \")\";\n }\n if (code === 38) { cur.fg = col; } else { cur.bg = col; }\n c += 2;\n changed = true;\n } else if (c + 4 < codes.length && codes[c + 1] === 2) {\n var rgb = \"rgb(\" + codes[c + 2] + \",\" + codes[c + 3] + \",\"\n + codes[c + 4] + \")\";\n if (code === 38) { cur.fg = rgb; } else { cur.bg = rgb; }\n c += 4;\n changed = true;\n }\n }\n }\n if (changed) { pushSpan(); }\n }\n i = k + 1;\n continue;\n }\n if (ch === \"]\") {\n var bel = html.indexOf(String.fromCharCode(7), j);\n var st = html.indexOf(ESC + String.fromCharCode(92), j);\n var end = -1;\n if (bel !== -1) { end = bel + 1; }\n if (st !== -1 && (end === -1 || st + 2 < end)) { end = st + 2; }\n if (end === -1) { i = n; break; }\n i = end;\n continue;\n }\n i = idx + 1;\n }\n closeSpan();\n return out;\n }\n\n /* Events ------------------------------------------------------------ */\n function handle(ev) {\n switch (ev.type) {\n case \"run_started\":\n flushPending();\n stepTimes = {};\n liveElapsed = {};\n runActive = true;\n runFailed = false;\n fatalNodes.clear();\n if (totalTimer !== null) { clearInterval(totalTimer); totalTimer = null; }\n runStartWall = (typeof ev.started_at === \"number\") ? ev.started_at : null;\n totalTimer = setInterval(renderTotal, 1000);\n renderTotal();\n clearConsoles();\n resetVerdicts();\n if (ev.attempt !== undefined) { attempt = ev.attempt; }\n maxAttempts = ev.max_attempts;\n var subject = el(\"subject\");\n if (subject) { subject.textContent = ev.subject; }\n if (ev.thread_id !== undefined) {\n var tid = el(\"thread-id\");\n if (tid) { tid.textContent = ev.thread_id; }\n }\n logLine(\"run started: \" + ev.subject + \" (thread \" + ev.thread_id + \")\",\n \"started\");\n updateAttempt();\n break;\n case \"node_started\":\n flushPending();\n if (!nodeIds.has(ev.node)) { break; }\n nodeStatus[ev.node] = \"running\";\n stageWord[ev.node] = \"running\";\n liveElapsed = {};\n liveElapsed[ev.node] = 0;\n renderGraph();\n break;\n case \"node_finished\":\n if (!nodeIds.has(ev.node)) { break; }\n if (ev.elapsed_seconds !== undefined) {\n stepTimes[ev.node] = (stepTimes[ev.node] || 0) + ev.elapsed_seconds;\n }\n if (!fatalNodes.has(ev.node)) {\n nodeStatus[ev.node] = ev.ok ? \"pass\" : \"fail\";\n }\n pendingCompletion = { node: ev.node, word: ev.ok ? \"completed\" : \"errored\" };\n setStageStatus(ev.node, ev.ok ? \"completed\" : \"errored\");\n renderGraph();\n break;\n case \"heartbeat\":\n if (nodeIds.has(ev.node) && nodeStatus[ev.node] === \"running\") {\n liveElapsed[ev.node] = ev.elapsed_seconds;\n renderStageHeader(ev.node);\n renderGraph();\n }\n break;\n case \"state\":\n if (ev.attempt !== undefined) { attempt = ev.attempt; }\n if (ev.step_verdicts !== undefined) {\n Object.keys(ev.step_verdicts).forEach(function (k) {\n verdictSteps.add(k);\n var v = ev.step_verdicts[k];\n if (v === \"pass\") { verdictPass.add(k); verdictFail.delete(k); verdictUnverifiable.delete(k); verdictEnv.delete(k); }\n else if (v === \"fail\") { verdictFail.add(k); verdictPass.delete(k); verdictUnverifiable.delete(k); verdictEnv.delete(k); }\n else if (v === \"unverifiable\") { verdictUnverifiable.add(k); verdictPass.delete(k); verdictFail.delete(k); verdictEnv.delete(k); }\n else if (v === \"env\") { verdictEnv.add(k); verdictPass.delete(k); verdictFail.delete(k); verdictUnverifiable.delete(k); }\n else { verdictPass.delete(k); verdictFail.delete(k); verdictUnverifiable.delete(k); verdictEnv.delete(k); }\n currentVerdicts[k] = v;\n });\n updateBadges();\n if (pendingCompletion !== null\n && (pendingCompletion.node in ev.step_verdicts)) {\n flushPending();\n }\n renderGraph();\n }\n if (ev.failed) {\n Object.keys(nodeStatus).forEach(function (n) {\n if (nodeStatus[n] === \"running\") { fatalNodes.add(n); }\n });\n logLine(\"state: failed\", \"failed\");\n renderGraph();\n }\n updateAttempt();\n break;\n case \"run_finished\":\n runActive = false;\n if (totalTimer !== null) { clearInterval(totalTimer); totalTimer = null; }\n var total = el(\"total\");\n if (ev.elapsed_seconds !== undefined) {\n runStartWall = null;\n if (total) {\n total.innerHTML = \"\";\n total.textContent = \"total \" + mmss(ev.elapsed_seconds);\n total.style.display = \"block\";\n var finalNote = document.createElement(\"span\");\n finalNote.className = \"total-note\";\n finalNote.textContent = \"FINAL\";\n total.appendChild(finalNote);\n }\n }\n flushPending();\n if (!ev.success) {\n runFailed = true;\n Object.keys(nodeStatus).forEach(function (n) {\n var s = statusOf(n);\n if (s === \"fail\" || s === \"running\") { nodeStatus[n] = \"fatal\"; }\n });\n renderGraph();\n }\n renderMeter();\n logLine(\"run finished: \" + (ev.success ? \"success\" : \"failure\"),\n ev.success ? \"success\" : \"failed\");\n var banner = el(\"run-banner\");\n if (banner) {\n banner.innerHTML = \"\";\n var title = document.createElement(\"div\");\n title.className = \"outcome-title\";\n title.textContent = ev.success ? \"SUCCESS\" : \"FAILURE\";\n var sub = document.createElement(\"div\");\n sub.className = \"outcome-sub\";\n sub.textContent = \"Run finished - \" + (el(\"subject\")\n ? el(\"subject\").textContent : \"\") + \" - attempt \"\n + (attempt === null ? \"?\" : attempt) + \"/\"\n + (maxAttempts === null ? \"?\" : maxAttempts)\n + \" - total \" + (ev.elapsed_seconds !== undefined\n ? mmss(ev.elapsed_seconds) : \"-\");\n banner.appendChild(title);\n banner.appendChild(sub);\n banner.className = \"outcome \"\n + (ev.success ? \"outcome-success\" : \"outcome-failure\");\n banner.style.display = \"block\";\n }\n break;\n case \"console\":\n handleConsole(ev);\n break;\n }\n }\n\n function loadTopology() {\n fetch(\"/topology\").then(function (r) { return r.json(); }).then(function (d) {\n var subject = el(\"subject\");\n if (subject) { subject.textContent = d.subject; }\n topology = {\n nodes: d.nodes || [],\n flow: d.flow || [],\n edges: d.edges || []\n };\n topology.nodes.forEach(function (n) {\n nodeIds.add(n);\n ensureSection(n);\n ensureStation(n);\n });\n topologyReady = true;\n renderGraph();\n pendingEvents.forEach(handle);\n pendingEvents = [];\n }).catch(function () {\n topologyFailed = true;\n topologyReady = true;\n logLine(\"failed to load topology\");\n renderGraph();\n pendingEvents.forEach(handle);\n pendingEvents = [];\n });\n }\n\n /* Stream ------------------------------------------------------------ */\n var es = null;\n\n function setStreamLive() {\n var stream = el(\"stream\");\n if (stream) { stream.className = \"stream\"; }\n var label = el(\"stream-label\");\n if (label) { label.textContent = \"STREAM LIVE\"; }\n }\n\n function setStreamReconnecting() {\n var stream = el(\"stream\");\n if (stream) { stream.className = \"stream reconnecting\"; }\n var label = el(\"stream-label\");\n if (label) { label.textContent = \"RECONNECTING\"; }\n }\n\n function connectStream() {\n var stream = new EventSource(\"/events\");\n stream.onmessage = function (e) {\n lastEventAt = Date.now();\n reconnectAttempts = 0;\n var banner = el(\"stream-banner\");\n if (banner) { banner.style.display = \"none\"; }\n setStreamLive();\n var ev;\n try { ev = JSON.parse(e.data); }\n catch (err) { logLine(\"bad event: \" + e.data); return; }\n if (!topologyReady) { pendingEvents.push(ev); return; }\n try { handle(ev); }\n catch (err) { logLine(\"bad event: \" + e.data); }\n };\n stream.onerror = function () { logLine(\"event stream interrupted\", \"interrupted\"); };\n return stream;\n }\n\n function recover() {\n var banner = el(\"stream-banner\");\n reconnectAttempts++;\n setStreamReconnecting();\n if (banner) {\n banner.textContent = \"Connection lost, reconnecting... attempt \"\n + reconnectAttempts + \"/3 (no event for 10 s)\";\n banner.style.display = \"block\";\n }\n if (reconnectAttempts > 3) {\n location.reload();\n return;\n }\n if (es) { es.close(); }\n es = connectStream();\n lastEventAt = Date.now();\n }\n\n loadTopology();\n\n setTimeout(function () {\n if (!topologyReady) {\n topologyReady = true;\n logLine(\"topology load timed out - event log only\");\n pendingEvents.forEach(handle);\n pendingEvents = [];\n }\n }, 5000);\n\n es = connectStream();\n\n document.addEventListener(\"visibilitychange\", function () {\n if (runActive && Date.now() - lastEventAt > 10000) { recover(); }\n });\n\n (function initThreadCopy() {\n var tid = el(\"thread-id\");\n if (!tid) { return; }\n\n function selectFallback() {\n // Explicit degradation: if the clipboard API is unavailable, select\n // the text so the user can copy it manually.\n try {\n var range = document.createRange();\n range.selectNodeContents(tid);\n var sel = window.getSelection();\n sel.removeAllRanges();\n sel.addRange(range);\n } catch (err) { /* selection is best-effort */ }\n }\n\n function showFeedback(value) {\n tid.textContent = \"Copied\";\n tid.classList.add(\"copied\");\n setTimeout(function () {\n tid.classList.remove(\"copied\");\n // Only restore when the feedback is still showing; a newer thread\n // id from run_started must not be clobbered.\n if (tid.textContent === \"Copied\") { tid.textContent = value; }\n }, 1200);\n }\n\n function copyThreadId() {\n var value = tid.textContent;\n if (!value || value === \"--\" || value === \"Copied\") { return; }\n if (!navigator.clipboard || !navigator.clipboard.writeText) {\n selectFallback();\n return;\n }\n navigator.clipboard.writeText(value).then(\n function () { showFeedback(value); }, selectFallback);\n }\n\n tid.addEventListener(\"click\", copyThreadId);\n tid.addEventListener(\"keydown\", function (ev) {\n if (ev.key === \"Enter\" || ev.key === \" \") {\n ev.preventDefault();\n copyThreadId();\n }\n });\n })();\n\n (function initRelayout() {\n if (typeof ResizeObserver !== \"undefined\") {\n var target = el(\"pipeline-panel\") || document.body;\n if (target) {\n new ResizeObserver(function () { renderGraph(); }).observe(target);\n }\n }\n window.addEventListener(\"resize\", function () { renderGraph(); });\n })();\n})();\n</script>\n</body>\n</html>\n"