openfactory 0.2.0__py3-none-any.whl

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 (270) hide show
  1. openfactory/__init__.py +7 -0
  2. openfactory/actions/__init__.py +344 -0
  3. openfactory/actions/base.py +334 -0
  4. openfactory/actions/catalog.py +4872 -0
  5. openfactory/actions/floor_intents.py +431 -0
  6. openfactory/adapters/__init__.py +0 -0
  7. openfactory/adapters/agent/__init__.py +71 -0
  8. openfactory/adapters/agent/base.py +358 -0
  9. openfactory/adapters/agent/claude_code.py +1023 -0
  10. openfactory/adapters/agent/codex.py +350 -0
  11. openfactory/adapters/agent/kimi.py +291 -0
  12. openfactory/adapters/agent/opencode.py +432 -0
  13. openfactory/adapters/agent/registry.py +485 -0
  14. openfactory/adapters/agent/roles.py +263 -0
  15. openfactory/adapters/agent/routes.py +255 -0
  16. openfactory/adapters/agent/session_store.py +221 -0
  17. openfactory/adapters/agent/stream.py +367 -0
  18. openfactory/adapters/agent/techlead.py +108 -0
  19. openfactory/adapters/agent/token_pool.py +67 -0
  20. openfactory/adapters/azure_devops.py +329 -0
  21. openfactory/adapters/board/__init__.py +10 -0
  22. openfactory/adapters/board/azure_devops.py +748 -0
  23. openfactory/adapters/board/base.py +193 -0
  24. openfactory/adapters/board/columns.py +68 -0
  25. openfactory/adapters/board/factory.py +218 -0
  26. openfactory/adapters/board/jira.py +252 -0
  27. openfactory/adapters/board/local.py +187 -0
  28. openfactory/adapters/board_db.py +276 -0
  29. openfactory/adapters/board_setup/__init__.py +8 -0
  30. openfactory/adapters/board_setup/base.py +49 -0
  31. openfactory/adapters/board_setup/local.py +63 -0
  32. openfactory/adapters/board_setup/registry.py +41 -0
  33. openfactory/adapters/channel/__init__.py +4 -0
  34. openfactory/adapters/channel/base.py +100 -0
  35. openfactory/adapters/channel/panel.py +107 -0
  36. openfactory/adapters/channel/registry.py +112 -0
  37. openfactory/adapters/credential/__init__.py +8 -0
  38. openfactory/adapters/credential/registry.py +164 -0
  39. openfactory/adapters/environment/__init__.py +30 -0
  40. openfactory/adapters/environment/azure_pipelines.py +412 -0
  41. openfactory/adapters/environment/base.py +35 -0
  42. openfactory/adapters/environment/github_actions.py +97 -0
  43. openfactory/adapters/environment/none.py +53 -0
  44. openfactory/adapters/environment/registry.py +160 -0
  45. openfactory/adapters/forge/__init__.py +30 -0
  46. openfactory/adapters/forge/azure_devops.py +1528 -0
  47. openfactory/adapters/forge/base.py +515 -0
  48. openfactory/adapters/forge/github.py +779 -0
  49. openfactory/adapters/forge/local.py +660 -0
  50. openfactory/adapters/forge/registry.py +210 -0
  51. openfactory/adapters/github_app.py +70 -0
  52. openfactory/adapters/notify/__init__.py +12 -0
  53. openfactory/adapters/notify/base.py +45 -0
  54. openfactory/adapters/notify/panel.py +43 -0
  55. openfactory/adapters/notify/registry.py +301 -0
  56. openfactory/adapters/reviewer/__init__.py +30 -0
  57. openfactory/adapters/reviewer/base.py +34 -0
  58. openfactory/adapters/reviewer/claude_code.py +140 -0
  59. openfactory/adapters/reviewer/harness.py +154 -0
  60. openfactory/adapters/sandbox/__init__.py +31 -0
  61. openfactory/adapters/sandbox/base.py +410 -0
  62. openfactory/adapters/sandbox/container.py +550 -0
  63. openfactory/adapters/sandbox/registry.py +490 -0
  64. openfactory/adapters/sandbox/timeouts.py +76 -0
  65. openfactory/adapters/sandbox/worktree.py +381 -0
  66. openfactory/adapters/tracker/__init__.py +30 -0
  67. openfactory/adapters/tracker/azure_devops.py +904 -0
  68. openfactory/adapters/tracker/base.py +549 -0
  69. openfactory/adapters/tracker/github.py +529 -0
  70. openfactory/adapters/tracker/github_board_setup.py +216 -0
  71. openfactory/adapters/tracker/github_project.py +733 -0
  72. openfactory/adapters/tracker/jira.py +575 -0
  73. openfactory/adapters/tracker/local.py +379 -0
  74. openfactory/adapters/tracker/parse.py +231 -0
  75. openfactory/adapters/tracker/registry.py +200 -0
  76. openfactory/after_merge.py +51 -0
  77. openfactory/api/__init__.py +4 -0
  78. openfactory/api/app.py +2627 -0
  79. openfactory/api/metrics_view.py +337 -0
  80. openfactory/api/panel.html +3419 -0
  81. openfactory/approvals.py +86 -0
  82. openfactory/box_prove.py +1458 -0
  83. openfactory/cli.py +3298 -0
  84. openfactory/cli_refusals.py +169 -0
  85. openfactory/conformance/__init__.py +19 -0
  86. openfactory/conformance/adapters.py +622 -0
  87. openfactory/contracts/__init__.py +65 -0
  88. openfactory/contracts/bot.py +42 -0
  89. openfactory/contracts/commands.py +183 -0
  90. openfactory/contracts/decision.py +286 -0
  91. openfactory/contracts/manifest.py +713 -0
  92. openfactory/contracts/product.py +95 -0
  93. openfactory/contracts/profile.py +214 -0
  94. openfactory/contracts/project.py +353 -0
  95. openfactory/contracts/refs.py +155 -0
  96. openfactory/contracts/review.py +52 -0
  97. openfactory/contracts/run.py +326 -0
  98. openfactory/contracts/state.py +64 -0
  99. openfactory/contracts/ticket.py +113 -0
  100. openfactory/credentials.py +356 -0
  101. openfactory/doctor.py +1577 -0
  102. openfactory/doors.py +238 -0
  103. openfactory/environ.py +251 -0
  104. openfactory/factory.py +460 -0
  105. openfactory/floor/__init__.py +31 -0
  106. openfactory/floor/ladder.py +655 -0
  107. openfactory/floor/reading.py +302 -0
  108. openfactory/identity/__init__.py +14 -0
  109. openfactory/identity/base.py +95 -0
  110. openfactory/identity/local.py +186 -0
  111. openfactory/identity/oidc.py +633 -0
  112. openfactory/identity/people.py +352 -0
  113. openfactory/identity/registry.py +91 -0
  114. openfactory/knowledge/__init__.py +76 -0
  115. openfactory/knowledge/bundle.py +219 -0
  116. openfactory/knowledge/check.py +167 -0
  117. openfactory/knowledge/contracts.py +407 -0
  118. openfactory/knowledge/experiment.py +118 -0
  119. openfactory/knowledge/gaps.py +118 -0
  120. openfactory/knowledge/gate.py +330 -0
  121. openfactory/knowledge/gather.py +156 -0
  122. openfactory/knowledge/generator.py +822 -0
  123. openfactory/knowledge/inventory.py +557 -0
  124. openfactory/knowledge/okf.py +369 -0
  125. openfactory/knowledge/pipeline.py +333 -0
  126. openfactory/knowledge/render.py +146 -0
  127. openfactory/knowledge/service.py +53 -0
  128. openfactory/knowledge/staleness.py +112 -0
  129. openfactory/language/__init__.py +15 -0
  130. openfactory/language/assent.py +130 -0
  131. openfactory/loader.py +156 -0
  132. openfactory/memory/__init__.py +29 -0
  133. openfactory/memory/ledger.py +215 -0
  134. openfactory/memory/messages.py +406 -0
  135. openfactory/memory/recall.py +303 -0
  136. openfactory/memory/store.py +157 -0
  137. openfactory/memory/transcript.py +267 -0
  138. openfactory/namespace.py +258 -0
  139. openfactory/observability/__init__.py +23 -0
  140. openfactory/observability/events.py +188 -0
  141. openfactory/observability/metrics.py +191 -0
  142. openfactory/observability/query.py +80 -0
  143. openfactory/observability/registry.py +253 -0
  144. openfactory/observability/sqlite_metrics.py +203 -0
  145. openfactory/observability/trajectory.py +243 -0
  146. openfactory/onboarding/__init__.py +64 -0
  147. openfactory/onboarding/concepts.py +450 -0
  148. openfactory/onboarding/context.py +2399 -0
  149. openfactory/onboarding/cover.py +97 -0
  150. openfactory/onboarding/deployment.py +807 -0
  151. openfactory/onboarding/firstrun.py +1637 -0
  152. openfactory/onboarding/history.py +363 -0
  153. openfactory/onboarding/infer.py +2736 -0
  154. openfactory/onboarding/live_ci.py +162 -0
  155. openfactory/onboarding/onboard.py +1070 -0
  156. openfactory/onboarding/propose_manifest.py +349 -0
  157. openfactory/onboarding/questions.py +137 -0
  158. openfactory/onboarding/readiness.py +1073 -0
  159. openfactory/onboarding/renew.py +239 -0
  160. openfactory/onboarding/spend.py +82 -0
  161. openfactory/ops/__init__.py +6 -0
  162. openfactory/ops/impediment.py +288 -0
  163. openfactory/orchestrator/__init__.py +26 -0
  164. openfactory/orchestrator/context.py +231 -0
  165. openfactory/orchestrator/errors.py +27 -0
  166. openfactory/orchestrator/machine.py +2617 -0
  167. openfactory/orchestrator/merge_policy.py +203 -0
  168. openfactory/orchestrator/promotion.py +249 -0
  169. openfactory/orchestrator/risk.py +191 -0
  170. openfactory/orchestrator/validation.py +172 -0
  171. openfactory/org_defaults/engineering.md +75 -0
  172. openfactory/org_defaults/floor.yaml +182 -0
  173. openfactory/org_defaults/profiles/prototype.yaml +16 -0
  174. openfactory/org_defaults/profiles/regulated.yaml +35 -0
  175. openfactory/org_defaults/requirements/0000-template.md +39 -0
  176. openfactory/org_defaults/roles/coordinator.md +48 -0
  177. openfactory/org_defaults/roles/executor.md +92 -0
  178. openfactory/org_defaults/roles/planner.md +117 -0
  179. openfactory/org_defaults/roles/product.md +71 -0
  180. openfactory/org_defaults/roles/recovery.md +33 -0
  181. openfactory/org_defaults/roles/sizer.md +62 -0
  182. openfactory/org_defaults/roles/techlead.md +61 -0
  183. openfactory/org_defaults/tdd.md +10 -0
  184. openfactory/own_work.py +41 -0
  185. openfactory/paths.py +117 -0
  186. openfactory/plugins.py +208 -0
  187. openfactory/policy/__init__.py +10 -0
  188. openfactory/policy/authz.py +105 -0
  189. openfactory/policy/census.py +161 -0
  190. openfactory/policy/conformance.py +300 -0
  191. openfactory/policy/floor.py +42 -0
  192. openfactory/policy/presets.py +172 -0
  193. openfactory/policy/profiles.py +275 -0
  194. openfactory/policy/protected.py +169 -0
  195. openfactory/policy/test_work.py +70 -0
  196. openfactory/preflight.py +577 -0
  197. openfactory/presets/node.yaml +6 -0
  198. openfactory/presets/python.yaml +9 -0
  199. openfactory/presets/security-oss.yaml +26 -0
  200. openfactory/presets/terraform.yaml +8 -0
  201. openfactory/product/__init__.py +56 -0
  202. openfactory/product/asked.py +172 -0
  203. openfactory/product/authoring.py +1791 -0
  204. openfactory/product/board.py +484 -0
  205. openfactory/product/brownfield.py +210 -0
  206. openfactory/product/case.py +409 -0
  207. openfactory/product/channel.py +1437 -0
  208. openfactory/product/config.py +364 -0
  209. openfactory/product/confirm.py +801 -0
  210. openfactory/product/conversation.py +49 -0
  211. openfactory/product/corpus.py +462 -0
  212. openfactory/product/domain.py +242 -0
  213. openfactory/product/facts.py +209 -0
  214. openfactory/product/followup.py +647 -0
  215. openfactory/product/intents.py +641 -0
  216. openfactory/product/loader.py +198 -0
  217. openfactory/product/module.py +3100 -0
  218. openfactory/product/needs_action.py +198 -0
  219. openfactory/product/onboard.py +331 -0
  220. openfactory/product/queue.py +273 -0
  221. openfactory/product/reading.py +130 -0
  222. openfactory/product/release.py +153 -0
  223. openfactory/product/requester.py +55 -0
  224. openfactory/product/role.py +1356 -0
  225. openfactory/product/staging.py +477 -0
  226. openfactory/product/triage.py +277 -0
  227. openfactory/product/voice.py +2080 -0
  228. openfactory/product/workspace.py +182 -0
  229. openfactory/registry.py +526 -0
  230. openfactory/review/__init__.py +11 -0
  231. openfactory/review/verdict.py +199 -0
  232. openfactory/runtime/__init__.py +0 -0
  233. openfactory/runtime/boxed_job.py +399 -0
  234. openfactory/runtime/card_repo.py +146 -0
  235. openfactory/runtime/host.py +91 -0
  236. openfactory/runtime/repo_cache.py +296 -0
  237. openfactory/runtime/temporal/__init__.py +26 -0
  238. openfactory/runtime/temporal/activities.py +5081 -0
  239. openfactory/runtime/temporal/connection.py +89 -0
  240. openfactory/runtime/temporal/io.py +639 -0
  241. openfactory/runtime/temporal/poller.py +185 -0
  242. openfactory/runtime/temporal/schedule.py +465 -0
  243. openfactory/runtime/temporal/starter.py +54 -0
  244. openfactory/runtime/temporal/view.py +884 -0
  245. openfactory/runtime/temporal/worker.py +325 -0
  246. openfactory/runtime/temporal/workflow.py +2459 -0
  247. openfactory/runtime/toolbox.py +163 -0
  248. openfactory/scheduler.py +75 -0
  249. openfactory/semver.py +35 -0
  250. openfactory/techlead/__init__.py +38 -0
  251. openfactory/techlead/classify.py +427 -0
  252. openfactory/techlead/conversation.py +1401 -0
  253. openfactory/techlead/diagnosis.py +251 -0
  254. openfactory/techlead/memory.py +264 -0
  255. openfactory/techlead/pack.py +169 -0
  256. openfactory/techlead/voice.py +679 -0
  257. openfactory/techlead/watch.py +638 -0
  258. openfactory/testing/__init__.py +1 -0
  259. openfactory/testing/local_flow.py +272 -0
  260. openfactory/util/__init__.py +0 -0
  261. openfactory/util/bounded.py +77 -0
  262. openfactory/util/causes.py +125 -0
  263. openfactory/util/scratch.py +80 -0
  264. openfactory-0.2.0.dist-info/METADATA +41 -0
  265. openfactory-0.2.0.dist-info/RECORD +270 -0
  266. openfactory-0.2.0.dist-info/WHEEL +5 -0
  267. openfactory-0.2.0.dist-info/entry_points.txt +2 -0
  268. openfactory-0.2.0.dist-info/licenses/LICENSE +202 -0
  269. openfactory-0.2.0.dist-info/licenses/NOTICE +22 -0
  270. openfactory-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,337 @@
1
+ """Cost-dashboard bridge — read the metrics table (observability.metrics) and shape it for the
2
+ panel's PER-PROJECT cost dashboard: filterable rollups (period / model / harness / role) plus a
3
+ per-task table (with wall-clock time). Read-only + best-effort: any failure degrades to empty so
4
+ the panel still renders. Low volume (dozens of jobs/day) → a full Scan + in-process aggregation."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ import os
10
+ from collections import defaultdict
11
+
12
+ log = logging.getLogger("openfactory.metrics")
13
+
14
+
15
+ def _configured_sink():
16
+ """The sink this deployment WRITES to, so a reader asks the same place (audit, 2026-08-02).
17
+
18
+ Both readers used to open with `os.environ.get("OPENFACTORY_METRICS_TABLE")` and return `[]`
19
+ when it
20
+ was unset — which on a `docker compose` install is always, because the local answer is SQLite
21
+ and that variable belongs to DynamoDB. So a perfectly configured local deployment wrote metrics
22
+ to disk and every reader answered "nothing recorded": the cost dashboard, the tech-lead's
23
+ memory of what it had already said, recurring-failure detection and the open-loop ledger, all
24
+ silently blind. `SqliteMetricsSink` had the two matching read methods the whole time, tested,
25
+ and called by nothing.
26
+
27
+ Returns None when the sink cannot read, which every caller already treats as "no data" — and
28
+ SAYS SO when that is a gap rather than a choice. The null sink dropped every record it was
29
+ given, so "nothing to read" is its true answer; any other sink that is not a `ReadableSink`
30
+ recorded rows nobody can read back, which is the cost dashboard, the tech-lead's memory and
31
+ the open-loop ledger all silently blind, and that is logged as a WARNING rather than read as
32
+ an empty store.
33
+
34
+ THE CHECK IS ON THE PORT (`ReadableSink`), not `hasattr(sink, "scan")` — and the two readers no
35
+ longer duck-type `table_name` as "this is DynamoDB". A third-party sink with an ordinary
36
+ `table_name` attribute was starved to `[]` by one reader and routed to a vendor's client by
37
+ the other (probes C/D, 2026-08-24).
38
+ """
39
+ from openfactory.observability.metrics import NullMetricsSink, ReadableSink
40
+ from openfactory.observability.registry import build_metrics_sink, metrics_sink_kind
41
+
42
+ kind = metrics_sink_kind()
43
+ try:
44
+ sink = build_metrics_sink(kind, path=os.environ.get("OPENFACTORY_METRICS_DB"))
45
+ except Exception as exc: # noqa: BLE001 — an unreadable store is empty, never a 500
46
+ log.warning("metrics sink %r could not be built for reading (%s) — reporting no data",
47
+ kind, exc)
48
+ return None
49
+ if isinstance(sink, ReadableSink):
50
+ return sink
51
+ if not isinstance(sink, NullMetricsSink):
52
+ log.warning("the configured metrics sink %r (%s) records and cannot be read back — the "
53
+ "dashboard and the agents' memory report nothing until a readable sink is "
54
+ "configured", kind, type(sink).__name__)
55
+ return None
56
+
57
+
58
+ def _opt_int(value) -> int | None:
59
+ """An int, or None when the value was never measured.
60
+
61
+ NOT `int(x or 0)`, which is what every other numeric here does and is right for them: a cost of
62
+ zero is a cost. A trajectory dimension of zero would say the agent called no tools, and the
63
+ rows that carry None are the rows where nobody could read the stream at all. The two must not
64
+ average together."""
65
+ try:
66
+ return int(float(value))
67
+ except (TypeError, ValueError):
68
+ # None and "" arrive here, and that is deliberate rather than lazy: an explicit
69
+ # `if value is None` clause in front of this was DEAD — the mutation that removed it
70
+ # changed nothing, because `float(None)` raises and lands in exactly this branch. One
71
+ # spelling of "not a number", not two, one of which is never reached.
72
+ return None
73
+
74
+
75
+ def scan_records(table_name: str | None = None, region: str | None = None) -> list[dict]:
76
+ """Every metric item, numbers parsed back from their stored strings. [] on any trouble
77
+ (table absent, no creds, scan error) — the dashboard shows 'no data yet', never an error.
78
+
79
+ THE DEGRADING DOOR, and it stays that way. A cost dashboard that 500s because the store hiccuped
80
+ is worse than one showing yesterday's shape; nobody decides anything from it in the next thirty
81
+ seconds. `scan_all_or_raise` is the same read for callers that DO — see #126, where this
82
+ function's `[]` reached the panel's human gates through three layers and told an operator their
83
+ question had already been answered."""
84
+ try:
85
+ return scan_all_or_raise(table_name=table_name, region=region)
86
+ except Exception as exc: # noqa: BLE001 — the dashboard degrades to empty, never 500s
87
+ log.warning("metrics scan failed for %s: %s", table_name or "the configured sink", exc)
88
+ return []
89
+
90
+
91
+ def scan_all_or_raise(table_name: str | None = None, region: str | None = None) -> list[dict]:
92
+ """`scan_records`, but an unreadable store RAISES rather than reading as an empty one.
93
+
94
+ `[]` here means the store answered and had nothing, or that no readable store is configured —
95
+ which is a supported deployment shape, not a failure.
96
+
97
+ THE SINK THE REGISTRY BUILT IS THE ONLY DOOR. This used to fall through to
98
+ `OPENFACTORY_METRICS_TABLE` whenever that sink could not read, so a deployment on any other
99
+ sink with the table variable set reached for one vendor's client. An explicit `table_name` is
100
+ the operator's override, and it is registry-shaped: the CONFIGURED sink's kind, pointed at that
101
+ table (`configured_metrics_sink`) — never a vendor's kind spelled here. Each sink parses its
102
+ own stored numbers — the DynamoDB one stringifies and parses back; nothing here knows that."""
103
+ if table_name:
104
+ from openfactory.observability.registry import configured_metrics_sink
105
+
106
+ return configured_metrics_sink(table=table_name, region=region).scan()
107
+ sink = _configured_sink()
108
+ return [] if sink is None else sink.scan()
109
+
110
+
111
+ def _round_map(d: dict) -> dict:
112
+ return {k: round(v, 4) for k, v in sorted(d.items(), key=lambda kv: -kv[1])}
113
+
114
+
115
+ def _median(xs: list[float]) -> float | None:
116
+ if not xs:
117
+ return None
118
+ s = sorted(xs)
119
+ mid = len(s) // 2
120
+ return s[mid] if len(s) % 2 else (s[mid - 1] + s[mid]) / 2
121
+
122
+
123
+ #: tickets that ran before the arm was recorded. `knowledge_map` has defaulted to false for every
124
+ #: project since the field existed, so these demonstrably ran WITHOUT the map — they are a real
125
+ #: baseline, not a mystery. What they are not is a *controlled* arm: nobody chose them, and their
126
+ #: ticket mix is whatever the factory happened to do. Kept as their own bucket so the baseline is
127
+ #: visible without being silently merged into the deliberate `off` control.
128
+ PRE_OKF = "pre_okf"
129
+
130
+ #: the treatment arm — the only one where the agent actually received the map
131
+ TREATMENT = "injected"
132
+
133
+
134
+ def _stats(xs: list[float]) -> dict:
135
+ """n / mean / median for one measure. Median matters as much as mean here: ticket sizes vary
136
+ enormously, so one large ticket landing in a small arm moves the mean and proves nothing."""
137
+ n = len(xs)
138
+ return {
139
+ "n": n,
140
+ "avg": round(sum(xs) / n, 4) if n else None,
141
+ "median": round(_median(xs) or 0.0, 4) if n else None,
142
+ }
143
+
144
+
145
+ def _knowledge_ab(tasks: list[dict]) -> dict:
146
+ """The Knowledge Layer's A/B readout (ADR-0017's gate), grouped by which arm each ticket ran
147
+ in: `injected` (the agent had the module map), `off` (the project hasn't opted in),
148
+ `unavailable` (opted in, but the map wasn't trustworthy for that checkout — those tickets ran
149
+ WITHOUT it, so they are controls, not treatment), and `pre_okf` (see above).
150
+
151
+ TOKENS ARE THE HEADLINE MEASURE, not cost. The claim being tested is that the map makes the
152
+ agent read less to find the same code, and tokens measure that directly. Cost is a derived
153
+ number that moves with per-model pricing and is simply absent for a harness that reports no
154
+ price (Codex), so an experiment resting on cost alone would be unreadable the moment the arms
155
+ ran on different engines.
156
+
157
+ The `delta` is a plain arithmetic difference between the treatment and its control, NOT a
158
+ verdict. Comparing arms is only valid if the ticket mix is comparable, and nothing here can
159
+ know that — which is why every bucket reports its own n and the delta names the control it
160
+ used. A human judges."""
161
+ arms: dict[str, dict] = {}
162
+ for t in tasks:
163
+ arm = t.get("knowledge") or PRE_OKF
164
+ # `pre_okf` is a BASELINE, not an arm, and it is kept out of the comparison entirely.
165
+ # Those tickets predate the experiment AND predate changes to what the platform counts
166
+ # (review-repair started being counted 2026-07-26), so putting them beside a chosen arm
167
+ # invites exactly the comparison that would be wrong. Reported separately, below.
168
+ a = arms.setdefault(arm, {"cost": [], "wall": [], "turns": [], "tokens": [],
169
+ "input": [], "output": []})
170
+ a["cost"].append(float(t.get("cost_usd") or 0.0))
171
+ if t.get("wall_s"):
172
+ a["wall"].append(float(t["wall_s"]))
173
+ a["turns"].append(float(t.get("turns") or 0))
174
+ if t.get("tokens"):
175
+ a["tokens"].append(float(t["tokens"]))
176
+ a["input"].append(float(t.get("input_tokens") or 0))
177
+ a["output"].append(float(t.get("output_tokens") or 0))
178
+
179
+ out: dict[str, dict] = {}
180
+ for arm, a in arms.items():
181
+ out[arm] = {
182
+ "tasks": len(a["cost"]),
183
+ "tokens": _stats(a["tokens"]),
184
+ "input_tokens": _stats(a["input"]),
185
+ "output_tokens": _stats(a["output"]),
186
+ "cost_usd": _stats(a["cost"]),
187
+ "wall_s": _stats(a["wall"]),
188
+ "turns": _stats(a["turns"]),
189
+ }
190
+ experiment = {k: v for k, v in out.items() if k != PRE_OKF}
191
+ return {"arms": experiment, "baseline": out.get(PRE_OKF), "delta": _delta(experiment)}
192
+
193
+
194
+ def _delta(arms: dict) -> dict | None:
195
+ """Treatment vs its control, per measure, as a signed percentage. `None` until both exist —
196
+ a one-arm readout has nothing to compare, and inventing a baseline is how a number that means
197
+ nothing ends up in a slide."""
198
+ t = arms.get(TREATMENT)
199
+ # `pre_okf` is deliberately NOT a candidate control: it is history, measured under a different
200
+ # platform, and a delta against it would be a before/after wearing an experiment's clothes.
201
+ control_name = next((c for c in ("off", "unavailable") if arms.get(c)), None)
202
+ if not t or not control_name:
203
+ return None
204
+ c = arms[control_name]
205
+ out = {"control": control_name, "treatment": TREATMENT, "measures": {}}
206
+ for m in ("tokens", "input_tokens", "output_tokens", "cost_usd", "wall_s", "turns"):
207
+ base, got = c[m].get("median"), t[m].get("median")
208
+ if base: # a zero or missing baseline yields no percentage, not a division by zero
209
+ out["measures"][m] = {
210
+ "control_median": base, "treatment_median": got,
211
+ "pct": round((got - base) / base * 100, 1) if got is not None else None,
212
+ }
213
+ return out
214
+
215
+
216
+ def dashboard(records: list[dict], project: str | None = None) -> dict:
217
+ """The panel's dashboard payload. `projects` lists every project seen (for the selector);
218
+ everything else is scoped to `project` (or the first project if none given). Cost breakdowns
219
+ come from `agent_run` items (model/harness/role + cost); the per-task table joins each
220
+ ticket's agent_runs (cost/turns/models/harnesses) with its `job` record (wall-clock, state)."""
221
+ projects = sorted({str(r.get("pk") or r.get("project") or "") for r in records
222
+ if (r.get("pk") or r.get("project"))})
223
+ project = project or (projects[0] if projects else "")
224
+
225
+ scoped = [r for r in records if str(r.get("pk") or r.get("project") or "") == project]
226
+ runs = [r for r in scoped if r.get("kind") == "agent_run"]
227
+ jobs = {str(r.get("ticket")): r for r in scoped if r.get("kind") == "job"}
228
+
229
+ def _sum_by(key: str) -> dict:
230
+ out: dict[str, float] = defaultdict(float)
231
+ for r in runs:
232
+ out[str(r.get(key) or "unknown")] += r.get("cost_usd") or 0.0
233
+ return _round_map(out)
234
+
235
+ by_day: dict[str, float] = defaultdict(float)
236
+ for r in runs:
237
+ day = str(r.get("ts") or "")[:10]
238
+ if day:
239
+ by_day[day] += r.get("cost_usd") or 0.0
240
+
241
+ # per-ticket rollup (the table). Group runs by ticket; join the job record for time/state.
242
+ per: dict[str, dict] = defaultdict(lambda: {"cost": 0.0, "turns": 0, "models": set(),
243
+ "harnesses": set(), "ts": "",
244
+ "in_tok": 0, "out_tok": 0,
245
+ "calls": 0, "repeats": 0, "refused": 0,
246
+ "read": 0})
247
+ for r in runs:
248
+ tk = str(r.get("ticket"))
249
+ p = per[tk]
250
+ p["cost"] += r.get("cost_usd") or 0.0
251
+ p["turns"] += int(r.get("num_turns") or 0)
252
+ # tokens were recorded per invocation but never rolled up, so the measure the Knowledge
253
+ # Layer exists to move was invisible on the dashboard that decides its fate
254
+ p["in_tok"] += int(r.get("input_tokens") or 0)
255
+ p["out_tok"] += int(r.get("output_tokens") or 0)
256
+ # WHAT THE PASSES DID, summed across the ticket's invocations. `turns_to_first_edit` is
257
+ # deliberately NOT here: it is a per-pass shape and adding two of them means nothing.
258
+ # `read` counts the passes whose trajectory could be read at all, so a ticket whose
259
+ # harness has no stream reader shows a dash rather than a confident zero.
260
+ if r.get("tool_calls") is not None:
261
+ p["read"] += 1
262
+ p["calls"] += int(r.get("tool_calls") or 0)
263
+ p["repeats"] += int(r.get("repeated_calls") or 0)
264
+ p["refused"] += int(r.get("refused_calls") or 0)
265
+ if r.get("model"):
266
+ p["models"].add(str(r["model"]))
267
+ if r.get("harness"):
268
+ p["harnesses"].add(str(r["harness"]))
269
+ p["ts"] = max(p["ts"], str(r.get("ts") or ""))
270
+ tasks = []
271
+ for tk, p in per.items():
272
+ job = jobs.get(tk, {})
273
+ tasks.append({
274
+ "ticket": tk,
275
+ "date": (p["ts"] or str(job.get("ts") or ""))[:10],
276
+ "model": ", ".join(sorted(p["models"])) or "—",
277
+ "harness": ", ".join(sorted(p["harnesses"])) or "—",
278
+ "turns": p["turns"],
279
+ "input_tokens": p["in_tok"],
280
+ "output_tokens": p["out_tok"],
281
+ "tokens": p["in_tok"] + p["out_tok"],
282
+ "wall_s": job.get("wall_s"),
283
+ "cost_usd": round(p["cost"], 4),
284
+ # None, not 0, when no pass on this ticket had a readable stream — the panel renders a
285
+ # dash, and an operator ranking tickets by waste is never handed a zero nobody measured.
286
+ "tool_calls": p["calls"] if p["read"] else None,
287
+ "repeated_calls": p["repeats"] if p["read"] else None,
288
+ "refused_calls": p["refused"] if p["read"] else None,
289
+ "state": job.get("state") or "",
290
+ "title": job.get("title") or "",
291
+ "knowledge": job.get("knowledge") or "", # the A/B arm (ADR-0017)
292
+ })
293
+ tasks.sort(key=lambda t: (t["date"], t["ticket"]), reverse=True)
294
+
295
+ total_cost = round(sum((r.get("cost_usd") or 0.0) for r in runs), 4)
296
+ walls = [j.get("wall_s") for j in jobs.values() if j.get("wall_s")]
297
+ n = len(per) or len(jobs)
298
+ # raw per-invocation rows so the panel can filter + re-aggregate client-side (instant, no
299
+ # round-trip per filter change). Small (dozens/day).
300
+ runs_out = [{
301
+ "ticket": str(r.get("ticket")), "ts": str(r.get("ts") or ""),
302
+ "date": str(r.get("ts") or "")[:10], "role": str(r.get("role") or ""),
303
+ "model": str(r.get("model") or ""), "harness": str(r.get("harness") or ""),
304
+ "cost_usd": round(r.get("cost_usd") or 0.0, 4), "turns": int(r.get("num_turns") or 0),
305
+ "input_tokens": int(r.get("input_tokens") or 0),
306
+ "output_tokens": int(r.get("output_tokens") or 0),
307
+ # Per invocation these stay None where they were not measured, for the same reason: the
308
+ # client-side re-aggregation must be able to skip them rather than average in zeros.
309
+ "tool_calls": _opt_int(r.get("tool_calls")),
310
+ "repeated_calls": _opt_int(r.get("repeated_calls")),
311
+ "refused_calls": _opt_int(r.get("refused_calls")),
312
+ "turns_to_first_edit": _opt_int(r.get("turns_to_first_edit")),
313
+ } for r in runs]
314
+ return {
315
+ "projects": projects,
316
+ "project": project,
317
+ "runs": runs_out,
318
+ "knowledge_ab": _knowledge_ab(tasks),
319
+ "by_day": {k: round(by_day[k], 4) for k in sorted(by_day)},
320
+ "by_model": _sum_by("model"),
321
+ "by_harness": _sum_by("harness"),
322
+ "by_role": _sum_by("role"),
323
+ "tasks": tasks,
324
+ "totals": {
325
+ "cost_usd": total_cost,
326
+ "tokens": sum(t["tokens"] for t in tasks),
327
+ "tasks": n,
328
+ "avg_cost_per_task": round(total_cost / n, 4) if n else 0.0,
329
+ "avg_wall_s": round(sum(walls) / len(walls), 1) if walls else 0.0,
330
+ "agent_runs": len(runs),
331
+ },
332
+ }
333
+
334
+
335
+ def cost_dashboard(project: str | None = None, table_name: str | None = None) -> dict:
336
+ """The full payload for GET /api/metrics — scan + per-project shape. Always returns a dict."""
337
+ return dashboard(scan_records(table_name), project=project)