devcouncil 0.2.0 → 0.3.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/README.md +12 -1
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/devcouncil/app/config.py +181 -7
- package/src/devcouncil/app/orchestrator.py +10 -6
- package/src/devcouncil/app/state_machine.py +4 -0
- package/src/devcouncil/artifacts/graph.py +9 -2
- package/src/devcouncil/cli/commands/check.py +12 -1
- package/src/devcouncil/cli/commands/design.py +186 -0
- package/src/devcouncil/cli/commands/doctor.py +160 -3
- package/src/devcouncil/cli/commands/go.py +96 -16
- package/src/devcouncil/cli/commands/hook.py +172 -0
- package/src/devcouncil/cli/commands/init.py +7 -2
- package/src/devcouncil/cli/commands/integrate.py +492 -34
- package/src/devcouncil/cli/commands/logs.py +106 -0
- package/src/devcouncil/cli/commands/okf.py +245 -0
- package/src/devcouncil/cli/commands/plan.py +54 -14
- package/src/devcouncil/cli/commands/repair.py +12 -3
- package/src/devcouncil/cli/commands/run.py +128 -7
- package/src/devcouncil/cli/commands/skills.py +180 -1
- package/src/devcouncil/cli/commands/status.py +7 -16
- package/src/devcouncil/cli/commands/verify.py +16 -10
- package/src/devcouncil/cli/commands/watch.py +24 -4
- package/src/devcouncil/cli/main.py +36 -1
- package/src/devcouncil/domain/evidence.py +7 -0
- package/src/devcouncil/execution/checkpoints.py +12 -2
- package/src/devcouncil/execution/fs_watcher.py +27 -2
- package/src/devcouncil/execution/handoff.py +1 -1
- package/src/devcouncil/execution/patch.py +6 -0
- package/src/devcouncil/execution/permissions.py +7 -0
- package/src/devcouncil/execution/policy_engine.py +12 -5
- package/src/devcouncil/execution/prompt_builder.py +126 -10
- package/src/devcouncil/execution/shell_session.py +6 -0
- package/src/devcouncil/execution/task_runner.py +18 -7
- package/src/devcouncil/executors/agent_registry.py +22 -1
- package/src/devcouncil/executors/coding_cli.py +133 -5
- package/src/devcouncil/executors/mini_swe.py +6 -0
- package/src/devcouncil/executors/native/agent.py +15 -0
- package/src/devcouncil/executors/openhands.py +6 -0
- package/src/devcouncil/gating/checks/secret_scan_check.py +7 -0
- package/src/devcouncil/gating/policy.py +38 -7
- package/src/devcouncil/indexing/ast_matcher.py +16 -6
- package/src/devcouncil/indexing/repo_mapper.py +30 -8
- package/src/devcouncil/indexing/semantic_index.py +42 -26
- package/src/devcouncil/integrations/actions.py +24 -4
- package/src/devcouncil/integrations/check.py +7 -4
- package/src/devcouncil/integrations/claude_assets.py +444 -0
- package/src/devcouncil/integrations/code_review_graph.py +13 -2
- package/src/devcouncil/integrations/github_intent.py +8 -1
- package/src/devcouncil/integrations/gitnexus.py +10 -2
- package/src/devcouncil/integrations/mcp/server.py +404 -15
- package/src/devcouncil/integrations/pr_comments.py +9 -0
- package/src/devcouncil/knowledge/__init__.py +23 -0
- package/src/devcouncil/knowledge/design.py +374 -0
- package/src/devcouncil/knowledge/design_conformance.py +317 -0
- package/src/devcouncil/knowledge/fetch.py +223 -0
- package/src/devcouncil/knowledge/frontmatter.py +51 -0
- package/src/devcouncil/knowledge/okf.py +202 -0
- package/src/devcouncil/knowledge/skill_bridge.py +96 -0
- package/src/devcouncil/knowledge/sources.py +239 -0
- package/src/devcouncil/live/cards.py +20 -6
- package/src/devcouncil/live/repair_prompt.py +29 -6
- package/src/devcouncil/live/reviewer.py +72 -13
- package/src/devcouncil/live/summary.py +18 -8
- package/src/devcouncil/live/transcripts.py +38 -5
- package/src/devcouncil/llm/cache.py +14 -6
- package/src/devcouncil/llm/provider.py +179 -92
- package/src/devcouncil/llm/router.py +122 -23
- package/src/devcouncil/optimization/skillopt.py +673 -0
- package/src/devcouncil/planning/arbiter_service.py +10 -2
- package/src/devcouncil/planning/correction_manifest.py +47 -4
- package/src/devcouncil/planning/critique_service.py +9 -2
- package/src/devcouncil/planning/plan_service.py +69 -3
- package/src/devcouncil/planning/prompt_enhancer_service.py +124 -0
- package/src/devcouncil/planning/repair_service.py +8 -2
- package/src/devcouncil/planning/spec_service.py +10 -2
- package/src/devcouncil/repo/ci_scaffold.py +13 -5
- package/src/devcouncil/repo/sca.py +11 -1
- package/src/devcouncil/reporting/json_report.py +11 -0
- package/src/devcouncil/reporting/markdown_report.py +14 -1
- package/src/devcouncil/reporting/okf_bundle_writer.py +364 -0
- package/src/devcouncil/reporting/okf_html.py +323 -0
- package/src/devcouncil/reporting/report_builder.py +18 -1
- package/src/devcouncil/skills/registry.py +111 -33
- package/src/devcouncil/storage/db.py +58 -2
- package/src/devcouncil/storage/models.py +4 -0
- package/src/devcouncil/storage/native.py +20 -18
- package/src/devcouncil/storage/repositories.py +35 -18
- package/src/devcouncil/telemetry/logging_setup.py +244 -0
- package/src/devcouncil/telemetry/stages.py +141 -0
- package/src/devcouncil/telemetry/tracker.py +12 -1
- package/src/devcouncil/ui/dashboard.py +69 -5
- package/src/devcouncil/verification/acceptance_compiler.py +147 -19
- package/src/devcouncil/verification/ad_hoc_check.py +6 -0
- package/src/devcouncil/verification/implementation_reviewer.py +11 -2
- package/src/devcouncil/verification/sandbox.py +7 -4
- package/src/devcouncil/verification/verifier.py +905 -517
- package/uv.lock +1 -1
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Central logging configuration for DevCouncil.
|
|
2
|
+
|
|
3
|
+
The codebase is sprinkled with ``logging.getLogger(__name__)`` calls across the
|
|
4
|
+
orchestrator, planner, executors, verifier, and LLM layers — but historically no
|
|
5
|
+
handler was ever installed, so every ``logger.info``/``logger.debug`` was silently
|
|
6
|
+
discarded and only uncaught WARNING+ records reached stderr via Python's "last
|
|
7
|
+
resort" handler. That made diagnosing the recurring run failures nearly
|
|
8
|
+
impossible: the breadcrumbs existed but went nowhere.
|
|
9
|
+
|
|
10
|
+
:func:`configure_logging` wires up two sinks, once per process:
|
|
11
|
+
|
|
12
|
+
* a **rotating file** at ``.devcouncil/logs/devcouncil.log`` that always captures
|
|
13
|
+
*everything* at DEBUG — this is the durable record you grep after a bad run;
|
|
14
|
+
* a **console** (stderr) handler whose level is dialed by ``-v``/``-q`` flags or
|
|
15
|
+
the ``DEVCOUNCIL_LOG_LEVEL`` env var, defaulting to WARNING so normal Rich CLI
|
|
16
|
+
output stays clean.
|
|
17
|
+
|
|
18
|
+
It is idempotent: calling it again only adjusts the console level (e.g. when a
|
|
19
|
+
command re-points at a different ``--project-root``), so importing modules can
|
|
20
|
+
call it freely without stacking duplicate handlers.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import logging
|
|
26
|
+
import os
|
|
27
|
+
from contextlib import contextmanager
|
|
28
|
+
from logging.handlers import RotatingFileHandler
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Iterator, Optional
|
|
31
|
+
|
|
32
|
+
# Sentinels so we can find (and reconfigure) our own handlers on repeat calls
|
|
33
|
+
# without disturbing handlers another library may have installed on the root.
|
|
34
|
+
_FILE_HANDLER_TAG = "devcouncil.file"
|
|
35
|
+
_CONSOLE_HANDLER_TAG = "devcouncil.console"
|
|
36
|
+
|
|
37
|
+
_LOG_FORMAT = "%(asctime)s %(levelname)-7s [%(name)s] %(message)s"
|
|
38
|
+
_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
|
|
39
|
+
|
|
40
|
+
# Default file location (relative to a project root) for the durable run log.
|
|
41
|
+
LOG_RELATIVE_PATH = Path(".devcouncil") / "logs" / "devcouncil.log"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _level_from_verbosity(verbosity: int, quiet: bool) -> int:
|
|
45
|
+
"""Map ``-v`` count / ``-q`` flag to a console log level.
|
|
46
|
+
|
|
47
|
+
quiet -> ERROR; default(0) -> WARNING; -v -> INFO; -vv (or more) -> DEBUG.
|
|
48
|
+
"""
|
|
49
|
+
if quiet:
|
|
50
|
+
return logging.ERROR
|
|
51
|
+
if verbosity <= 0:
|
|
52
|
+
return logging.WARNING
|
|
53
|
+
if verbosity == 1:
|
|
54
|
+
return logging.INFO
|
|
55
|
+
return logging.DEBUG
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _resolve_console_level(verbosity: int, quiet: bool, log_level: Optional[str]) -> int:
|
|
59
|
+
"""Console level precedence: explicit arg > env var > verbosity flags."""
|
|
60
|
+
explicit = log_level or os.environ.get("DEVCOUNCIL_LOG_LEVEL")
|
|
61
|
+
if explicit:
|
|
62
|
+
resolved = logging.getLevelName(explicit.strip().upper())
|
|
63
|
+
if isinstance(resolved, int):
|
|
64
|
+
return resolved
|
|
65
|
+
return _level_from_verbosity(verbosity, quiet)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _find_tagged_handler(logger: logging.Logger, tag: str) -> Optional[logging.Handler]:
|
|
69
|
+
for handler in logger.handlers:
|
|
70
|
+
if getattr(handler, "_devcouncil_tag", None) == tag:
|
|
71
|
+
return handler
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def configure_logging(
|
|
76
|
+
project_root: Optional[Path] = None,
|
|
77
|
+
*,
|
|
78
|
+
verbosity: int = 0,
|
|
79
|
+
quiet: bool = False,
|
|
80
|
+
log_level: Optional[str] = None,
|
|
81
|
+
) -> Optional[Path]:
|
|
82
|
+
"""Install DevCouncil's file + console log handlers on the root logger.
|
|
83
|
+
|
|
84
|
+
Safe to call repeatedly. The file handler (DEBUG, rotating) is created once;
|
|
85
|
+
subsequent calls only update the console handler's level so a later command
|
|
86
|
+
invocation can raise/lower verbosity. Returns the resolved log file path, or
|
|
87
|
+
``None`` if the file sink could not be created (console logging still works).
|
|
88
|
+
"""
|
|
89
|
+
root = logging.getLogger()
|
|
90
|
+
# The root must pass DEBUG records through to handlers; each handler then
|
|
91
|
+
# applies its own threshold (file=DEBUG, console=user-selected).
|
|
92
|
+
root.setLevel(logging.DEBUG)
|
|
93
|
+
|
|
94
|
+
formatter = logging.Formatter(_LOG_FORMAT, datefmt=_DATE_FORMAT)
|
|
95
|
+
console_level = _resolve_console_level(verbosity, quiet, log_level)
|
|
96
|
+
|
|
97
|
+
# --- Console handler (stderr) -------------------------------------------
|
|
98
|
+
console = _find_tagged_handler(root, _CONSOLE_HANDLER_TAG)
|
|
99
|
+
if console is None:
|
|
100
|
+
console = logging.StreamHandler() # defaults to stderr, keeps stdout clean
|
|
101
|
+
console._devcouncil_tag = _CONSOLE_HANDLER_TAG # type: ignore[attr-defined]
|
|
102
|
+
console.setFormatter(formatter)
|
|
103
|
+
root.addHandler(console)
|
|
104
|
+
console.setLevel(console_level)
|
|
105
|
+
|
|
106
|
+
# --- Rotating file handler (always DEBUG) -------------------------------
|
|
107
|
+
log_path: Optional[Path] = None
|
|
108
|
+
if _find_tagged_handler(root, _FILE_HANDLER_TAG) is None:
|
|
109
|
+
base = Path(project_root) if project_root is not None else Path.cwd()
|
|
110
|
+
log_path = base / LOG_RELATIVE_PATH
|
|
111
|
+
try:
|
|
112
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
113
|
+
file_handler = RotatingFileHandler(
|
|
114
|
+
log_path,
|
|
115
|
+
maxBytes=5 * 1024 * 1024, # 5 MB per file
|
|
116
|
+
backupCount=5, # keep ~25 MB of history
|
|
117
|
+
encoding="utf-8",
|
|
118
|
+
)
|
|
119
|
+
file_handler._devcouncil_tag = _FILE_HANDLER_TAG # type: ignore[attr-defined]
|
|
120
|
+
file_handler.setLevel(logging.DEBUG)
|
|
121
|
+
file_handler.setFormatter(formatter)
|
|
122
|
+
root.addHandler(file_handler)
|
|
123
|
+
except OSError:
|
|
124
|
+
# Read-only FS or unwritable path: degrade to console-only rather
|
|
125
|
+
# than crashing the command the user actually asked for.
|
|
126
|
+
log_path = None
|
|
127
|
+
else:
|
|
128
|
+
base = Path(project_root) if project_root is not None else Path.cwd()
|
|
129
|
+
log_path = base / LOG_RELATIVE_PATH
|
|
130
|
+
|
|
131
|
+
# Quieten chatty third-party loggers on the console; the file still gets them.
|
|
132
|
+
for noisy in ("httpx", "httpcore", "urllib3", "asyncio"):
|
|
133
|
+
logging.getLogger(noisy).setLevel(logging.WARNING)
|
|
134
|
+
|
|
135
|
+
_install_excepthook()
|
|
136
|
+
|
|
137
|
+
logging.getLogger(__name__).debug(
|
|
138
|
+
"Logging configured (console=%s, file=%s)",
|
|
139
|
+
logging.getLevelName(console_level),
|
|
140
|
+
log_path,
|
|
141
|
+
)
|
|
142
|
+
return log_path
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _install_excepthook() -> None:
|
|
146
|
+
"""Ensure an uncaught exception is written to the log (with traceback) before exit.
|
|
147
|
+
|
|
148
|
+
A crash otherwise only prints a traceback to the terminal — gone once the scrollback
|
|
149
|
+
is. Routing it through logging means the full stack also lands in the durable DEBUG
|
|
150
|
+
file (and any active per-run log), which is exactly what you need to diagnose the
|
|
151
|
+
recurring failures. ``KeyboardInterrupt`` is left to the default handler so Ctrl-C
|
|
152
|
+
stays clean. Installed once; idempotent across repeat ``configure_logging`` calls.
|
|
153
|
+
"""
|
|
154
|
+
import sys
|
|
155
|
+
|
|
156
|
+
if getattr(sys.excepthook, "_devcouncil_hook", False):
|
|
157
|
+
return
|
|
158
|
+
previous = sys.excepthook
|
|
159
|
+
|
|
160
|
+
def _hook(exc_type, exc_value, exc_tb):
|
|
161
|
+
if not issubclass(exc_type, KeyboardInterrupt):
|
|
162
|
+
logging.getLogger("devcouncil.crash").critical(
|
|
163
|
+
"Uncaught exception", exc_info=(exc_type, exc_value, exc_tb)
|
|
164
|
+
)
|
|
165
|
+
previous(exc_type, exc_value, exc_tb)
|
|
166
|
+
|
|
167
|
+
_hook._devcouncil_hook = True # type: ignore[attr-defined]
|
|
168
|
+
sys.excepthook = _hook
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def set_log_dir(project_root: Path) -> Optional[Path]:
|
|
172
|
+
"""Re-point the shared DEBUG file handler at ``project_root/.devcouncil/logs``.
|
|
173
|
+
|
|
174
|
+
The CLI callback configures logging before any command knows its ``--project-root``,
|
|
175
|
+
so the file handler initially lands under the current working directory. When a
|
|
176
|
+
command operates on a *different* root (``dev go --project-root /other/repo``), its
|
|
177
|
+
log should live with that project, not in cwd. Each command calls this once it has
|
|
178
|
+
resolved its root; if the handler already points there (the common ``.`` case) this
|
|
179
|
+
is a cheap no-op. Returns the (re)resolved log path, or ``None`` if it could not be
|
|
180
|
+
created (logging then stays on the previous handler / console).
|
|
181
|
+
"""
|
|
182
|
+
target = Path(project_root) / LOG_RELATIVE_PATH
|
|
183
|
+
root = logging.getLogger()
|
|
184
|
+
existing = _find_tagged_handler(root, _FILE_HANDLER_TAG)
|
|
185
|
+
if existing is not None:
|
|
186
|
+
current = getattr(existing, "baseFilename", None)
|
|
187
|
+
if current and Path(current) == target.resolve():
|
|
188
|
+
return target # already logging to this project's file — nothing to do
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
192
|
+
new_handler = RotatingFileHandler(
|
|
193
|
+
target, maxBytes=5 * 1024 * 1024, backupCount=5, encoding="utf-8"
|
|
194
|
+
)
|
|
195
|
+
new_handler._devcouncil_tag = _FILE_HANDLER_TAG # type: ignore[attr-defined]
|
|
196
|
+
new_handler.setLevel(logging.DEBUG)
|
|
197
|
+
new_handler.setFormatter(logging.Formatter(_LOG_FORMAT, datefmt=_DATE_FORMAT))
|
|
198
|
+
except OSError:
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
if existing is not None:
|
|
202
|
+
root.removeHandler(existing)
|
|
203
|
+
existing.close()
|
|
204
|
+
root.addHandler(new_handler)
|
|
205
|
+
logging.getLogger(__name__).debug("Log file re-pointed to %s", target)
|
|
206
|
+
return target
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@contextmanager
|
|
210
|
+
def run_log(log_file: Path) -> Iterator[Optional[Path]]:
|
|
211
|
+
"""Capture everything logged during a single invocation into its own DEBUG file.
|
|
212
|
+
|
|
213
|
+
The shared rotating ``devcouncil.log`` interleaves every command and rotates by
|
|
214
|
+
size, so isolating one run's complete trail there means grepping across rotations
|
|
215
|
+
and unrelated activity. This attaches a second, run-scoped DEBUG file handler for
|
|
216
|
+
the duration of the ``with`` block (e.g. ``.devcouncil/runs/<id>/run.log``) and
|
|
217
|
+
detaches it on exit — giving a clean, self-contained log for exactly that run on
|
|
218
|
+
top of the always-on shared log.
|
|
219
|
+
|
|
220
|
+
Best-effort: if the file can't be opened (read-only FS, bad path) the block still
|
|
221
|
+
runs with only the shared log. Yields the resolved path, or ``None`` on failure.
|
|
222
|
+
"""
|
|
223
|
+
root = logging.getLogger()
|
|
224
|
+
handler: Optional[logging.Handler] = None
|
|
225
|
+
try:
|
|
226
|
+
log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
227
|
+
handler = logging.FileHandler(log_file, encoding="utf-8")
|
|
228
|
+
handler._devcouncil_tag = "devcouncil.run" # type: ignore[attr-defined]
|
|
229
|
+
handler.setLevel(logging.DEBUG)
|
|
230
|
+
handler.setFormatter(logging.Formatter(_LOG_FORMAT, datefmt=_DATE_FORMAT))
|
|
231
|
+
root.addHandler(handler)
|
|
232
|
+
except OSError:
|
|
233
|
+
if handler is not None:
|
|
234
|
+
root.removeHandler(handler)
|
|
235
|
+
handler = None
|
|
236
|
+
log_file = None # type: ignore[assignment]
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
yield log_file
|
|
240
|
+
finally:
|
|
241
|
+
if handler is not None:
|
|
242
|
+
handler.flush()
|
|
243
|
+
handler.close()
|
|
244
|
+
root.removeHandler(handler)
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Stage and step instrumentation for DevCouncil pipelines.
|
|
2
|
+
|
|
3
|
+
A DevCouncil run moves through coarse *stages* (plan, execute, verify, repair,
|
|
4
|
+
reconcile, report) each made of finer *steps*. When a run misbehaves, the first
|
|
5
|
+
question is always "how far did it get, and what was the last thing it tried?".
|
|
6
|
+
This module gives one consistent way to answer that:
|
|
7
|
+
|
|
8
|
+
* :func:`log_stage` — a context manager that logs ``▶ <stage>`` on entry and
|
|
9
|
+
``✔ <stage> (1.23s)`` / ``✖ <stage> failed (1.23s): ...`` on exit, with wall
|
|
10
|
+
time. It also mirrors the boundary to the structured JSONL trace
|
|
11
|
+
(:class:`~devcouncil.telemetry.traces.TraceLogger`) when a ``project_root`` is
|
|
12
|
+
given, so the human log and the machine trace stay in lock-step.
|
|
13
|
+
* :func:`log_step` — a one-liner for a single step inside a stage.
|
|
14
|
+
|
|
15
|
+
Both are best-effort: instrumentation must never be the reason a run fails, so
|
|
16
|
+
trace-write errors are swallowed (the Python log line still goes out).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
import time
|
|
23
|
+
from contextlib import contextmanager
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any, Iterator, Optional
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger("devcouncil.stage")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _safe_trace(
|
|
31
|
+
project_root: Optional[Path],
|
|
32
|
+
event_type: str,
|
|
33
|
+
details: dict[str, Any],
|
|
34
|
+
*,
|
|
35
|
+
run_id: Optional[str],
|
|
36
|
+
task_id: Optional[str],
|
|
37
|
+
summary: str,
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Mirror a stage/step boundary to the JSONL trace, swallowing any error."""
|
|
40
|
+
if project_root is None:
|
|
41
|
+
return
|
|
42
|
+
try:
|
|
43
|
+
from devcouncil.telemetry.traces import TraceLogger
|
|
44
|
+
|
|
45
|
+
TraceLogger(Path(project_root)).log_event(
|
|
46
|
+
event_type, details, run_id=run_id, task_id=task_id, summary=summary
|
|
47
|
+
)
|
|
48
|
+
except Exception: # pragma: no cover - tracing is strictly best-effort
|
|
49
|
+
logger.debug("Failed to mirror %s to trace", event_type, exc_info=True)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _context_suffix(context: dict[str, Any]) -> str:
|
|
53
|
+
if not context:
|
|
54
|
+
return ""
|
|
55
|
+
return " " + " ".join(f"{k}={v}" for k, v in context.items())
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@contextmanager
|
|
59
|
+
def log_stage(
|
|
60
|
+
name: str,
|
|
61
|
+
*,
|
|
62
|
+
project_root: Optional[Path] = None,
|
|
63
|
+
run_id: Optional[str] = None,
|
|
64
|
+
task_id: Optional[str] = None,
|
|
65
|
+
**context: Any,
|
|
66
|
+
) -> Iterator[None]:
|
|
67
|
+
"""Log entry/exit (with timing) around a coarse pipeline stage.
|
|
68
|
+
|
|
69
|
+
Usage::
|
|
70
|
+
|
|
71
|
+
with log_stage("plan", project_root=root, run_id=run_id, goal=goal):
|
|
72
|
+
...
|
|
73
|
+
|
|
74
|
+
Logs at INFO on success, ERROR on exception, and always reports elapsed time.
|
|
75
|
+
Re-raises whatever the body raised so control flow is unchanged.
|
|
76
|
+
"""
|
|
77
|
+
suffix = _context_suffix(context)
|
|
78
|
+
logger.info("▶ %s%s", name, suffix)
|
|
79
|
+
_safe_trace(
|
|
80
|
+
project_root,
|
|
81
|
+
"stage_started",
|
|
82
|
+
{"stage": name, **context},
|
|
83
|
+
run_id=run_id,
|
|
84
|
+
task_id=task_id,
|
|
85
|
+
summary=f"Stage started: {name}",
|
|
86
|
+
)
|
|
87
|
+
start = time.monotonic()
|
|
88
|
+
try:
|
|
89
|
+
yield
|
|
90
|
+
except BaseException as exc:
|
|
91
|
+
elapsed = time.monotonic() - start
|
|
92
|
+
logger.error("✖ %s failed (%.2fs): %s", name, elapsed, exc)
|
|
93
|
+
_safe_trace(
|
|
94
|
+
project_root,
|
|
95
|
+
"stage_failed",
|
|
96
|
+
{"stage": name, "error": str(exc), "elapsed_s": round(elapsed, 3), **context},
|
|
97
|
+
run_id=run_id,
|
|
98
|
+
task_id=task_id,
|
|
99
|
+
summary=f"Stage failed: {name}",
|
|
100
|
+
)
|
|
101
|
+
raise
|
|
102
|
+
else:
|
|
103
|
+
elapsed = time.monotonic() - start
|
|
104
|
+
logger.info("✔ %s (%.2fs)", name, elapsed)
|
|
105
|
+
_safe_trace(
|
|
106
|
+
project_root,
|
|
107
|
+
"stage_completed",
|
|
108
|
+
{"stage": name, "elapsed_s": round(elapsed, 3), **context},
|
|
109
|
+
run_id=run_id,
|
|
110
|
+
task_id=task_id,
|
|
111
|
+
summary=f"Stage completed: {name}",
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def log_step(
|
|
116
|
+
message: str,
|
|
117
|
+
*,
|
|
118
|
+
project_root: Optional[Path] = None,
|
|
119
|
+
run_id: Optional[str] = None,
|
|
120
|
+
task_id: Optional[str] = None,
|
|
121
|
+
level: int = logging.INFO,
|
|
122
|
+
trace: bool = False,
|
|
123
|
+
**context: Any,
|
|
124
|
+
) -> None:
|
|
125
|
+
"""Log a single step within a stage.
|
|
126
|
+
|
|
127
|
+
By default this only writes the Python log (cheap, captured in full by the
|
|
128
|
+
file handler). Set ``trace=True`` for milestone steps worth recording in the
|
|
129
|
+
structured JSONL trace as well.
|
|
130
|
+
"""
|
|
131
|
+
suffix = _context_suffix(context)
|
|
132
|
+
logger.log(level, "• %s%s", message, suffix)
|
|
133
|
+
if trace:
|
|
134
|
+
_safe_trace(
|
|
135
|
+
project_root,
|
|
136
|
+
"step",
|
|
137
|
+
{"message": message, **context},
|
|
138
|
+
run_id=run_id,
|
|
139
|
+
task_id=task_id,
|
|
140
|
+
summary=message,
|
|
141
|
+
)
|
|
@@ -7,7 +7,11 @@ from devcouncil.telemetry.pricing import pricing_for_model
|
|
|
7
7
|
class TelemetryTracker:
|
|
8
8
|
def __init__(self, project_root: Path):
|
|
9
9
|
self.log_file = project_root / ".devcouncil" / "logs" / "telemetry.json"
|
|
10
|
-
|
|
10
|
+
# log_usage() reloads the ledger immediately before saving (for concurrent-write
|
|
11
|
+
# safety), so any value read here would always be overwritten before use. Start
|
|
12
|
+
# from the same default shape _load() returns for a missing file instead of
|
|
13
|
+
# doing a dead disk read at construction.
|
|
14
|
+
self.stats: Dict[str, Any] = {"total_cost": 0.0, "total_prompt_tokens": 0, "total_completion_tokens": 0, "models": {}}
|
|
11
15
|
|
|
12
16
|
def _load(self) -> Dict[str, Any]:
|
|
13
17
|
if self.log_file.exists():
|
|
@@ -24,6 +28,13 @@ class TelemetryTracker:
|
|
|
24
28
|
json.dump(self.stats, f, indent=2)
|
|
25
29
|
|
|
26
30
|
def log_usage(self, model: str, usage: Dict[str, int], *, local: bool = False):
|
|
31
|
+
# Re-read the ledger immediately before mutating so the whole load->mutate->save
|
|
32
|
+
# runs as one synchronous (await-free) step. The router constructs a fresh tracker
|
|
33
|
+
# per call and only calls log_usage *after* the LLM await, so snapshotting the
|
|
34
|
+
# baseline at construction would let concurrent calls (e.g. plan.py's gather, or a
|
|
35
|
+
# parallelized SkillOpt _evaluate) each load the same baseline and clobber each
|
|
36
|
+
# other's entries on save. Reloading here makes the last writer additive, not lossy.
|
|
37
|
+
self.stats = self._load()
|
|
27
38
|
prompt_tokens = usage.get("prompt_tokens", 0)
|
|
28
39
|
completion_tokens = usage.get("completion_tokens", 0)
|
|
29
40
|
|
|
@@ -6,14 +6,18 @@ import time
|
|
|
6
6
|
from importlib import resources
|
|
7
7
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
8
8
|
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
9
10
|
from urllib.parse import urlparse
|
|
10
11
|
|
|
11
12
|
from devcouncil.app.project_status import compute_phase
|
|
12
13
|
from devcouncil.integrations.actions import apply_integration_target
|
|
13
14
|
from devcouncil.integrations.check import build_integration_check_report, integration_status_summary
|
|
14
15
|
from devcouncil.storage.db import get_db
|
|
15
|
-
from devcouncil.storage.repositories import ArtifactGraphRepository, StateRepository
|
|
16
|
-
from devcouncil.telemetry.traces import
|
|
16
|
+
from devcouncil.storage.repositories import ArtifactGraphRepository, StateRepository
|
|
17
|
+
from devcouncil.telemetry.traces import TraceEvent, read_trace_events_since
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from devcouncil.artifacts.graph import ArtifactGraph
|
|
17
21
|
|
|
18
22
|
LOGO_ASSET = "devcouncil_logo_premium.png"
|
|
19
23
|
LEGACY_LOGO_ASSET = "devcouncil-logo.svg"
|
|
@@ -41,9 +45,25 @@ def _load_run_manifest(manifest_path: Path) -> dict | None:
|
|
|
41
45
|
return dict(manifest)
|
|
42
46
|
|
|
43
47
|
|
|
48
|
+
# The glob+stat over every run manifest is wasteful on each 2-second poll; a
|
|
49
|
+
# short TTL cache of the assembled result keeps the loop cheap.
|
|
50
|
+
_RECENT_RUNS_TTL_SECONDS = 2.0
|
|
51
|
+
_RECENT_RUNS_CACHE: dict[str, tuple[float, int, list[dict]]] = {}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _invalidate_recent_runs(project_root: Path) -> None:
|
|
55
|
+
_RECENT_RUNS_CACHE.pop(str(project_root), None)
|
|
56
|
+
|
|
57
|
+
|
|
44
58
|
def recent_run_artifacts(project_root: Path, *, limit: int = 10) -> list[dict]:
|
|
59
|
+
key = str(project_root)
|
|
60
|
+
now = time.monotonic()
|
|
61
|
+
cached = _RECENT_RUNS_CACHE.get(key)
|
|
62
|
+
if cached is not None and cached[1] == limit and now - cached[0] < _RECENT_RUNS_TTL_SECONDS:
|
|
63
|
+
return cached[2]
|
|
45
64
|
runs_dir = project_root / ".devcouncil" / "runs"
|
|
46
65
|
if not runs_dir.exists():
|
|
66
|
+
_RECENT_RUNS_CACHE[key] = (now, limit, [])
|
|
47
67
|
return []
|
|
48
68
|
manifests: list[dict] = []
|
|
49
69
|
for manifest_path in sorted(
|
|
@@ -58,6 +78,7 @@ def recent_run_artifacts(project_root: Path, *, limit: int = 10) -> list[dict]:
|
|
|
58
78
|
manifests.append(manifest)
|
|
59
79
|
if len(manifests) >= limit:
|
|
60
80
|
break
|
|
81
|
+
_RECENT_RUNS_CACHE[key] = (now, limit, manifests)
|
|
61
82
|
return manifests
|
|
62
83
|
|
|
63
84
|
|
|
@@ -82,6 +103,49 @@ def _invalidate_integration_summary(project_root: Path) -> None:
|
|
|
82
103
|
_INTEGRATION_SUMMARY_CACHE.pop(str(project_root), None)
|
|
83
104
|
|
|
84
105
|
|
|
106
|
+
# load_graph() runs six full-table scans, but the dashboard only needs the
|
|
107
|
+
# coverage summary and task list. Cache the materialized graph for a short TTL so
|
|
108
|
+
# the 2-second poll loop reuses it instead of rescanning every table each time.
|
|
109
|
+
_ARTIFACT_GRAPH_TTL_SECONDS = 2.0
|
|
110
|
+
_ARTIFACT_GRAPH_CACHE: dict[str, tuple[float, "ArtifactGraph"]] = {}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _artifact_graph_cached(project_root: Path, session) -> "ArtifactGraph":
|
|
114
|
+
key = str(project_root)
|
|
115
|
+
now = time.monotonic()
|
|
116
|
+
cached = _ARTIFACT_GRAPH_CACHE.get(key)
|
|
117
|
+
if cached is not None and now - cached[0] < _ARTIFACT_GRAPH_TTL_SECONDS:
|
|
118
|
+
return cached[1]
|
|
119
|
+
graph = ArtifactGraphRepository(session).load_graph()
|
|
120
|
+
_ARTIFACT_GRAPH_CACHE[key] = (now, graph)
|
|
121
|
+
return graph
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _invalidate_artifact_graph(project_root: Path) -> None:
|
|
125
|
+
_ARTIFACT_GRAPH_CACHE.pop(str(project_root), None)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# Re-reading and re-parsing the whole trace file on each poll is O(all events).
|
|
129
|
+
# Keep a per-root byte cursor plus the last 50 parsed events; each refresh reads
|
|
130
|
+
# only the bytes appended since the cursor. Semantics match the previous
|
|
131
|
+
# ``list(read_trace_events(...))[-50:]`` (last 50 events, in order).
|
|
132
|
+
_TRACE_EVENTS_LIMIT = 50
|
|
133
|
+
_TRACE_EVENTS_CACHE: dict[str, tuple[int, list[TraceEvent]]] = {}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _recent_trace_events_cached(project_root: Path) -> list[TraceEvent]:
|
|
137
|
+
key = str(project_root)
|
|
138
|
+
cursor, buffer = _TRACE_EVENTS_CACHE.get(key, (0, []))
|
|
139
|
+
new_events, next_cursor = read_trace_events_since(project_root, cursor)
|
|
140
|
+
if next_cursor < cursor:
|
|
141
|
+
# File was truncated/rotated: discard the stale buffer and start over.
|
|
142
|
+
buffer = new_events[-_TRACE_EVENTS_LIMIT:]
|
|
143
|
+
elif new_events:
|
|
144
|
+
buffer = (buffer + new_events)[-_TRACE_EVENTS_LIMIT:]
|
|
145
|
+
_TRACE_EVENTS_CACHE[key] = (next_cursor, buffer)
|
|
146
|
+
return buffer
|
|
147
|
+
|
|
148
|
+
|
|
85
149
|
def logo_svg() -> str:
|
|
86
150
|
return resources.files("devcouncil.assets").joinpath(LEGACY_LOGO_ASSET).read_text(encoding="utf-8")
|
|
87
151
|
|
|
@@ -103,16 +167,16 @@ def dashboard_payload(project_root: Path) -> dict:
|
|
|
103
167
|
"recent_runs": recent_run_artifacts(project_root),
|
|
104
168
|
}
|
|
105
169
|
with db.get_session() as session:
|
|
106
|
-
graph =
|
|
170
|
+
graph = _artifact_graph_cached(project_root, session)
|
|
107
171
|
state = StateRepository(session).get_state()
|
|
108
172
|
phase = compute_phase(graph, state.current_phase if state else None)
|
|
109
|
-
tasks = [task.model_dump() for task in
|
|
173
|
+
tasks = [task.model_dump() for task in graph.tasks.values()]
|
|
110
174
|
return {
|
|
111
175
|
"initialized": True,
|
|
112
176
|
"phase": phase,
|
|
113
177
|
"coverage": graph.coverage_summary(),
|
|
114
178
|
"tasks": tasks,
|
|
115
|
-
"events": [event.model_dump(by_alias=True) for event in
|
|
179
|
+
"events": [event.model_dump(by_alias=True) for event in _recent_trace_events_cached(project_root)],
|
|
116
180
|
"integrations": _integration_summary_cached(project_root),
|
|
117
181
|
"recent_runs": recent_run_artifacts(project_root),
|
|
118
182
|
}
|