codetrail-cli 1.4.1__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.
- codetrail/__init__.py +3 -0
- codetrail/__main__.py +5 -0
- codetrail/agents/__init__.py +4 -0
- codetrail/agents/convert.py +92 -0
- codetrail/agents/export.py +284 -0
- codetrail/agents/instructions.py +42 -0
- codetrail/agents/protocol.py +111 -0
- codetrail/agents/tools.py +185 -0
- codetrail/assistant/__init__.py +161 -0
- codetrail/assistant/claude_code.py +330 -0
- codetrail/assistant/codex.py +260 -0
- codetrail/assistant/environment.py +66 -0
- codetrail/assistant/estimate.py +225 -0
- codetrail/assistant/fake.py +74 -0
- codetrail/assistant/guard.py +80 -0
- codetrail/assistant/guard_hook.py +58 -0
- codetrail/assistant/local.py +245 -0
- codetrail/assistant/local_tools.py +177 -0
- codetrail/assistant/prompts.py +404 -0
- codetrail/assistant/routing.py +86 -0
- codetrail/assistant/runner.py +120 -0
- codetrail/assistant/status.py +186 -0
- codetrail/assistant/usage.py +93 -0
- codetrail/bridge.py +292 -0
- codetrail/cli.py +407 -0
- codetrail/config.py +485 -0
- codetrail/database/__init__.py +60 -0
- codetrail/database/migrations/0001_facts.sql +34 -0
- codetrail/database/migrations/0002_settings.sql +5 -0
- codetrail/database/migrations/0003_learning.sql +34 -0
- codetrail/database/migrations/0004_assistant_usage.sql +24 -0
- codetrail/database/migrations/0005_page_failures.sql +7 -0
- codetrail/database/migrations/0006_metric_values.sql +9 -0
- codetrail/errors.py +5 -0
- codetrail/extract/__init__.py +249 -0
- codetrail/extract/adr.py +98 -0
- codetrail/extract/github_actions.py +140 -0
- codetrail/extract/openapi.py +101 -0
- codetrail/extract/python.py +221 -0
- codetrail/extract/swift.py +204 -0
- codetrail/extract/terraform.py +166 -0
- codetrail/extract/typescript.py +464 -0
- codetrail/facts/__init__.py +112 -0
- codetrail/facts/store.py +209 -0
- codetrail/generate/__init__.py +1 -0
- codetrail/generate/failures.py +31 -0
- codetrail/generate/outline.py +159 -0
- codetrail/generate/revise.py +72 -0
- codetrail/generate/run.py +530 -0
- codetrail/generate/scope.py +135 -0
- codetrail/generate/validate.py +218 -0
- codetrail/guide/__init__.py +147 -0
- codetrail/learn/__init__.py +143 -0
- codetrail/learn/routes.py +139 -0
- codetrail/locales/ar/LC_MESSAGES/codetrail.mo +0 -0
- codetrail/locales/ar/LC_MESSAGES/codetrail.po +2236 -0
- codetrail/locales/codetrail.pot +1942 -0
- codetrail/lock.py +114 -0
- codetrail/metrics/__init__.py +6 -0
- codetrail/metrics/activity.py +96 -0
- codetrail/metrics/churn.py +156 -0
- codetrail/metrics/commits.py +45 -0
- codetrail/metrics/coverage.py +114 -0
- codetrail/metrics/decisions.py +77 -0
- codetrail/metrics/history.py +47 -0
- codetrail/metrics/inventory.py +63 -0
- codetrail/metrics/rationale.py +74 -0
- codetrail/metrics/releases.py +26 -0
- codetrail/metrics/report.py +111 -0
- codetrail/metrics/repository.py +128 -0
- codetrail/metrics/size.py +95 -0
- codetrail/py.typed +0 -0
- codetrail/remove.py +50 -0
- codetrail/repo/__init__.py +4 -0
- codetrail/repo/git.py +56 -0
- codetrail/repo/gitleaks.toml +6 -0
- codetrail/repo/history.py +262 -0
- codetrail/repo/mirror.py +137 -0
- codetrail/repo/refresh.py +41 -0
- codetrail/repo/rules.py +83 -0
- codetrail/repo/secrets.py +145 -0
- codetrail/repo/signal.py +65 -0
- codetrail/repo/source.py +166 -0
- codetrail/search.py +178 -0
- codetrail/server.py +45 -0
- codetrail/system.py +484 -0
- codetrail/update.py +276 -0
- codetrail/web/__init__.py +1 -0
- codetrail/web/app.py +897 -0
- codetrail/web/diagrams.py +414 -0
- codetrail/web/documentation.py +146 -0
- codetrail/web/i18n.py +44 -0
- codetrail/web/navigation.py +127 -0
- codetrail/web/render.py +126 -0
- codetrail/web/repository_view.py +230 -0
- codetrail/web/security.py +171 -0
- codetrail/web/static/brand/favicon.svg +1 -0
- codetrail/web/static/brand/mark.svg +1 -0
- codetrail/web/static/css/base.css +192 -0
- codetrail/web/static/css/components.css +2254 -0
- codetrail/web/static/css/content.css +310 -0
- codetrail/web/static/css/layout.css +410 -0
- codetrail/web/static/css/print.css +42 -0
- codetrail/web/static/css/tokens.css +228 -0
- codetrail/web/static/js/api.js +49 -0
- codetrail/web/static/js/ask.js +182 -0
- codetrail/web/static/js/d2-worker.js +34 -0
- codetrail/web/static/js/d2.js +28 -0
- codetrail/web/static/js/diagram.js +52 -0
- codetrail/web/static/js/documentation.js +24 -0
- codetrail/web/static/js/learning.js +47 -0
- codetrail/web/static/js/main.js +38 -0
- codetrail/web/static/js/outline.js +44 -0
- codetrail/web/static/js/palette.js +211 -0
- codetrail/web/static/js/repository.js +528 -0
- codetrail/web/static/js/resize.js +126 -0
- codetrail/web/static/js/shortcuts.js +87 -0
- codetrail/web/static/js/sidebar.js +194 -0
- codetrail/web/static/js/theme-init.js +27 -0
- codetrail/web/static/js/theme.js +42 -0
- codetrail/web/static/js/update.js +107 -0
- codetrail/web/static/vendor/d2/LICENSE +375 -0
- codetrail/web/static/vendor/d2/LICENSE-elk +263 -0
- codetrail/web/static/vendor/d2/LICENSE-wasm_exec +27 -0
- codetrail/web/static/vendor/d2/VERSION +12 -0
- codetrail/web/static/vendor/d2/d2.wasm +0 -0
- codetrail/web/static/vendor/d2/elk.js +105914 -0
- codetrail/web/static/vendor/d2/wasm_exec.js +656 -0
- codetrail/web/static/vendor/inter/InterVariable-Italic.woff2 +0 -0
- codetrail/web/static/vendor/inter/InterVariable.woff2 +0 -0
- codetrail/web/static/vendor/inter/LICENSE.txt +92 -0
- codetrail/web/static/vendor/inter/VERSION +6 -0
- codetrail/web/static/vendor/noto-sans-arabic/NotoSansArabic-wght.woff2 +0 -0
- codetrail/web/static/vendor/noto-sans-arabic/OFL.txt +93 -0
- codetrail/web/static/vendor/noto-sans-arabic/VERSION +12 -0
- codetrail/web/target_view.py +63 -0
- codetrail/web/templates/answers.html +25 -0
- codetrail/web/templates/area.html +46 -0
- codetrail/web/templates/base.html +37 -0
- codetrail/web/templates/decisions.html +30 -0
- codetrail/web/templates/diagram.html +10 -0
- codetrail/web/templates/digests.html +24 -0
- codetrail/web/templates/documentation.html +247 -0
- codetrail/web/templates/error.html +9 -0
- codetrail/web/templates/estimate.html +48 -0
- codetrail/web/templates/fact.html +29 -0
- codetrail/web/templates/home.html +196 -0
- codetrail/web/templates/layout/ask_panel.html +27 -0
- codetrail/web/templates/layout/header.html +32 -0
- codetrail/web/templates/layout/icons.html +27 -0
- codetrail/web/templates/layout/outline.html +4 -0
- codetrail/web/templates/layout/palette.html +27 -0
- codetrail/web/templates/layout/shortcuts.html +27 -0
- codetrail/web/templates/layout/sidebar.html +79 -0
- codetrail/web/templates/layout/update_dialog.html +18 -0
- codetrail/web/templates/macros.html +13 -0
- codetrail/web/templates/page.html +93 -0
- codetrail/web/templates/progress.html +46 -0
- codetrail/web/templates/repository.html +369 -0
- codetrail/web/templates/search.html +31 -0
- codetrail/web/templates/source.html +15 -0
- codetrail/web/templates/system.html +43 -0
- codetrail/web/templates/system_arrows.html +17 -0
- codetrail/web/templates/system_legend.html +9 -0
- codetrail/web/templates/update_log.html +30 -0
- codetrail/web/trend.py +22 -0
- codetrail_cli-1.4.1.dist-info/METADATA +372 -0
- codetrail_cli-1.4.1.dist-info/RECORD +171 -0
- codetrail_cli-1.4.1.dist-info/WHEEL +4 -0
- codetrail_cli-1.4.1.dist-info/entry_points.txt +3 -0
- codetrail_cli-1.4.1.dist-info/licenses/LICENSE +202 -0
codetrail/config.py
ADDED
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
"""Codetrail's configuration and folders (design sections 2.3 and 9).
|
|
2
|
+
|
|
3
|
+
The global settings live in `<config>/config.toml` and one file per target in `<config>/targets/<name>.toml`. Both are
|
|
4
|
+
validated at load time; unknown keys are refused by name, and a key that is absent takes its default.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import tomllib
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Literal
|
|
17
|
+
from urllib.parse import urlsplit
|
|
18
|
+
|
|
19
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator
|
|
20
|
+
|
|
21
|
+
from codetrail.errors import CodetrailError
|
|
22
|
+
|
|
23
|
+
TARGET_NAME = re.compile(r"[a-z0-9][a-z0-9-]{0,62}")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Paths:
|
|
28
|
+
"""Where Codetrail keeps its configuration, data and state, following the XDG base directories."""
|
|
29
|
+
|
|
30
|
+
config_dir: Path
|
|
31
|
+
data_dir: Path
|
|
32
|
+
state_dir: Path
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def from_environment(cls, environ: Mapping[str, str] = os.environ) -> Paths:
|
|
36
|
+
home = Path(environ.get("HOME") or Path.home())
|
|
37
|
+
|
|
38
|
+
def base(variable: str, default: Path) -> Path:
|
|
39
|
+
value = environ.get(variable)
|
|
40
|
+
# The XDG specification says relative values are invalid and must be ignored.
|
|
41
|
+
return Path(value) if value and Path(value).is_absolute() else default
|
|
42
|
+
|
|
43
|
+
return cls(
|
|
44
|
+
config_dir=base("XDG_CONFIG_HOME", home / ".config") / "codetrail",
|
|
45
|
+
data_dir=base("XDG_DATA_HOME", home / ".local" / "share") / "codetrail",
|
|
46
|
+
state_dir=base("XDG_STATE_HOME", home / ".local" / "state") / "codetrail",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def target_file(self, name: str) -> Path:
|
|
50
|
+
return self.config_dir / "targets" / f"{name}.toml"
|
|
51
|
+
|
|
52
|
+
def ignore_file(self, name: str) -> Path:
|
|
53
|
+
return self.config_dir / "targets" / f"{name}.ignore"
|
|
54
|
+
|
|
55
|
+
def target_data(self, name: str) -> Path:
|
|
56
|
+
return self.data_dir / name
|
|
57
|
+
|
|
58
|
+
def target_state(self, name: str) -> Path:
|
|
59
|
+
return self.state_dir / name
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Settings(BaseModel):
|
|
63
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ToolsSettings(Settings):
|
|
67
|
+
gitleaks: str = "gitleaks"
|
|
68
|
+
gitleaks_timeout_seconds: float = Field(default=600, gt=0, le=86_400) # each gitleaks and `mise which` run
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class BridgeSettings(Settings):
|
|
72
|
+
max_question_chars: int = Field(default=4000, gt=0, le=20_000)
|
|
73
|
+
max_turns: int = Field(default=20, gt=0)
|
|
74
|
+
max_budget_usd: float = Field(default=1.0, gt=0)
|
|
75
|
+
max_session_answers: int = Field(default=20, gt=0, le=200) # unsaved answers kept per session (16.4)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class SearchSettings(Settings):
|
|
79
|
+
max_query_chars: int = Field(default=200, gt=0, le=1000)
|
|
80
|
+
max_results: int = Field(default=20, gt=0, le=100)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class LearnSettings(Settings):
|
|
84
|
+
grading_cooldown_seconds: int = Field(default=10, ge=0)
|
|
85
|
+
max_budget_usd: float = Field(default=0.25, gt=0)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class AssistantSettings(Settings):
|
|
89
|
+
retry_attempts: int = Field(default=2, ge=0, le=5)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class ClaudeCodeSettings(Settings):
|
|
93
|
+
command: str = "claude"
|
|
94
|
+
auth: Literal["subscription", "api_key"] = "subscription"
|
|
95
|
+
timeout_seconds: int = Field(default=900, gt=0)
|
|
96
|
+
hook_timeout_seconds: int = Field(default=30, gt=0)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
LOOPBACK_HOSTS = {"127.0.0.1", "::1", "localhost"}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class LocalSettings(Settings):
|
|
103
|
+
base_url: str = "http://127.0.0.1:11434/v1" # Ollama; LM Studio is http://127.0.0.1:1234/v1
|
|
104
|
+
max_turns: int = Field(default=30, gt=0)
|
|
105
|
+
max_tokens_per_call: int = Field(default=200_000, gt=0)
|
|
106
|
+
timeout_seconds: int = Field(default=900, gt=0)
|
|
107
|
+
max_read_bytes: int = Field(default=200_000, gt=0)
|
|
108
|
+
|
|
109
|
+
@field_validator("base_url")
|
|
110
|
+
@classmethod
|
|
111
|
+
def loopback_only(cls, base_url: str) -> str:
|
|
112
|
+
"""Prompts carry the repository's text, so they go only to this machine (design section 15.5)."""
|
|
113
|
+
parts = urlsplit(base_url)
|
|
114
|
+
if (
|
|
115
|
+
parts.scheme not in ("http", "https")
|
|
116
|
+
or parts.username
|
|
117
|
+
or parts.password
|
|
118
|
+
or (parts.hostname not in LOOPBACK_HOSTS)
|
|
119
|
+
):
|
|
120
|
+
raise ValueError(f"{base_url!r} isn't a loopback address (127.0.0.1, ::1 or localhost, with no user).")
|
|
121
|
+
return base_url
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class CodexSettings(Settings):
|
|
125
|
+
command: str = "codex"
|
|
126
|
+
auth: Literal["subscription", "api_key"] = "subscription"
|
|
127
|
+
max_tokens_per_call: int = Field(default=400_000, gt=0)
|
|
128
|
+
timeout_seconds: int = Field(default=900, gt=0)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class ExtractSettings(Settings):
|
|
132
|
+
max_file_bytes: int = Field(default=1_000_000, gt=0)
|
|
133
|
+
max_attribute_chars: int = Field(default=300, ge=10)
|
|
134
|
+
max_tsconfig_paths: int = Field(default=100, gt=0, le=10_000) # path patterns and targets read from a tsconfig
|
|
135
|
+
max_workflow_steps: int = Field(default=5000, gt=0, le=100_000) # steps read from one workflow file
|
|
136
|
+
max_resource_paths: int = Field(default=20, gt=0, le=1000) # path attributes kept on one Terraform resource
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class ServerSettings(Settings):
|
|
140
|
+
# The host is always 127.0.0.1 and can't be configured (design section 7.4).
|
|
141
|
+
port: int = Field(default=8765, ge=1024, le=65535)
|
|
142
|
+
login_code_ttl_seconds: int = Field(default=60, gt=0)
|
|
143
|
+
session_minutes: int = Field(default=480, gt=0)
|
|
144
|
+
update_cooldown_seconds: int = Field(default=300, ge=0)
|
|
145
|
+
estimate_ttl_seconds: int = Field(default=300, gt=0)
|
|
146
|
+
update_log_lines: int = Field(default=200, gt=0) # the update panel's steps the server keeps
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class InterfaceSettings(Settings):
|
|
150
|
+
default_language: str = "en"
|
|
151
|
+
max_pins: int = Field(default=9, ge=1, le=50) # pinned pages the sidebar keeps
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class SignalSettings(Settings):
|
|
155
|
+
cache_seconds: int = Field(default=60, ge=0)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class DiagramSettings(Settings):
|
|
159
|
+
max_nodes: int = Field(default=25, gt=0)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class AgentSettings(Settings):
|
|
163
|
+
"""The map, briefs and tool server for coding agents (design section 23.6)."""
|
|
164
|
+
|
|
165
|
+
max_map_words: int = Field(default=2500, ge=500, le=20_000)
|
|
166
|
+
max_brief_words: int = Field(default=300, ge=50, le=2000)
|
|
167
|
+
key_files_per_part: int = Field(default=5, ge=0, le=20)
|
|
168
|
+
recent_digests: int = Field(default=5, ge=0, le=50)
|
|
169
|
+
max_briefs_per_call: int = Field(default=3, ge=1, le=10)
|
|
170
|
+
max_response_words: int = Field(default=4000, ge=500, le=20_000)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# A file name or suffix, lower-cased, and its language (design section 19.6); `[metrics.languages]` replaces it.
|
|
174
|
+
DEFAULT_LANGUAGES = {
|
|
175
|
+
".py": "Python", ".pyi": "Python",
|
|
176
|
+
".ts": "TypeScript", ".tsx": "TypeScript", ".mts": "TypeScript", ".cts": "TypeScript",
|
|
177
|
+
".js": "JavaScript", ".jsx": "JavaScript", ".mjs": "JavaScript", ".cjs": "JavaScript",
|
|
178
|
+
".astro": "Astro", ".swift": "Swift", ".kt": "Kotlin", ".kts": "Kotlin", ".java": "Java", ".go": "Go",
|
|
179
|
+
".rs": "Rust", ".rb": "Ruby", ".php": "PHP", ".c": "C", ".h": "C", ".cc": "C++", ".cpp": "C++", ".hpp": "C++",
|
|
180
|
+
".cs": "C#", ".m": "Objective-C", ".mm": "Objective-C", ".scala": "Scala",
|
|
181
|
+
".sh": "Shell", ".bash": "Shell", ".zsh": "Shell", ".sql": "SQL", ".html": "HTML", ".css": "CSS", ".scss": "CSS",
|
|
182
|
+
".vue": "Vue", ".svelte": "Svelte", ".tf": "Terraform", ".yaml": "YAML", ".yml": "YAML", ".json": "JSON",
|
|
183
|
+
".toml": "TOML", ".xml": "XML", ".proto": "Protocol Buffers", ".graphql": "GraphQL",
|
|
184
|
+
"dockerfile": "Dockerfile", "makefile": "Makefile", "justfile": "just",
|
|
185
|
+
} # fmt: skip
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class MetricsSettings(Settings):
|
|
189
|
+
"""The documentation metrics' and repository statistics' limits (design sections 18.5 and 19.6)."""
|
|
190
|
+
|
|
191
|
+
commit_window: int = Field(default=200, gt=0, le=10_000) # the latest non-merge commits the commit metric reads
|
|
192
|
+
proposed_adr_days: int = Field(default=30, ge=0) # a proposed ADR older than this needs attention
|
|
193
|
+
trend_updates: int = Field(default=12, ge=2, le=200) # updates a trend line shows
|
|
194
|
+
max_listed: int = Field(default=50, gt=0, le=5000) # items a list shows before the rest fold away
|
|
195
|
+
max_message_chars: int = Field(default=20_000, gt=0, le=1_000_000) # of a commit body, matched for a why
|
|
196
|
+
activity_months: int = Field(default=24, gt=0, le=600) # months the activity bars show
|
|
197
|
+
history_limit: int = Field(default=1_000_000, gt=0, le=100_000_000) # commits whose dates are read
|
|
198
|
+
largest_files: int = Field(default=10, gt=0, le=1000) # files the Repository page lists by size
|
|
199
|
+
churn_window: int = Field(default=500, gt=0, le=100_000) # the latest non-merge commits the hot spots read
|
|
200
|
+
quiet_days: int = Field(default=365, gt=0, le=36_500) # code no commit changed for this long is quiet
|
|
201
|
+
folder_depth: int = Field(default=2, ge=1, le=6) # levels of folders the change heat map groups by
|
|
202
|
+
heatmap_tiles: int = Field(default=60, gt=0, le=1000) # tiles a heat map level draws, the largest by lines
|
|
203
|
+
languages: dict[str, str] = DEFAULT_LANGUAGES
|
|
204
|
+
|
|
205
|
+
@field_validator("languages")
|
|
206
|
+
@classmethod
|
|
207
|
+
def lower_case_names(cls, languages: dict[str, str]) -> dict[str, str]:
|
|
208
|
+
return {name.lower(): language for name, language in languages.items()}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class ProvidersSettings(Settings):
|
|
212
|
+
claude_code: ClaudeCodeSettings = ClaudeCodeSettings()
|
|
213
|
+
codex: CodexSettings = CodexSettings()
|
|
214
|
+
local: LocalSettings = LocalSettings()
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class Price(Settings):
|
|
218
|
+
"""USD per million tokens, at API list prices: used for estimates and budgets only (design section 15.4)."""
|
|
219
|
+
|
|
220
|
+
input: float = Field(ge=0)
|
|
221
|
+
output: float = Field(ge=0)
|
|
222
|
+
cached_input: float | None = Field(default=None, ge=0)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# API list prices on 5 October 2026 (https://platform.claude.com/docs/en/about-claude/pricing); they change, so a
|
|
226
|
+
# [prices] table in the configuration adds to or replaces these.
|
|
227
|
+
DEFAULT_PRICES = {
|
|
228
|
+
"claude-opus-5-5": Price(input=4.0, output=20.0, cached_input=0.4),
|
|
229
|
+
"claude-sonnet-5-5": Price(input=2.0, output=10.0, cached_input=0.2),
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class TokenGuess(Settings):
|
|
234
|
+
input: int = Field(ge=0)
|
|
235
|
+
output: int = Field(ge=0)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class EstimateSettings(Settings):
|
|
239
|
+
"""Starting tokens per call, used until Codetrail has history of its own (design section 15.4)."""
|
|
240
|
+
|
|
241
|
+
history_size: int = Field(default=20, ge=3)
|
|
242
|
+
plan: TokenGuess = TokenGuess(input=60_000, output=8_000)
|
|
243
|
+
write: TokenGuess = TokenGuess(input=120_000, output=6_500)
|
|
244
|
+
revise: TokenGuess = TokenGuess(input=40_000, output=2_500)
|
|
245
|
+
digest: TokenGuess = TokenGuess(input=40_000, output=3_000)
|
|
246
|
+
answer: TokenGuess = TokenGuess(input=30_000, output=1_500)
|
|
247
|
+
grade: TokenGuess = TokenGuess(input=4_000, output=500)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class GlobalConfig(Settings):
|
|
251
|
+
tools: ToolsSettings = ToolsSettings()
|
|
252
|
+
assistant: AssistantSettings = AssistantSettings()
|
|
253
|
+
providers: ProvidersSettings = ProvidersSettings()
|
|
254
|
+
prices: dict[str, Price] = DEFAULT_PRICES
|
|
255
|
+
estimates: EstimateSettings = EstimateSettings()
|
|
256
|
+
|
|
257
|
+
@field_validator("prices")
|
|
258
|
+
@classmethod
|
|
259
|
+
def keep_default_prices(cls, prices: dict[str, Price]) -> dict[str, Price]:
|
|
260
|
+
return DEFAULT_PRICES | prices
|
|
261
|
+
|
|
262
|
+
bridge: BridgeSettings = BridgeSettings()
|
|
263
|
+
search: SearchSettings = SearchSettings()
|
|
264
|
+
learn: LearnSettings = LearnSettings()
|
|
265
|
+
extract: ExtractSettings = ExtractSettings()
|
|
266
|
+
server: ServerSettings = ServerSettings()
|
|
267
|
+
ui: InterfaceSettings = InterfaceSettings()
|
|
268
|
+
signal: SignalSettings = SignalSettings()
|
|
269
|
+
diagrams: DiagramSettings = DiagramSettings()
|
|
270
|
+
agents: AgentSettings = AgentSettings()
|
|
271
|
+
metrics: MetricsSettings = MetricsSettings()
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
class AdrSettings(Settings):
|
|
275
|
+
paths: list[str] = ["docs/adr/*.md"]
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class OpenApiSettings(Settings):
|
|
279
|
+
paths: list[str] = ["**/openapi.json", "**/openapi.yaml", "**/openapi.yml"]
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
class GenerationSettings(Settings):
|
|
283
|
+
max_pages_per_update: int = Field(default=20, ge=0)
|
|
284
|
+
concurrency: int = Field(default=2, gt=0, le=8)
|
|
285
|
+
max_turns: int = Field(default=30, gt=0)
|
|
286
|
+
max_budget_usd_per_call: float = Field(default=1.0, gt=0)
|
|
287
|
+
max_budget_usd_per_update: float = Field(default=10.0, gt=0)
|
|
288
|
+
# Counts every provider, including models with no configured price (design section 15.3).
|
|
289
|
+
max_tokens_per_update: int = Field(default=5_000_000, gt=0)
|
|
290
|
+
# An affected page with at most this many fact and link changes in its scope since it was written is revised:
|
|
291
|
+
# only the sections the changes affect are rewritten (design section 6.3). 0 writes every page whole.
|
|
292
|
+
revise_max_changes: int = Field(default=20, ge=0)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
class TargetMetricsSettings(Settings):
|
|
296
|
+
"""What counts as a document, a commit that explains why, a test and a commit type here (sections 18.5, 19.6)."""
|
|
297
|
+
|
|
298
|
+
document_globs: list[str] = ["README*", "*.md", "*.rst", "*.adoc", "*.txt", "docs/**"] # gitignore syntax
|
|
299
|
+
# Spaces and tabs only, never \s: whitespace that could cross lines would take quadratic time on a long run of
|
|
300
|
+
# blank lines in a message.
|
|
301
|
+
commit_why_pattern: str = r"(?im)^[ \t]*(?:#+[ \t]*)?why\b"
|
|
302
|
+
|
|
303
|
+
@field_validator("commit_why_pattern")
|
|
304
|
+
@classmethod
|
|
305
|
+
def compiles(cls, pattern: str) -> str:
|
|
306
|
+
try:
|
|
307
|
+
re.compile(pattern)
|
|
308
|
+
except re.error as error:
|
|
309
|
+
raise ValueError(f"isn't a valid regular expression: {error}") from error
|
|
310
|
+
return pattern
|
|
311
|
+
|
|
312
|
+
@property
|
|
313
|
+
def why(self) -> re.Pattern[str]:
|
|
314
|
+
return re.compile(self.commit_why_pattern)
|
|
315
|
+
|
|
316
|
+
test_globs: list[str] = ["test/**", "tests/**", "**/test_*.py", "**/*_test.*", "**/*.test.*", "**/*.spec.*",
|
|
317
|
+
"**/__tests__/**", "**/Tests/**"] # fmt: skip
|
|
318
|
+
commit_type_pattern: str = r"^(?P<type>[A-Za-z]+)(?:\((?P<scope>[^()\r\n]*)\))?!?:[ \t]" # a Conventional Commit
|
|
319
|
+
|
|
320
|
+
@field_validator("commit_type_pattern")
|
|
321
|
+
@classmethod
|
|
322
|
+
def has_a_type(cls, pattern: str) -> str:
|
|
323
|
+
try:
|
|
324
|
+
compiled = re.compile(pattern)
|
|
325
|
+
except re.error as error:
|
|
326
|
+
raise ValueError(f"isn't a valid regular expression: {error}") from error
|
|
327
|
+
if "type" not in compiled.groupindex:
|
|
328
|
+
raise ValueError("needs a (?P<type>...) group")
|
|
329
|
+
return pattern
|
|
330
|
+
|
|
331
|
+
@property
|
|
332
|
+
def types(self) -> re.Pattern[str]:
|
|
333
|
+
return re.compile(self.commit_type_pattern)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
PROVIDERS = ("claude_code", "codex", "local")
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def model_choice(value: str) -> tuple[str, str]:
|
|
340
|
+
"""A `[models]` value as (provider, model): `local:qwen3:14b` is ("local", "qwen3:14b"); no prefix: claude_code."""
|
|
341
|
+
provider, separator, model = value.partition(":")
|
|
342
|
+
if separator and provider in PROVIDERS:
|
|
343
|
+
return provider, model
|
|
344
|
+
return "claude_code", value
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
class ModelSettings(Settings):
|
|
348
|
+
"""The provider and model of each kind of call, as `provider:model` (design section 15.1)."""
|
|
349
|
+
|
|
350
|
+
plan: str = "claude-opus-5-5"
|
|
351
|
+
write: str = "claude-sonnet-5-5"
|
|
352
|
+
digest: str = "claude-sonnet-5-5"
|
|
353
|
+
answer: str = "claude-sonnet-5-5"
|
|
354
|
+
grade: str = "claude-sonnet-5-5"
|
|
355
|
+
|
|
356
|
+
@field_validator("plan", "write", "digest", "answer", "grade")
|
|
357
|
+
@classmethod
|
|
358
|
+
def known_provider(cls, value: str) -> str:
|
|
359
|
+
provider, separator, _model = value.partition(":")
|
|
360
|
+
if separator and provider not in PROVIDERS:
|
|
361
|
+
raise ValueError(f"{provider!r} isn't a provider; use one of {', '.join(PROVIDERS)}.")
|
|
362
|
+
return value
|
|
363
|
+
|
|
364
|
+
def uses(self, provider: str) -> bool:
|
|
365
|
+
return any(model_choice(getattr(self, kind))[0] == provider for kind in type(self).model_fields)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
class TargetAssistantSettings(Settings):
|
|
369
|
+
# Codex can read and run anything the reader can, so a target opts in to it (design section 15.5).
|
|
370
|
+
allow_codex: bool = False
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
class TargetConfig(Settings):
|
|
374
|
+
repository: Path
|
|
375
|
+
branch: str
|
|
376
|
+
extractors: list[Literal["python", "adr", "openapi", "terraform", "swift", "typescript", "github_actions"]] = [
|
|
377
|
+
"python",
|
|
378
|
+
"adr",
|
|
379
|
+
"openapi",
|
|
380
|
+
"terraform",
|
|
381
|
+
"swift",
|
|
382
|
+
"typescript",
|
|
383
|
+
"github_actions",
|
|
384
|
+
]
|
|
385
|
+
adr: AdrSettings = AdrSettings()
|
|
386
|
+
openapi: OpenApiSettings = OpenApiSettings()
|
|
387
|
+
generation: GenerationSettings = GenerationSettings()
|
|
388
|
+
models: ModelSettings = ModelSettings()
|
|
389
|
+
assistant: TargetAssistantSettings = TargetAssistantSettings()
|
|
390
|
+
metrics: TargetMetricsSettings = TargetMetricsSettings()
|
|
391
|
+
|
|
392
|
+
@model_validator(mode="after")
|
|
393
|
+
def codex_needs_consent(self) -> TargetConfig:
|
|
394
|
+
if self.models.uses("codex") and not self.assistant.allow_codex:
|
|
395
|
+
raise ValueError(
|
|
396
|
+
"models: Codex can read outside the repository's allowed files (design section 15.5); to use it for "
|
|
397
|
+
"this target, set [assistant] allow_codex = true."
|
|
398
|
+
)
|
|
399
|
+
return self
|
|
400
|
+
|
|
401
|
+
@field_validator("repository")
|
|
402
|
+
@classmethod
|
|
403
|
+
def expand_home(cls, repository: Path) -> Path:
|
|
404
|
+
return repository.expanduser()
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def validate_target_name(name: str) -> str:
|
|
408
|
+
if not TARGET_NAME.fullmatch(name):
|
|
409
|
+
raise CodetrailError(
|
|
410
|
+
f"{name!r} isn't a valid target name: use lower-case letters, digits and hyphens, starting with a letter "
|
|
411
|
+
"or digit, at most 63 characters."
|
|
412
|
+
)
|
|
413
|
+
return name
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def load_global(paths: Paths) -> GlobalConfig:
|
|
417
|
+
file = paths.config_dir / "config.toml"
|
|
418
|
+
if not file.exists():
|
|
419
|
+
return GlobalConfig()
|
|
420
|
+
return _validate(GlobalConfig, _read_toml(file), file)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def load_target(paths: Paths, name: str) -> TargetConfig:
|
|
424
|
+
file = paths.target_file(validate_target_name(name))
|
|
425
|
+
if not file.exists():
|
|
426
|
+
raise CodetrailError(f"No target named {name!r}. Add it with: codetrail target add {name} <path>")
|
|
427
|
+
return _validate(TargetConfig, _read_toml(file), file)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def check_containment(paths: Paths, repository: Path) -> None:
|
|
431
|
+
"""Codetrail's folders and the repository must not contain each other (AGENTS.md, Never do, rule 6)."""
|
|
432
|
+
repository = repository.expanduser().resolve()
|
|
433
|
+
for folder in (paths.config_dir, paths.data_dir, paths.state_dir):
|
|
434
|
+
folder = folder.expanduser().resolve()
|
|
435
|
+
if folder.is_relative_to(repository) or repository.is_relative_to(folder):
|
|
436
|
+
raise CodetrailError(
|
|
437
|
+
f"Codetrail's folder {folder} can't be inside the repository, nor the repository inside it."
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def write_target(paths: Paths, name: str, repository: Path, branch: str) -> Path:
|
|
442
|
+
"""Writes a new target's configuration file and returns its path."""
|
|
443
|
+
file = paths.target_file(validate_target_name(name))
|
|
444
|
+
repository = repository.expanduser().resolve()
|
|
445
|
+
check_containment(paths, repository)
|
|
446
|
+
validate_branch_name(branch)
|
|
447
|
+
# json.dumps with ASCII escapes writes a valid TOML basic string for any text.
|
|
448
|
+
text = (
|
|
449
|
+
f"# Codetrail target {name!r}, written by `codetrail target add`.\n"
|
|
450
|
+
f"repository = {json.dumps(str(repository))}\n"
|
|
451
|
+
f"branch = {json.dumps(branch)}\n"
|
|
452
|
+
)
|
|
453
|
+
file.parent.mkdir(parents=True, exist_ok=True)
|
|
454
|
+
try:
|
|
455
|
+
with file.open("x", encoding="utf-8") as handle:
|
|
456
|
+
handle.write(text)
|
|
457
|
+
except FileExistsError as error:
|
|
458
|
+
raise CodetrailError(f"A target named {name!r} already exists: {file}") from error
|
|
459
|
+
return file
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def validate_branch_name(branch: str) -> None:
|
|
463
|
+
from codetrail.repo.git import run_git # the git runner, not the repository package's readers
|
|
464
|
+
|
|
465
|
+
try:
|
|
466
|
+
run_git(["check-ref-format", "--branch", branch])
|
|
467
|
+
except CodetrailError as error:
|
|
468
|
+
raise CodetrailError(f"{branch!r} isn't a valid branch name.") from error
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _read_toml(file: Path) -> dict[str, Any]:
|
|
472
|
+
try:
|
|
473
|
+
return tomllib.loads(file.read_text(encoding="utf-8"))
|
|
474
|
+
except tomllib.TOMLDecodeError as error:
|
|
475
|
+
raise CodetrailError(f"{file} isn't valid TOML: {error}") from error
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _validate[T: Settings](model: type[T], data: dict[str, Any], file: Path) -> T:
|
|
479
|
+
try:
|
|
480
|
+
return model.model_validate(data)
|
|
481
|
+
except ValidationError as error:
|
|
482
|
+
problems = "; ".join(
|
|
483
|
+
f"{'.'.join(str(part) for part in issue['loc'])}: {issue['msg']}" for issue in error.errors()
|
|
484
|
+
)
|
|
485
|
+
raise CodetrailError(f"{file}: {problems}") from error
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""The target's SQLite database: facts and learning state, migrated with ordered SQL files (design section 10).
|
|
2
|
+
|
|
3
|
+
Each migration is `migrations/NNNN_name.sql`; `PRAGMA user_version` holds the last one applied.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
import sqlite3
|
|
10
|
+
from importlib.resources import files
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from codetrail.errors import CodetrailError
|
|
14
|
+
|
|
15
|
+
MIGRATION_NAME = re.compile(r"(\d{4})_[a-z0-9_]+\.sql")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def migrations() -> list[tuple[int, str]]:
|
|
19
|
+
found = []
|
|
20
|
+
for item in files("codetrail.database").joinpath("migrations").iterdir():
|
|
21
|
+
match = MIGRATION_NAME.fullmatch(item.name)
|
|
22
|
+
if match:
|
|
23
|
+
found.append((int(match.group(1)), item.read_text(encoding="utf-8")))
|
|
24
|
+
return sorted(found)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def connect(path: Path) -> sqlite3.Connection:
|
|
28
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
connection = sqlite3.connect(path, autocommit=True) # transactions are explicit, and executescript keeps them
|
|
30
|
+
connection.row_factory = sqlite3.Row
|
|
31
|
+
try:
|
|
32
|
+
connection.execute("PRAGMA foreign_keys = ON")
|
|
33
|
+
connection.execute("PRAGMA journal_mode = WAL")
|
|
34
|
+
available = migrations()
|
|
35
|
+
latest = available[-1][0] if available else 0
|
|
36
|
+
if _version(connection, path, latest) < latest:
|
|
37
|
+
# Another connection may be migrating too (the page's requests and its update thread): the write lock
|
|
38
|
+
# makes this one wait for it, and the version read again under the lock says what's left to apply.
|
|
39
|
+
connection.execute("BEGIN IMMEDIATE")
|
|
40
|
+
try:
|
|
41
|
+
current = _version(connection, path, latest)
|
|
42
|
+
for number, script in available:
|
|
43
|
+
if number > current:
|
|
44
|
+
connection.executescript(f"{script}\nPRAGMA user_version = {number};")
|
|
45
|
+
connection.execute("COMMIT")
|
|
46
|
+
except BaseException:
|
|
47
|
+
if connection.in_transaction: # SQLite ends it itself after some errors
|
|
48
|
+
connection.execute("ROLLBACK")
|
|
49
|
+
raise
|
|
50
|
+
except BaseException:
|
|
51
|
+
connection.close()
|
|
52
|
+
raise
|
|
53
|
+
return connection
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _version(connection: sqlite3.Connection, path: Path, latest: int) -> int:
|
|
57
|
+
current = int(connection.execute("PRAGMA user_version").fetchone()[0])
|
|
58
|
+
if current > latest:
|
|
59
|
+
raise CodetrailError(f"{path} was written by a newer Codetrail (schema {current}, this one knows {latest}).")
|
|
60
|
+
return current
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
-- The fact store (design section 4.2; ADR 0003). Each fact version is one row, valid from first_seen to last_seen
|
|
2
|
+
-- (inclusive); last_seen is NULL while the version is current.
|
|
3
|
+
CREATE TABLE snapshots (
|
|
4
|
+
id INTEGER PRIMARY KEY,
|
|
5
|
+
commit_sha TEXT NOT NULL,
|
|
6
|
+
taken_at TEXT NOT NULL
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
CREATE TABLE entities (
|
|
10
|
+
id TEXT NOT NULL,
|
|
11
|
+
kind TEXT NOT NULL,
|
|
12
|
+
attributes TEXT NOT NULL,
|
|
13
|
+
hash TEXT NOT NULL,
|
|
14
|
+
sources TEXT NOT NULL,
|
|
15
|
+
first_seen INTEGER NOT NULL REFERENCES snapshots (id),
|
|
16
|
+
last_seen INTEGER REFERENCES snapshots (id),
|
|
17
|
+
PRIMARY KEY (id, first_seen)
|
|
18
|
+
);
|
|
19
|
+
CREATE UNIQUE INDEX entities_current ON entities (id) WHERE last_seen IS NULL;
|
|
20
|
+
CREATE INDEX entities_kind ON entities (kind) WHERE last_seen IS NULL;
|
|
21
|
+
|
|
22
|
+
CREATE TABLE relations (
|
|
23
|
+
source_id TEXT NOT NULL,
|
|
24
|
+
kind TEXT NOT NULL,
|
|
25
|
+
target_id TEXT NOT NULL,
|
|
26
|
+
attributes TEXT NOT NULL,
|
|
27
|
+
hash TEXT NOT NULL,
|
|
28
|
+
sources TEXT NOT NULL,
|
|
29
|
+
first_seen INTEGER NOT NULL REFERENCES snapshots (id),
|
|
30
|
+
last_seen INTEGER REFERENCES snapshots (id),
|
|
31
|
+
PRIMARY KEY (source_id, kind, target_id, first_seen)
|
|
32
|
+
);
|
|
33
|
+
CREATE UNIQUE INDEX relations_current ON relations (source_id, kind, target_id) WHERE last_seen IS NULL;
|
|
34
|
+
CREATE INDEX relations_target ON relations (target_id) WHERE last_seen IS NULL;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
-- Learning state (design section 8.4): marks apply to a page version; passes to a check's hash.
|
|
2
|
+
CREATE TABLE page_marks (
|
|
3
|
+
page_id TEXT PRIMARY KEY,
|
|
4
|
+
read_version TEXT,
|
|
5
|
+
read_at TEXT,
|
|
6
|
+
learned_version TEXT,
|
|
7
|
+
learned_commit TEXT,
|
|
8
|
+
learned_at TEXT
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
CREATE TABLE check_passes (
|
|
12
|
+
page_id TEXT NOT NULL,
|
|
13
|
+
check_id TEXT NOT NULL,
|
|
14
|
+
check_hash TEXT NOT NULL,
|
|
15
|
+
passed_at TEXT NOT NULL,
|
|
16
|
+
PRIMARY KEY (page_id, check_id, check_hash)
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
CREATE TABLE check_attempts (
|
|
20
|
+
id INTEGER PRIMARY KEY,
|
|
21
|
+
page_id TEXT NOT NULL,
|
|
22
|
+
check_id TEXT NOT NULL,
|
|
23
|
+
check_hash TEXT NOT NULL,
|
|
24
|
+
answer TEXT NOT NULL,
|
|
25
|
+
verdict TEXT NOT NULL,
|
|
26
|
+
feedback TEXT NOT NULL,
|
|
27
|
+
language TEXT NOT NULL,
|
|
28
|
+
attempted_at TEXT NOT NULL
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
CREATE TABLE digest_reads (
|
|
32
|
+
digest_id TEXT PRIMARY KEY,
|
|
33
|
+
read_at TEXT NOT NULL
|
|
34
|
+
);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
-- What each assistant call used (design section 15.3): tokens, and its cost in dollars when known.
|
|
2
|
+
CREATE TABLE assistant_calls (
|
|
3
|
+
id INTEGER PRIMARY KEY,
|
|
4
|
+
called_at TEXT NOT NULL,
|
|
5
|
+
kind TEXT NOT NULL,
|
|
6
|
+
provider TEXT NOT NULL,
|
|
7
|
+
model TEXT NOT NULL,
|
|
8
|
+
input_tokens INTEGER NOT NULL,
|
|
9
|
+
cached_input_tokens INTEGER NOT NULL,
|
|
10
|
+
output_tokens INTEGER NOT NULL,
|
|
11
|
+
cost_usd REAL
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
CREATE INDEX assistant_calls_by_kind ON assistant_calls (kind, provider, model, id);
|
|
15
|
+
|
|
16
|
+
-- The latest reading of each subscription usage window a provider reported, and when it was read.
|
|
17
|
+
CREATE TABLE plan_usage (
|
|
18
|
+
provider TEXT NOT NULL,
|
|
19
|
+
window TEXT NOT NULL,
|
|
20
|
+
utilization REAL NOT NULL,
|
|
21
|
+
resets_at INTEGER NOT NULL,
|
|
22
|
+
observed_at TEXT NOT NULL,
|
|
23
|
+
PRIMARY KEY (provider, window)
|
|
24
|
+
);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
-- Pages whose drafts failed validation twice (design section 6.5), with the hash of their scope at the time: an
|
|
2
|
+
-- update skips such a page until its scope changes, or until the reader asks for a retry.
|
|
3
|
+
CREATE TABLE page_failures (
|
|
4
|
+
page_id TEXT PRIMARY KEY,
|
|
5
|
+
scope_hash TEXT NOT NULL,
|
|
6
|
+
failed_at TEXT NOT NULL
|
|
7
|
+
);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
-- Each documentation metric's value at each update (design section 18.3): its numerator and denominator, the
|
|
2
|
+
-- denominator empty for a count, so a small share can be shown as "3 of 4".
|
|
3
|
+
CREATE TABLE metric_values (
|
|
4
|
+
snapshot INTEGER NOT NULL REFERENCES snapshots(id),
|
|
5
|
+
metric TEXT NOT NULL,
|
|
6
|
+
numerator INTEGER NOT NULL,
|
|
7
|
+
denominator INTEGER,
|
|
8
|
+
PRIMARY KEY (snapshot, metric)
|
|
9
|
+
);
|