okstra 0.148.0 → 0.149.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/okstra-render-report-views.py +20 -0
- package/runtime/prompts/profiles/project-analysis.md +18 -0
- package/runtime/prompts/profiles/release-handoff.md +3 -0
- package/runtime/prompts/profiles/requirements-discovery.md +7 -0
- package/runtime/python/okstra_ctl/report_html/common.py +77 -47
- package/runtime/python/okstra_ctl/report_html/filters.py +125 -30
- package/runtime/python/okstra_ctl/report_html/models.py +15 -0
- package/runtime/python/okstra_ctl/report_html/render.py +49 -5
- package/runtime/python/okstra_ctl/report_html/view_models/change_impact_analysis.py +14 -4
- package/runtime/python/okstra_ctl/report_html/view_models/error_analysis.py +3 -3
- package/runtime/python/okstra_ctl/report_html/view_models/feature_analysis.py +5 -3
- package/runtime/python/okstra_ctl/report_html/view_models/final_verification.py +2 -7
- package/runtime/python/okstra_ctl/report_html/view_models/implementation.py +5 -9
- package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +2 -5
- package/runtime/python/okstra_ctl/report_html/view_models/improvement_discovery.py +1 -2
- package/runtime/python/okstra_ctl/report_html/view_models/project_analysis.py +90 -4
- package/runtime/python/okstra_ctl/report_html/view_models/release_handoff.py +1 -8
- package/runtime/python/okstra_ctl/report_html/view_models/requirements_discovery.py +6 -4
- package/runtime/python/okstra_ctl/report_html/visualizations.py +146 -11
- package/runtime/python/okstra_ctl/report_view_artifacts.py +5 -0
- package/runtime/python/okstra_ctl/time_report.py +2 -2
- package/runtime/python/okstra_ctl/usage_report.py +2 -2
- package/runtime/schemas/final-report-v2.0.schema.json +229 -1
- package/runtime/templates/reports/final-report.template.md +55 -0
- package/runtime/templates/reports/html/assets/base.css +64 -8
- package/runtime/templates/reports/html/base.template.html +21 -41
- package/runtime/templates/reports/html/macros/forms.html +24 -22
- package/runtime/templates/reports/html/macros/layout.html +18 -5
- package/runtime/templates/reports/html/macros/visualizations.html +7 -5
- package/runtime/templates/reports/html/tasks/change-impact-analysis.template.html +30 -15
- package/runtime/templates/reports/html/tasks/error-analysis.template.html +22 -15
- package/runtime/templates/reports/html/tasks/feature-analysis.template.html +35 -15
- package/runtime/templates/reports/html/tasks/final-verification.template.html +21 -14
- package/runtime/templates/reports/html/tasks/implementation-planning.template.html +78 -19
- package/runtime/templates/reports/html/tasks/implementation.template.html +34 -16
- package/runtime/templates/reports/html/tasks/improvement-discovery.template.html +11 -11
- package/runtime/templates/reports/html/tasks/project-analysis.template.html +65 -25
- package/runtime/templates/reports/html/tasks/release-handoff.template.html +28 -14
- package/runtime/templates/reports/html/tasks/requirements-discovery.template.html +35 -15
- package/runtime/templates/reports/report.js +7 -1
- package/runtime/validators/validate_analysis_report.py +36 -0
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
"""Human-first feature-analysis view model."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
-
from ..common import analysis_review_ids,
|
|
4
|
+
from ..common import analysis_review_ids, evidence_index
|
|
5
5
|
from ..models import HumanReportView, VisualNode
|
|
6
6
|
from ..visualizations import flow_figure
|
|
7
7
|
|
|
8
8
|
|
|
9
|
+
_FLOW_KINDS = {"normal": "Normal path", "alternative": "Alternative path", "failure": "Failure path"}
|
|
10
|
+
|
|
11
|
+
|
|
9
12
|
def _flow_nodes(feature: dict) -> tuple[VisualNode, ...]:
|
|
10
13
|
return tuple(
|
|
11
14
|
VisualNode(
|
|
12
15
|
id=row["id"],
|
|
13
|
-
label=row["kind"]
|
|
16
|
+
label=_FLOW_KINDS.get(row["kind"], row["kind"]),
|
|
14
17
|
group=row["kind"],
|
|
15
18
|
status="risk" if row["kind"] == "failure" else "stable",
|
|
16
19
|
detail=" → ".join(step["action"] for step in row.get("steps", [])),
|
|
@@ -29,7 +32,6 @@ def build_feature_analysis_view(data: dict) -> HumanReportView:
|
|
|
29
32
|
"flowFigure": figure,
|
|
30
33
|
"analysisReviewIds": analysis_review_ids(data),
|
|
31
34
|
"evidenceIndex": evidence_index(data),
|
|
32
|
-
"audit": audit_context(data),
|
|
33
35
|
}
|
|
34
36
|
return HumanReportView(
|
|
35
37
|
task_type="feature-analysis",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Human-first final-verification view model."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
-
from ..common import
|
|
4
|
+
from ..common import evidence_index
|
|
5
5
|
from ..models import HumanReportView, VisualNode
|
|
6
6
|
from ..visualizations import coverage_figure
|
|
7
7
|
|
|
@@ -14,6 +14,7 @@ def _coverage_nodes(final: dict) -> tuple[VisualNode, ...]:
|
|
|
14
14
|
"requirement",
|
|
15
15
|
row["status"],
|
|
16
16
|
row["artifact"],
|
|
17
|
+
note=row["status"],
|
|
17
18
|
)
|
|
18
19
|
for row in final["validationEvidence"]
|
|
19
20
|
)
|
|
@@ -24,11 +25,6 @@ def build_final_verification_view(data: dict) -> HumanReportView:
|
|
|
24
25
|
figure = coverage_figure(
|
|
25
26
|
rows=_coverage_nodes(final), title="Requirement verification coverage"
|
|
26
27
|
)
|
|
27
|
-
audit = audit_context(data)
|
|
28
|
-
audit["deliveryEvidence"] = {
|
|
29
|
-
"sourceImplementation": final["sourceImplementationReport"],
|
|
30
|
-
"readonlyCommandLog": final["readonlyCommandLog"],
|
|
31
|
-
}
|
|
32
28
|
context = {
|
|
33
29
|
"humanSummary": data["humanSummary"],
|
|
34
30
|
"verdict": data["verdictCard"],
|
|
@@ -37,7 +33,6 @@ def build_final_verification_view(data: dict) -> HumanReportView:
|
|
|
37
33
|
"coverageFigure": figure,
|
|
38
34
|
"releaseAllowed": data["verdictCard"]["verdictToken"] == "accepted",
|
|
39
35
|
"evidenceIndex": evidence_index(data),
|
|
40
|
-
"audit": audit,
|
|
41
36
|
}
|
|
42
37
|
return HumanReportView(
|
|
43
38
|
"final-verification",
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
"""Human-first implementation delivery view model."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
-
from ..common import
|
|
4
|
+
from ..common import evidence_index
|
|
5
5
|
from ..models import HumanReportView, VisualNode
|
|
6
6
|
from ..visualizations import change_map_figure
|
|
7
7
|
|
|
8
8
|
|
|
9
|
+
_FILE_ACTIONS = {"created": "Created", "modified": "Modified", "deleted": "Deleted"}
|
|
10
|
+
|
|
11
|
+
|
|
9
12
|
def _change_nodes(implementation: dict) -> tuple[VisualNode, ...]:
|
|
10
13
|
return tuple(
|
|
11
14
|
VisualNode(
|
|
@@ -14,6 +17,7 @@ def _change_nodes(implementation: dict) -> tuple[VisualNode, ...]:
|
|
|
14
17
|
row["planStep"],
|
|
15
18
|
row["action"],
|
|
16
19
|
row["lines"],
|
|
20
|
+
note=f'{_FILE_ACTIONS.get(row["action"], row["action"])} · plan step {row["planStep"]}',
|
|
17
21
|
)
|
|
18
22
|
for row in implementation["diffSummary"]["files"]
|
|
19
23
|
)
|
|
@@ -24,20 +28,12 @@ def build_implementation_view(data: dict) -> HumanReportView:
|
|
|
24
28
|
figure = change_map_figure(
|
|
25
29
|
nodes=_change_nodes(implementation), title="Delivered change areas"
|
|
26
30
|
)
|
|
27
|
-
audit = audit_context(data)
|
|
28
|
-
audit["deliveryEvidence"] = {
|
|
29
|
-
"commits": implementation["commitList"],
|
|
30
|
-
"rawDiffStat": implementation["diffSummary"]["rawStat"],
|
|
31
|
-
"verifiers": implementation["verifierResults"],
|
|
32
|
-
"validationCommands": implementation["validationEvidence"],
|
|
33
|
-
}
|
|
34
31
|
context = {
|
|
35
32
|
"humanSummary": data["humanSummary"],
|
|
36
33
|
"implementation": implementation,
|
|
37
34
|
"narrative": implementation["userNarrative"],
|
|
38
35
|
"changeFigure": figure,
|
|
39
36
|
"evidenceIndex": evidence_index(data),
|
|
40
|
-
"audit": audit,
|
|
41
37
|
}
|
|
42
38
|
return HumanReportView(
|
|
43
39
|
"implementation",
|
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
import re
|
|
5
5
|
from dataclasses import dataclass
|
|
6
6
|
|
|
7
|
-
from ..common import
|
|
7
|
+
from ..common import evidence_index
|
|
8
8
|
from ..models import HumanReportView, VisualEdge, VisualNode
|
|
9
9
|
from ..visualizations import stage_map_figure
|
|
10
10
|
|
|
@@ -46,7 +46,7 @@ def plan_approval_state(data: dict) -> PlanApprovalState | None:
|
|
|
46
46
|
if row.get("blocks") == "approval"
|
|
47
47
|
and row.get("status") in {"open", "answered"}
|
|
48
48
|
)
|
|
49
|
-
reason = f"
|
|
49
|
+
reason = f"{len(blockers)} approval blocker(s) unresolved" if blockers else ""
|
|
50
50
|
return PlanApprovalState(
|
|
51
51
|
names,
|
|
52
52
|
resolve_recommended_option(recommended, names),
|
|
@@ -78,8 +78,6 @@ def build_implementation_planning_view(data: dict) -> HumanReportView:
|
|
|
78
78
|
planning = data["implementationPlanning"]
|
|
79
79
|
figure = _stage_figure(planning)
|
|
80
80
|
approval = plan_approval_state(data)
|
|
81
|
-
audit = audit_context(data)
|
|
82
|
-
audit["planBodyVerification"] = planning["planBodyVerification"]
|
|
83
81
|
context = {
|
|
84
82
|
"humanSummary": data["humanSummary"],
|
|
85
83
|
"planning": planning,
|
|
@@ -93,7 +91,6 @@ def build_implementation_planning_view(data: dict) -> HumanReportView:
|
|
|
93
91
|
and row.get("status") in {"open", "answered"}
|
|
94
92
|
],
|
|
95
93
|
"evidenceIndex": evidence_index(data),
|
|
96
|
-
"audit": audit,
|
|
97
94
|
}
|
|
98
95
|
return HumanReportView(
|
|
99
96
|
"implementation-planning",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Human-first improvement-discovery view model."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
-
from ..common import
|
|
4
|
+
from ..common import evidence_index
|
|
5
5
|
from ..models import HumanReportView, VisualNode
|
|
6
6
|
from ..visualizations import matrix_figure
|
|
7
7
|
|
|
@@ -33,7 +33,6 @@ def build_improvement_discovery_view(data: dict) -> HumanReportView:
|
|
|
33
33
|
"narrative": improvement["userNarrative"],
|
|
34
34
|
"matrixFigure": figure,
|
|
35
35
|
"evidenceIndex": evidence_index(data),
|
|
36
|
-
"audit": audit_context(data),
|
|
37
36
|
}
|
|
38
37
|
return HumanReportView(
|
|
39
38
|
"improvement-discovery",
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
"""Human-first project-analysis view model."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
-
from ..common import
|
|
4
|
+
from ..common import evidence_index
|
|
5
5
|
from ..models import HumanReportView, VisualEdge, VisualNode
|
|
6
|
-
from ..visualizations import dependency_figure
|
|
6
|
+
from ..visualizations import dependency_figure, infrastructure_figure, workflow_figure
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
def _component_nodes(project: dict) -> tuple[VisualNode, ...]:
|
|
@@ -14,6 +14,7 @@ def _component_nodes(project: dict) -> tuple[VisualNode, ...]:
|
|
|
14
14
|
group=(row.get("paths") or ["project"])[0].split("/", 1)[0],
|
|
15
15
|
status="stable",
|
|
16
16
|
detail=row["responsibility"],
|
|
17
|
+
paths=tuple(row.get("paths") or ()),
|
|
17
18
|
)
|
|
18
19
|
for row in project.get("components", [])
|
|
19
20
|
)
|
|
@@ -31,6 +32,88 @@ def _dependency_edges(project: dict) -> tuple[VisualEdge, ...]:
|
|
|
31
32
|
)
|
|
32
33
|
|
|
33
34
|
|
|
35
|
+
def _infrastructure_figure(project: dict):
|
|
36
|
+
"""The components that hold a boundary, and what sits on the far side.
|
|
37
|
+
|
|
38
|
+
The dependency graph answers "what calls what inside the code". Where the
|
|
39
|
+
data lives and which outside service the code depends on is a different
|
|
40
|
+
question with different nodes, and reading it off a component graph means
|
|
41
|
+
reconstructing it from adapter paths.
|
|
42
|
+
"""
|
|
43
|
+
components = {row["id"]: row for row in project.get("components", [])}
|
|
44
|
+
nodes: list[VisualNode] = []
|
|
45
|
+
edges: list[VisualEdge] = []
|
|
46
|
+
seen: set[str] = set()
|
|
47
|
+
|
|
48
|
+
def owner_node(component_id: str) -> str | None:
|
|
49
|
+
row = components.get(component_id)
|
|
50
|
+
if row is None:
|
|
51
|
+
return None
|
|
52
|
+
if row["id"] not in seen:
|
|
53
|
+
seen.add(row["id"])
|
|
54
|
+
nodes.append(
|
|
55
|
+
VisualNode(row["id"], row["name"], "component", "stable", row["responsibility"], note="Component")
|
|
56
|
+
)
|
|
57
|
+
return row["id"]
|
|
58
|
+
|
|
59
|
+
for index, row in enumerate(project.get("dataStores", []), start=1):
|
|
60
|
+
store_id = f"store-{index}"
|
|
61
|
+
nodes.append(
|
|
62
|
+
VisualNode(store_id, row["name"], "store", "store", row.get("readBoundary", ""), note="Data store")
|
|
63
|
+
)
|
|
64
|
+
source = owner_node(row.get("ownerComponentId", ""))
|
|
65
|
+
if source:
|
|
66
|
+
edges.append(VisualEdge(source, store_id, "reads · writes", "storage"))
|
|
67
|
+
for index, row in enumerate(project.get("externalSystems", []), start=1):
|
|
68
|
+
system_id = f"external-{index}"
|
|
69
|
+
nodes.append(
|
|
70
|
+
VisualNode(system_id, row["name"], "external", "external", row.get("direction", ""), note="External system")
|
|
71
|
+
)
|
|
72
|
+
edges.append(VisualEdge(row.get("adapter", "adapter"), system_id, row.get("direction", ""), "external"))
|
|
73
|
+
if row.get("adapter") and row["adapter"] not in seen:
|
|
74
|
+
seen.add(row["adapter"])
|
|
75
|
+
nodes.append(
|
|
76
|
+
VisualNode(row["adapter"], row["adapter"], "adapter", "stable", "Adapter", note="Adapter")
|
|
77
|
+
)
|
|
78
|
+
return infrastructure_figure(
|
|
79
|
+
nodes=tuple(nodes), edges=tuple(edges), title="Infrastructure and boundaries"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _workflow_figure(project: dict):
|
|
84
|
+
"""Each workflow's steps, as the path a request takes between components."""
|
|
85
|
+
components = {row["id"]: row for row in project.get("components", [])}
|
|
86
|
+
nodes: list[VisualNode] = []
|
|
87
|
+
edges: list[VisualEdge] = []
|
|
88
|
+
seen: set[str] = set()
|
|
89
|
+
for flow in project.get("workflows", []):
|
|
90
|
+
steps = sorted(flow.get("steps", []), key=lambda step: step.get("order", 0))
|
|
91
|
+
for step in steps:
|
|
92
|
+
component_id = step.get("componentId", "")
|
|
93
|
+
if component_id and component_id not in seen:
|
|
94
|
+
seen.add(component_id)
|
|
95
|
+
row = components.get(component_id, {})
|
|
96
|
+
nodes.append(
|
|
97
|
+
VisualNode(
|
|
98
|
+
component_id,
|
|
99
|
+
row.get("name", component_id),
|
|
100
|
+
"component",
|
|
101
|
+
"stable",
|
|
102
|
+
step.get("action", ""),
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
for first, second in zip(steps, steps[1:]):
|
|
106
|
+
edges.append(
|
|
107
|
+
VisualEdge(
|
|
108
|
+
first.get("componentId", ""),
|
|
109
|
+
second.get("componentId", ""),
|
|
110
|
+
second.get("action", "next"),
|
|
111
|
+
"workflow",
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
return workflow_figure(nodes=tuple(nodes), edges=tuple(edges), title="Workflows")
|
|
115
|
+
|
|
116
|
+
|
|
34
117
|
def build_project_analysis_view(data: dict) -> HumanReportView:
|
|
35
118
|
project = data["projectAnalysis"]
|
|
36
119
|
architecture = dependency_figure(
|
|
@@ -38,17 +121,20 @@ def build_project_analysis_view(data: dict) -> HumanReportView:
|
|
|
38
121
|
edges=_dependency_edges(project),
|
|
39
122
|
title="Project architecture and dependencies",
|
|
40
123
|
)
|
|
124
|
+
infrastructure = _infrastructure_figure(project)
|
|
125
|
+
workflows = _workflow_figure(project) if project.get("workflows") else None
|
|
41
126
|
context = {
|
|
42
127
|
"humanSummary": data["humanSummary"],
|
|
43
128
|
"project": project,
|
|
129
|
+
"infrastructureFigure": infrastructure,
|
|
130
|
+
"workflowFigure": workflows,
|
|
44
131
|
"narrative": project["userNarrative"],
|
|
45
132
|
"architectureFigure": architecture,
|
|
46
133
|
"evidenceIndex": evidence_index(data),
|
|
47
|
-
"audit": audit_context(data),
|
|
48
134
|
}
|
|
49
135
|
return HumanReportView(
|
|
50
136
|
task_type="project-analysis",
|
|
51
137
|
template_name="html/tasks/project-analysis.template.html",
|
|
52
138
|
context=context,
|
|
53
|
-
figures=(architecture,),
|
|
139
|
+
figures=tuple(f for f in (architecture, infrastructure, workflows) if f),
|
|
54
140
|
)
|
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
|
|
4
4
|
from urllib.parse import urlparse
|
|
5
5
|
|
|
6
|
-
from ..common import
|
|
6
|
+
from ..common import evidence_index
|
|
7
7
|
from ..models import HumanReportView, VisualNode
|
|
8
8
|
from ..visualizations import timeline_figure
|
|
9
9
|
|
|
@@ -31,12 +31,6 @@ def build_release_handoff_view(data: dict) -> HumanReportView:
|
|
|
31
31
|
figure = timeline_figure(
|
|
32
32
|
events=_timeline_nodes(handoff), title="Release handoff timeline"
|
|
33
33
|
)
|
|
34
|
-
audit = audit_context(data)
|
|
35
|
-
audit["deliveryEvidence"] = {
|
|
36
|
-
"executedCommands": handoff["executedCommands"],
|
|
37
|
-
"commits": handoff["commitList"],
|
|
38
|
-
"sourceVerification": handoff["sourceVerificationReport"],
|
|
39
|
-
}
|
|
40
34
|
context = {
|
|
41
35
|
"humanSummary": data["humanSummary"],
|
|
42
36
|
"handoff": handoff,
|
|
@@ -44,7 +38,6 @@ def build_release_handoff_view(data: dict) -> HumanReportView:
|
|
|
44
38
|
"handoffFigure": figure,
|
|
45
39
|
"prUrl": _safe_http_url(handoff["pullRequestOutcome"].get("url", "")),
|
|
46
40
|
"evidenceIndex": evidence_index(data),
|
|
47
|
-
"audit": audit,
|
|
48
41
|
}
|
|
49
42
|
return HumanReportView(
|
|
50
43
|
"release-handoff",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Human-first requirements-discovery view model."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
-
from ..common import
|
|
4
|
+
from ..common import evidence_index
|
|
5
5
|
from ..models import HumanReportView, VisualEdge, VisualNode
|
|
6
6
|
from ..visualizations import decision_flow_figure
|
|
7
7
|
|
|
@@ -15,6 +15,7 @@ def _routing_figure(data: dict, requirements: dict):
|
|
|
15
15
|
"current",
|
|
16
16
|
"active",
|
|
17
17
|
requirements["systemInterpretation"],
|
|
18
|
+
note="This task",
|
|
18
19
|
)
|
|
19
20
|
}
|
|
20
21
|
edges = []
|
|
@@ -22,10 +23,12 @@ def _routing_figure(data: dict, requirements: dict):
|
|
|
22
23
|
source = row["fromTaskKey"]
|
|
23
24
|
target = row["toTaskKey"]
|
|
24
25
|
nodes.setdefault(
|
|
25
|
-
source,
|
|
26
|
+
source,
|
|
27
|
+
VisualNode(source, source, "current", "active", row["relation"], note="This task"),
|
|
26
28
|
)
|
|
27
29
|
nodes.setdefault(
|
|
28
|
-
target,
|
|
30
|
+
target,
|
|
31
|
+
VisualNode(target, target, "next", "pending", row["relation"], note="Next task"),
|
|
29
32
|
)
|
|
30
33
|
edges.append(VisualEdge(source, target, row["relation"], "dependency"))
|
|
31
34
|
return decision_flow_figure(
|
|
@@ -45,7 +48,6 @@ def build_requirements_discovery_view(data: dict) -> HumanReportView:
|
|
|
45
48
|
"routingFigure": figure,
|
|
46
49
|
"clarificationItems": data.get("clarificationItems", []),
|
|
47
50
|
"evidenceIndex": evidence_index(data),
|
|
48
|
-
"audit": audit_context(data),
|
|
49
51
|
}
|
|
50
52
|
return HumanReportView(
|
|
51
53
|
"requirements-discovery",
|
|
@@ -13,23 +13,124 @@ _COLUMN_WIDTH = 260
|
|
|
13
13
|
_ROW_HEIGHT = 100
|
|
14
14
|
|
|
15
15
|
|
|
16
|
-
def
|
|
17
|
-
"""
|
|
18
|
-
|
|
16
|
+
def _group_columns(nodes: Sequence[VisualNode]) -> list[list[str]]:
|
|
17
|
+
"""One column per group, wrapping a tall group into extra columns.
|
|
18
|
+
|
|
19
|
+
This is the layout for a figure with no edges — a coverage or matrix view,
|
|
20
|
+
where the group is the only structure there is to show.
|
|
21
|
+
"""
|
|
19
22
|
counts: dict[str, int] = {}
|
|
20
23
|
column_offset: dict[str, int] = {}
|
|
21
24
|
offset = 0
|
|
22
|
-
for group in
|
|
25
|
+
for group in sorted({n.group for n in nodes}):
|
|
23
26
|
column_offset[group] = offset
|
|
24
27
|
size = sum(1 for n in nodes if n.group == group)
|
|
25
28
|
offset += max(1, -(-size // _ROWS_PER_COLUMN))
|
|
26
|
-
|
|
29
|
+
columns: list[list[str]] = [[] for _ in range(max(offset, 1))]
|
|
27
30
|
for node in nodes:
|
|
28
31
|
index = counts.get(node.group, 0)
|
|
29
32
|
counts[node.group] = index + 1
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
+
columns[column_offset[node.group] + index // _ROWS_PER_COLUMN].append(node.id)
|
|
34
|
+
return columns
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _depths(node_ids: list[str], edges: Sequence[VisualEdge]) -> dict[str, int]:
|
|
38
|
+
"""Longest path from a root, so a node sits past every one it depends on.
|
|
39
|
+
|
|
40
|
+
Relaxing until nothing moves settles the longest path; a cycle would relax
|
|
41
|
+
forever, so the pass count bounds it — a dependency graph that loops has no
|
|
42
|
+
correct layering anyway, and stopping leaves it readable rather than hung.
|
|
43
|
+
"""
|
|
44
|
+
depth = {node_id: 0 for node_id in node_ids}
|
|
45
|
+
links = [(e.source, e.target) for e in edges if e.source in depth and e.target in depth]
|
|
46
|
+
for _ in range(len(depth)):
|
|
47
|
+
settled = True
|
|
48
|
+
for source, target in links:
|
|
49
|
+
if depth[target] < depth[source] + 1:
|
|
50
|
+
depth[target] = depth[source] + 1
|
|
51
|
+
settled = False
|
|
52
|
+
if settled:
|
|
53
|
+
break
|
|
54
|
+
return depth
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
_ORDERING_SWEEPS = 4
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _ordered_by_neighbours(
|
|
61
|
+
column: list[str], neighbours: list[str], links: list[tuple[str, str]], *, backward: bool
|
|
62
|
+
) -> list[str]:
|
|
63
|
+
"""Sort a column to sit opposite the nodes it connects to.
|
|
64
|
+
|
|
65
|
+
One direction is not enough: ordering a column by its parents can pull it
|
|
66
|
+
out of line with its children, so the sweeps alternate.
|
|
67
|
+
"""
|
|
68
|
+
rank = {node_id: index for index, node_id in enumerate(neighbours)}
|
|
69
|
+
original = {node_id: index for index, node_id in enumerate(column)}
|
|
70
|
+
|
|
71
|
+
def key(node_id: str) -> tuple[float, int]:
|
|
72
|
+
if backward:
|
|
73
|
+
related = [rank[t] for s, t in links if s == node_id and t in rank]
|
|
74
|
+
else:
|
|
75
|
+
related = [rank[s] for s, t in links if t == node_id and s in rank]
|
|
76
|
+
centre = sum(related) / len(related) if related else float(original[node_id])
|
|
77
|
+
return (centre, original[node_id])
|
|
78
|
+
|
|
79
|
+
return sorted(column, key=key)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _uncross(columns: list[list[str]], links: list[tuple[str, str]]) -> list[list[str]]:
|
|
83
|
+
"""Alternate forward and backward barycentre sweeps until they settle."""
|
|
84
|
+
for sweep in range(_ORDERING_SWEEPS):
|
|
85
|
+
before = [list(column) for column in columns]
|
|
86
|
+
if sweep % 2 == 0:
|
|
87
|
+
for index in range(1, len(columns)):
|
|
88
|
+
columns[index] = _ordered_by_neighbours(
|
|
89
|
+
columns[index], columns[index - 1], links, backward=False
|
|
90
|
+
)
|
|
91
|
+
else:
|
|
92
|
+
for index in range(len(columns) - 2, -1, -1):
|
|
93
|
+
columns[index] = _ordered_by_neighbours(
|
|
94
|
+
columns[index], columns[index + 1], links, backward=True
|
|
95
|
+
)
|
|
96
|
+
if columns == before:
|
|
97
|
+
break
|
|
98
|
+
return columns
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _layer_columns(nodes: Sequence[VisualNode], edges: Sequence[VisualEdge]) -> list[list[str]]:
|
|
102
|
+
"""Columns by dependency depth, with the unconnected nodes held back.
|
|
103
|
+
|
|
104
|
+
A node with no edge has no place in the dependency story, and letting it
|
|
105
|
+
take a slot pushed the nodes that do relate to each other apart. They get
|
|
106
|
+
their own columns after the graph.
|
|
107
|
+
"""
|
|
108
|
+
linked = {e.source for e in edges} | {e.target for e in edges}
|
|
109
|
+
connected = [n.id for n in nodes if n.id in linked]
|
|
110
|
+
loose = [n.id for n in nodes if n.id not in linked]
|
|
111
|
+
depth = _depths(connected, edges)
|
|
112
|
+
links = [(e.source, e.target) for e in edges if e.source in depth and e.target in depth]
|
|
113
|
+
|
|
114
|
+
columns: list[list[str]] = []
|
|
115
|
+
for level in range(max(depth.values(), default=-1) + 1):
|
|
116
|
+
columns.append([node_id for node_id in connected if depth[node_id] == level])
|
|
117
|
+
columns = _uncross(columns, links)
|
|
118
|
+
for index in range(0, len(loose), _ROWS_PER_COLUMN):
|
|
119
|
+
columns.append(loose[index:index + _ROWS_PER_COLUMN])
|
|
120
|
+
return columns or [[]]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _node_positions(
|
|
124
|
+
nodes: Sequence[VisualNode], edges: Sequence[VisualEdge] = ()
|
|
125
|
+
) -> dict[str, tuple[int, int]]:
|
|
126
|
+
columns = _layer_columns(nodes, edges) if edges else _group_columns(nodes)
|
|
127
|
+
positions: dict[str, tuple[int, int]] = {}
|
|
128
|
+
for column_index, column in enumerate(columns):
|
|
129
|
+
for row_index, node_id in enumerate(column):
|
|
130
|
+
positions[node_id] = (
|
|
131
|
+
50 + column_index * _COLUMN_WIDTH,
|
|
132
|
+
55 + row_index * _ROW_HEIGHT,
|
|
133
|
+
)
|
|
33
134
|
return positions
|
|
34
135
|
|
|
35
136
|
|
|
@@ -41,7 +142,7 @@ _ARROW_MARKER = (
|
|
|
41
142
|
|
|
42
143
|
|
|
43
144
|
def _svg_document(nodes: Sequence[VisualNode], edges: Sequence[VisualEdge]) -> str:
|
|
44
|
-
positions = _node_positions(nodes)
|
|
145
|
+
positions = _node_positions(nodes, edges)
|
|
45
146
|
width = max((x for x, _ in positions.values()), default=50) + 230
|
|
46
147
|
height = max((y for _, y in positions.values()), default=55) + 90
|
|
47
148
|
parts = [f'<svg viewBox="0 0 {width} {height}" role="img" xmlns="http://www.w3.org/2000/svg">']
|
|
@@ -51,10 +152,28 @@ def _svg_document(nodes: Sequence[VisualNode], edges: Sequence[VisualEdge]) -> s
|
|
|
51
152
|
continue
|
|
52
153
|
x1, y1 = positions[edge.source]
|
|
53
154
|
x2, y2 = positions[edge.target]
|
|
155
|
+
# Leave the source box on its right edge and arrive on the target's
|
|
156
|
+
# left, so a left-to-right layering reads as one direction of travel.
|
|
157
|
+
start_x, end_x = (x1 + 180, x2) if x2 > x1 else ((x1, x2 + 180) if x2 < x1 else (x1 + 90, x2 + 90))
|
|
158
|
+
start_y, end_y = y1 + 25, y2 + 25
|
|
159
|
+
span = abs(x2 - x1) // _COLUMN_WIDTH
|
|
160
|
+
if span > 1:
|
|
161
|
+
# An edge that skips a column would otherwise be drawn straight
|
|
162
|
+
# through the boxes standing in it. Arcing it clear of the band is
|
|
163
|
+
# what removes the crossings the layering itself cannot.
|
|
164
|
+
# Clamped so a long skip from the top row still arcs inside
|
|
165
|
+
# the canvas instead of being drawn off it.
|
|
166
|
+
lift = max(10, min(start_y, end_y) - 30 - 12 * span)
|
|
167
|
+
geometry = (
|
|
168
|
+
f'<path d="M {start_x} {start_y} Q {(start_x + end_x) // 2} {lift} {end_x} {end_y}" '
|
|
169
|
+
f'fill="none"'
|
|
170
|
+
)
|
|
171
|
+
else:
|
|
172
|
+
geometry = f'<line x1="{start_x}" y1="{start_y}" x2="{end_x}" y2="{end_y}"'
|
|
54
173
|
parts.append(
|
|
55
174
|
f'<g class="edge-group"><title>{html.escape(edge.label)}</title>'
|
|
56
|
-
f'
|
|
57
|
-
f'
|
|
175
|
+
f'{geometry} class="edge edge-{html.escape(edge.kind)}" '
|
|
176
|
+
f'marker-end="url(#edge-arrow)" /></g>'
|
|
58
177
|
)
|
|
59
178
|
for node in nodes:
|
|
60
179
|
x, y = positions[node.id]
|
|
@@ -86,6 +205,22 @@ def dependency_figure(*, nodes: Sequence[VisualNode], edges: Sequence[VisualEdge
|
|
|
86
205
|
)
|
|
87
206
|
|
|
88
207
|
|
|
208
|
+
def infrastructure_figure(*, nodes: Sequence[VisualNode], edges: Sequence[VisualEdge], title: str) -> FigureModel:
|
|
209
|
+
return replace(
|
|
210
|
+
dependency_figure(nodes=nodes, edges=edges, title=title),
|
|
211
|
+
kind="infrastructure",
|
|
212
|
+
figure_id="infrastructure-graph",
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def workflow_figure(*, nodes: Sequence[VisualNode], edges: Sequence[VisualEdge], title: str) -> FigureModel:
|
|
217
|
+
return replace(
|
|
218
|
+
dependency_figure(nodes=nodes, edges=edges, title=title),
|
|
219
|
+
kind="workflow",
|
|
220
|
+
figure_id="workflow-graph",
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
89
224
|
def flow_figure(*, nodes: Sequence[VisualNode], edges: Sequence[VisualEdge], title: str) -> FigureModel:
|
|
90
225
|
return replace(dependency_figure(nodes=nodes, edges=edges, title=title), kind="flow", figure_id="flow-graph")
|
|
91
226
|
|
|
@@ -15,3 +15,8 @@ def html_view_path(report_path: Path) -> Path:
|
|
|
15
15
|
def user_responses_dir_for_report(report_path: Path) -> Path:
|
|
16
16
|
"""Return the sidecar directory used by the report's exported responses."""
|
|
17
17
|
return report_path.parent.parent / "user-responses"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def team_state_path_for_report(report_path: Path, task_type: str, seq: str) -> Path:
|
|
21
|
+
"""Return the team-state sibling that records the run's usage windows."""
|
|
22
|
+
return report_path.parent.parent / "state" / f"team-state-{task_type}-{seq}.json"
|
|
@@ -47,7 +47,7 @@ def _parse_iso(value: str) -> dt.datetime:
|
|
|
47
47
|
return dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
48
48
|
|
|
49
49
|
|
|
50
|
-
def
|
|
50
|
+
def wall_clock_ms(state: dict) -> int:
|
|
51
51
|
"""run 의 실제 경과(벽시계) = max(endedAt) − min(startedAt). lead·worker 윈도가
|
|
52
52
|
겹치므로 cpuSum 과 다르다. timestamp 부족하면 0."""
|
|
53
53
|
times: list[dt.datetime] = []
|
|
@@ -105,7 +105,7 @@ def _collect_run(run: dict, project_root: Path) -> dict:
|
|
|
105
105
|
if lead == 0 and all(w["durationMs"] == 0 for w in workers):
|
|
106
106
|
return {**base, "reason": "no durationMs (Phase 7 not reached)"}
|
|
107
107
|
return {**base, "leadMs": lead, "workers": workers,
|
|
108
|
-
"wallClockMs":
|
|
108
|
+
"wallClockMs": wall_clock_ms(state), "phases": _phase_rows(state)}
|
|
109
109
|
|
|
110
110
|
|
|
111
111
|
def _by_task_type(collected: list[dict]) -> list[dict]:
|
|
@@ -11,7 +11,7 @@ from typing import Any
|
|
|
11
11
|
from okstra_ctl.time_report import (
|
|
12
12
|
_duration_ms,
|
|
13
13
|
_read_state,
|
|
14
|
-
|
|
14
|
+
wall_clock_ms,
|
|
15
15
|
load_runs,
|
|
16
16
|
)
|
|
17
17
|
from okstra_project import ResolverError, list_project_tasks, resolve_project_root
|
|
@@ -94,7 +94,7 @@ def _wall_clock_from_blocks(lead: dict, workers: list[dict]) -> int:
|
|
|
94
94
|
"workers": [{"usage": usage} for usage in workers],
|
|
95
95
|
}
|
|
96
96
|
try:
|
|
97
|
-
return
|
|
97
|
+
return wall_clock_ms(state)
|
|
98
98
|
except TypeError:
|
|
99
99
|
return 0
|
|
100
100
|
|