taskboy 0.1.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.
- taskboy/__init__.py +0 -0
- taskboy/adapters/__init__.py +0 -0
- taskboy/adapters/_util.py +30 -0
- taskboy/adapters/aws_read.py +107 -0
- taskboy/adapters/confluence.py +88 -0
- taskboy/adapters/github_api.py +564 -0
- taskboy/adapters/issues.py +435 -0
- taskboy/adapters/jira.py +263 -0
- taskboy/adapters/sentry.py +82 -0
- taskboy/adapters/slack_history.py +204 -0
- taskboy/assets.py +27 -0
- taskboy/audit.py +78 -0
- taskboy/broker.py +266 -0
- taskboy/classifier.py +219 -0
- taskboy/cli.py +161 -0
- taskboy/config.py +539 -0
- taskboy/dashboard/__init__.py +5 -0
- taskboy/dashboard/api.py +963 -0
- taskboy/dashboard/app.py +78 -0
- taskboy/dashboard/auth.py +117 -0
- taskboy/dashboard/editors.py +140 -0
- taskboy/dashboard/gitops.py +60 -0
- taskboy/dashboard/render.py +48 -0
- taskboy/debug_feed.py +170 -0
- taskboy/deploy/env.example +17 -0
- taskboy/deploy/git-cred-helper.py +36 -0
- taskboy/deploy/install.sh +101 -0
- taskboy/deploy/remote-update.sh +52 -0
- taskboy/deploy/taskboy-restart.path +10 -0
- taskboy/deploy/taskboy-restart.service +8 -0
- taskboy/deploy/taskboy.service +22 -0
- taskboy/hooks.py +208 -0
- taskboy/issue_runs.py +203 -0
- taskboy/llm.py +125 -0
- taskboy/main.py +269 -0
- taskboy/memory.py +59 -0
- taskboy/models.py +103 -0
- taskboy/mrkdwn.py +19 -0
- taskboy/notify.py +41 -0
- taskboy/orchestrator.py +401 -0
- taskboy/personality.py +17 -0
- taskboy/prompts.py +288 -0
- taskboy/quick.py +234 -0
- taskboy/redact.py +49 -0
- taskboy/repocache.py +117 -0
- taskboy/review_requests.py +388 -0
- taskboy/router.py +146 -0
- taskboy/runner.py +581 -0
- taskboy/scheduler.py +301 -0
- taskboy/secrets.py +83 -0
- taskboy/settings.py +19 -0
- taskboy/setup_checks.py +184 -0
- taskboy/setup_wizard.py +699 -0
- taskboy/skills.py +95 -0
- taskboy/slack.py +666 -0
- taskboy/slack_users.py +32 -0
- taskboy/started_messages.py +33 -0
- taskboy/store.py +1393 -0
- taskboy/task_actions.py +86 -0
- taskboy/templates/config.example.yaml +149 -0
- taskboy/templates/conventions.md +60 -0
- taskboy/templates/help.example.md +29 -0
- taskboy/templates/personality_agent.example.md +5 -0
- taskboy/templates/personality_reviewer.example.md +5 -0
- taskboy/templates/services/aws.yaml +7 -0
- taskboy/templates/services/confluence.yaml +4 -0
- taskboy/templates/services/github.yaml +14 -0
- taskboy/templates/services/jira.yaml +6 -0
- taskboy/templates/services/sentry.yaml +4 -0
- taskboy/templates/services/slack.yaml +9 -0
- taskboy/templates/skills/README.md +33 -0
- taskboy/templates/skills/discoverissues/SKILL.md +46 -0
- taskboy/templates/skills/implementapprovedissues/SKILL.md +41 -0
- taskboy/templates/skills/jira2pr/SKILL.md +66 -0
- taskboy/templates/skills/monitor/SKILL.md +32 -0
- taskboy/templates/skills/monitornew/SKILL.md +27 -0
- taskboy/templates/skills/refineissue/SKILL.md +35 -0
- taskboy/templates/skills/release/SKILL.md +36 -0
- taskboy/templates/skills/review/SKILL.md +89 -0
- taskboy/templates/skills/reviewandmonitor/SKILL.md +22 -0
- taskboy/templates/skills/reviews/SKILL.md +26 -0
- taskboy/templates/skills/slack2jira/SKILL.md +47 -0
- taskboy/templates/skills/slack2pr/SKILL.md +64 -0
- taskboy/templates/skills/spec2pr/SKILL.md +35 -0
- taskboy/templates/slack_app_manifest.yaml +48 -0
- taskboy/templates/task_started_messages.yaml +14 -0
- taskboy/ui_dist/assets/index-DmYxR9Qy.css +1 -0
- taskboy/ui_dist/assets/index-LDDO4iT2.js +15 -0
- taskboy/ui_dist/index.html +15 -0
- taskboy/workspace.py +86 -0
- taskboy-0.1.1.dist-info/METADATA +120 -0
- taskboy-0.1.1.dist-info/RECORD +95 -0
- taskboy-0.1.1.dist-info/WHEEL +5 -0
- taskboy-0.1.1.dist-info/entry_points.txt +2 -0
- taskboy-0.1.1.dist-info/top_level.txt +1 -0
taskboy/config.py
ADDED
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
"""config.yaml loading and validation. invalid config fails fast (exit 64) so systemd stops instead of crash-looping."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
from taskboy.models import EFFORT_LEVELS
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ConfigError(Exception):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# connected services an operator can turn on/off, each configurable in its own services/<name>.yaml file
|
|
18
|
+
KNOWN_SERVICES = ("slack", "github", "jira", "confluence", "sentry", "aws")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class SlackConfig:
|
|
23
|
+
team_id: str
|
|
24
|
+
allowed_channels: list[str] # empty = any channel the bot is invited to (the invite is the gate)
|
|
25
|
+
enabled: bool = False
|
|
26
|
+
ack_reaction: bool = True
|
|
27
|
+
debug_channel: str = ""
|
|
28
|
+
task_started_messages_path: str | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class ReviewerConfig:
|
|
33
|
+
# optional second github-only persona that reviews the main agent's prs (needs its own github app)
|
|
34
|
+
enabled: bool = False
|
|
35
|
+
name: str = "Reviewer"
|
|
36
|
+
personality_path: str | None = None
|
|
37
|
+
review_agent_prs: bool = True
|
|
38
|
+
commit_name: str = "Reviewer"
|
|
39
|
+
commit_email: str = ""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class DashboardConfig:
|
|
44
|
+
# mission control web ui. served behind an alb that authenticates auth0 sso; the app
|
|
45
|
+
# trusts the alb's signed identity header, so bind stays private (alb -> instance only).
|
|
46
|
+
enabled: bool = False
|
|
47
|
+
bind: str = "127.0.0.1" # 0.0.0.0 on the host (security group limits ingress to the alb)
|
|
48
|
+
port: int = 8787
|
|
49
|
+
allowed_email_domain: str = "" # any member of this email domain can view; required when enabled
|
|
50
|
+
admin_emails: list[str] = field(default_factory=list) # only these emails can edit/cancel/retry
|
|
51
|
+
dev_user_email: str | None = None # local-only identity when no alb header is present
|
|
52
|
+
expected_alb_arn: str = "" # oidc header's "signer" claim must match this; empty fails closed (no real alb token has an empty signer)
|
|
53
|
+
public_url: str = "" # e.g. https://agent.example.com; empty disables dashboard links in slack posts
|
|
54
|
+
commit_repo: str = "" # e.g. example-org/taskboy; empty disables auto-commit of edits
|
|
55
|
+
commit_branch: str = "main"
|
|
56
|
+
committer_name: str = "" # git committer identity for dashboard auto-commits; defaults to "<agent name> Dashboard"
|
|
57
|
+
committer_email: str = "" # required when auto_commit.repo is set
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def auto_commit_enabled(self) -> bool:
|
|
61
|
+
return bool(self.commit_repo)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class CliUpdateConfig:
|
|
66
|
+
# off-peak, in-process upgrade of the claude-agent-sdk package (which bundles the claude cli) in the
|
|
67
|
+
# service venv, plus a restart so the new version takes effect — ungated by CI (issue #67)
|
|
68
|
+
enabled: bool = False
|
|
69
|
+
at_time: str = "02:00"
|
|
70
|
+
tzname: str = "America/Los_Angeles"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class Role:
|
|
75
|
+
name: str
|
|
76
|
+
members: list[str]
|
|
77
|
+
allowed_profiles: list[str]
|
|
78
|
+
model_override: bool
|
|
79
|
+
max_budget_usd: float | None
|
|
80
|
+
repos: list[str] | None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class Config:
|
|
85
|
+
max_concurrency: int
|
|
86
|
+
queue_max: int
|
|
87
|
+
max_retries: int
|
|
88
|
+
progress_min_interval_seconds: int
|
|
89
|
+
runner: str # echo (dev/lifecycle testing) | claude (real sub-agent sessions)
|
|
90
|
+
slack: SlackConfig
|
|
91
|
+
roles: dict[str, Role]
|
|
92
|
+
raw: dict # full parsed yaml; later phases read their own sections from here
|
|
93
|
+
agent_name: str = "Agent" # the main agent's display name, used in all user-facing text
|
|
94
|
+
conventions_path: str | None = None
|
|
95
|
+
personality_path: str | None = None
|
|
96
|
+
help_path: str | None = None
|
|
97
|
+
dashboard: DashboardConfig = field(default_factory=DashboardConfig)
|
|
98
|
+
reviewer: ReviewerConfig = field(default_factory=ReviewerConfig)
|
|
99
|
+
cli_update: CliUpdateConfig = field(default_factory=CliUpdateConfig)
|
|
100
|
+
services: dict[str, bool] = field(default_factory=dict) # KNOWN_SERVICES name -> enabled
|
|
101
|
+
|
|
102
|
+
def service_enabled(self, name: str) -> bool:
|
|
103
|
+
return bool(self.services.get(name, False))
|
|
104
|
+
|
|
105
|
+
def enabled_integrations(self) -> list[str]:
|
|
106
|
+
# the classifier's integration vocabulary (prompts.CLASSIFICATION_SCHEMA), narrowed to services that are on
|
|
107
|
+
return [name for name in ("github", "aws", "sentry", "jira", "confluence") if self.service_enabled(name)]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def load_config(path: str) -> Config:
|
|
111
|
+
try:
|
|
112
|
+
with open(path) as f:
|
|
113
|
+
raw = yaml.safe_load(f)
|
|
114
|
+
except FileNotFoundError:
|
|
115
|
+
raise ConfigError(f"config file not found: {path} (run `taskboy setup` — or `taskboy setup --local` — to create it)")
|
|
116
|
+
except yaml.YAMLError as e:
|
|
117
|
+
raise ConfigError(f"config is not valid yaml: {e}")
|
|
118
|
+
if not isinstance(raw, dict):
|
|
119
|
+
raise ConfigError("config must be a yaml mapping")
|
|
120
|
+
orchestrator = raw.get("orchestrator")
|
|
121
|
+
if not isinstance(orchestrator, dict):
|
|
122
|
+
raise ConfigError("config is missing the 'orchestrator' section")
|
|
123
|
+
runner = orchestrator.get("runner", "echo")
|
|
124
|
+
if runner not in ("echo", "claude"):
|
|
125
|
+
raise ConfigError(f"orchestrator.runner must be 'echo' or 'claude', got {runner!r}")
|
|
126
|
+
_merge_service_files(raw, path)
|
|
127
|
+
services = _services_config(raw)
|
|
128
|
+
slack = _slack_config(raw.get("slack") or {}, path, enabled=services["slack"])
|
|
129
|
+
github = raw.get("github") or {}
|
|
130
|
+
if not isinstance(github, dict):
|
|
131
|
+
raise ConfigError("github section must be a mapping")
|
|
132
|
+
self_repo = github.get("self_repo", "")
|
|
133
|
+
if not isinstance(self_repo, str):
|
|
134
|
+
raise ConfigError(f"github.self_repo must be a string, got {self_repo!r}")
|
|
135
|
+
if self_repo and self_repo not in (github.get("approved_repos") or []):
|
|
136
|
+
raise ConfigError(f"github.self_repo must be listed in github.approved_repos, got {self_repo!r}")
|
|
137
|
+
_validate_profiles(raw)
|
|
138
|
+
roles = _roles_config(raw, slack)
|
|
139
|
+
_validate_skills(raw)
|
|
140
|
+
agent_name, personality_path = _agent_config(raw, path)
|
|
141
|
+
conventions_path = _file_path(raw, path, "conventions")
|
|
142
|
+
help_path = _file_path(raw, path, "help")
|
|
143
|
+
dashboard = _dashboard_config(raw.get("dashboard"), agent_name)
|
|
144
|
+
reviewer = _reviewer_config(raw.get("reviewer"), path)
|
|
145
|
+
cli_update = _cli_update_config(raw.get("cli_update"))
|
|
146
|
+
return Config(
|
|
147
|
+
max_concurrency=_int_at_least(orchestrator, "max_concurrency", 1),
|
|
148
|
+
queue_max=_int_at_least(orchestrator, "queue_max", 1),
|
|
149
|
+
max_retries=_int_at_least(orchestrator, "max_retries", 0),
|
|
150
|
+
progress_min_interval_seconds=_int_at_least(orchestrator, "progress_min_interval_seconds", 0),
|
|
151
|
+
runner=runner,
|
|
152
|
+
slack=slack,
|
|
153
|
+
roles=roles,
|
|
154
|
+
raw=raw,
|
|
155
|
+
agent_name=agent_name,
|
|
156
|
+
conventions_path=conventions_path,
|
|
157
|
+
personality_path=personality_path,
|
|
158
|
+
help_path=help_path,
|
|
159
|
+
dashboard=dashboard,
|
|
160
|
+
reviewer=reviewer,
|
|
161
|
+
cli_update=cli_update,
|
|
162
|
+
services=services,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _merge_service_files(raw: dict, config_path: str) -> None:
|
|
167
|
+
"""fold services/<name>.yaml files (next to config.yaml) into their top-level sections."""
|
|
168
|
+
services_dir = Path(config_path).resolve().parent / "services"
|
|
169
|
+
if not services_dir.is_dir():
|
|
170
|
+
return
|
|
171
|
+
for file in sorted(services_dir.glob("*.yaml")):
|
|
172
|
+
if file.name.startswith("."):
|
|
173
|
+
continue # editor/os droppings must not brick a restart
|
|
174
|
+
name = file.stem
|
|
175
|
+
if name not in KNOWN_SERVICES:
|
|
176
|
+
raise ConfigError(f"services/{file.name} is not a known service config (known services: {', '.join(KNOWN_SERVICES)})")
|
|
177
|
+
if name in raw:
|
|
178
|
+
raise ConfigError(f"{name} is configured in both config.yaml and services/{file.name} — keep only services/{file.name}")
|
|
179
|
+
try:
|
|
180
|
+
section = yaml.safe_load(file.read_text())
|
|
181
|
+
except OSError as e:
|
|
182
|
+
raise ConfigError(f"services/{file.name} could not be read: {e}")
|
|
183
|
+
except yaml.YAMLError as e:
|
|
184
|
+
raise ConfigError(f"services/{file.name} is not valid yaml: {e}")
|
|
185
|
+
if not isinstance(section, dict):
|
|
186
|
+
raise ConfigError(f"services/{file.name} must be a yaml mapping")
|
|
187
|
+
if not isinstance(section.get("enabled"), bool):
|
|
188
|
+
raise ConfigError(f"services/{file.name} must set enabled: true or false")
|
|
189
|
+
raw[name] = section
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def service_enabled_in(raw: dict, name: str) -> bool:
|
|
193
|
+
"""resolved enabled flag for one service section: the explicit key, else legacy inference."""
|
|
194
|
+
section = raw.get(name) or {}
|
|
195
|
+
enabled = section.get("enabled") if isinstance(section, dict) else None
|
|
196
|
+
if isinstance(enabled, bool):
|
|
197
|
+
return enabled
|
|
198
|
+
return _legacy_enabled(name, section if isinstance(section, dict) else {})
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _legacy_enabled(name: str, section: dict) -> bool:
|
|
202
|
+
# configs from before the enabled flag signal "on" by filling in a key; github was gated by secrets alone
|
|
203
|
+
if name == "slack":
|
|
204
|
+
return bool(section.get("team_id"))
|
|
205
|
+
if name in ("jira", "confluence"):
|
|
206
|
+
return bool(section.get("site"))
|
|
207
|
+
if name == "sentry":
|
|
208
|
+
return bool(section.get("organization"))
|
|
209
|
+
if name == "aws":
|
|
210
|
+
return bool(section.get("allowed_services"))
|
|
211
|
+
return name == "github"
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _services_config(raw: dict) -> dict[str, bool]:
|
|
215
|
+
services: dict[str, bool] = {}
|
|
216
|
+
for name in KNOWN_SERVICES:
|
|
217
|
+
section = raw.get(name)
|
|
218
|
+
if section is None:
|
|
219
|
+
section = {}
|
|
220
|
+
if not isinstance(section, dict):
|
|
221
|
+
raise ConfigError(f"{name} section must be a mapping")
|
|
222
|
+
enabled = section.get("enabled")
|
|
223
|
+
if enabled is None:
|
|
224
|
+
enabled = _legacy_enabled(name, section)
|
|
225
|
+
if not isinstance(enabled, bool):
|
|
226
|
+
raise ConfigError(f"{name}.enabled must be a boolean, got {enabled!r}")
|
|
227
|
+
if enabled:
|
|
228
|
+
required = {"slack": "team_id", "jira": "site", "confluence": "site", "sentry": "organization", "aws": "allowed_services"}.get(name)
|
|
229
|
+
if required and not section.get(required):
|
|
230
|
+
raise ConfigError(f"{name}.{required} is required when {name} is enabled")
|
|
231
|
+
services[name] = enabled
|
|
232
|
+
return services
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _cli_update_config(section: object) -> CliUpdateConfig:
|
|
236
|
+
if section is None:
|
|
237
|
+
section = {}
|
|
238
|
+
if not isinstance(section, dict):
|
|
239
|
+
raise ConfigError("cli_update section must be a mapping")
|
|
240
|
+
enabled = section.get("enabled", False)
|
|
241
|
+
if not isinstance(enabled, bool):
|
|
242
|
+
raise ConfigError(f"cli_update.enabled must be a boolean, got {enabled!r}")
|
|
243
|
+
at_time = section.get("at_time", "02:00")
|
|
244
|
+
if not isinstance(at_time, str) or re.fullmatch(r"(?:[01]\d|2[0-3]):[0-5]\d", at_time) is None:
|
|
245
|
+
raise ConfigError(f"cli_update.at_time must be HH:MM in 24-hour time, got {at_time!r}")
|
|
246
|
+
tzname = section.get("tzname", "America/Los_Angeles")
|
|
247
|
+
if not isinstance(tzname, str) or not tzname:
|
|
248
|
+
raise ConfigError(f"cli_update.tzname must be a non-empty string, got {tzname!r}")
|
|
249
|
+
try:
|
|
250
|
+
ZoneInfo(tzname)
|
|
251
|
+
except (ZoneInfoNotFoundError, ValueError):
|
|
252
|
+
raise ConfigError(f"cli_update.tzname {tzname!r} is not a known IANA timezone")
|
|
253
|
+
return CliUpdateConfig(enabled=enabled, at_time=at_time, tzname=tzname)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _reviewer_config(section: object, config_path: str) -> ReviewerConfig:
|
|
257
|
+
if section is None:
|
|
258
|
+
section = {}
|
|
259
|
+
if not isinstance(section, dict):
|
|
260
|
+
raise ConfigError("reviewer section must be a mapping")
|
|
261
|
+
enabled = section.get("enabled", False)
|
|
262
|
+
if not isinstance(enabled, bool):
|
|
263
|
+
raise ConfigError(f"reviewer.enabled must be a boolean, got {enabled!r}")
|
|
264
|
+
name = section.get("name", "Reviewer")
|
|
265
|
+
if not isinstance(name, str) or not name:
|
|
266
|
+
raise ConfigError(f"reviewer.name must be a non-empty string, got {name!r}")
|
|
267
|
+
personality_file = section.get("personality_file", "")
|
|
268
|
+
if not isinstance(personality_file, str):
|
|
269
|
+
raise ConfigError(f"reviewer.personality_file must be a string, got {personality_file!r}")
|
|
270
|
+
personality_path = None
|
|
271
|
+
if personality_file:
|
|
272
|
+
resolved = (Path(config_path).resolve().parent / personality_file).resolve()
|
|
273
|
+
if not resolved.is_file():
|
|
274
|
+
raise ConfigError(f"reviewer.personality_file not found: {resolved}")
|
|
275
|
+
personality_path = str(resolved)
|
|
276
|
+
review_agent_prs = section.get("review_agent_prs", True)
|
|
277
|
+
if not isinstance(review_agent_prs, bool):
|
|
278
|
+
raise ConfigError(f"reviewer.review_agent_prs must be a boolean, got {review_agent_prs!r}")
|
|
279
|
+
commit_name = section.get("commit_name") or name # git author defaults to the display name
|
|
280
|
+
if not isinstance(commit_name, str) or not commit_name:
|
|
281
|
+
raise ConfigError(f"reviewer.commit_name must be a non-empty string, got {commit_name!r}")
|
|
282
|
+
commit_email = section.get("commit_email", "")
|
|
283
|
+
if not isinstance(commit_email, str):
|
|
284
|
+
raise ConfigError(f"reviewer.commit_email must be a string, got {commit_email!r}")
|
|
285
|
+
if enabled and not commit_email:
|
|
286
|
+
raise ConfigError("reviewer.commit_email is required when the reviewer is enabled")
|
|
287
|
+
return ReviewerConfig(
|
|
288
|
+
enabled=enabled,
|
|
289
|
+
name=name,
|
|
290
|
+
personality_path=personality_path,
|
|
291
|
+
review_agent_prs=review_agent_prs,
|
|
292
|
+
commit_name=commit_name,
|
|
293
|
+
commit_email=commit_email,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _dashboard_config(section: object, agent_name: str) -> DashboardConfig:
|
|
298
|
+
if section is None:
|
|
299
|
+
section = {}
|
|
300
|
+
if not isinstance(section, dict):
|
|
301
|
+
raise ConfigError("dashboard section must be a mapping")
|
|
302
|
+
enabled = section.get("enabled", False)
|
|
303
|
+
if not isinstance(enabled, bool):
|
|
304
|
+
raise ConfigError(f"dashboard.enabled must be a boolean, got {enabled!r}")
|
|
305
|
+
bind = section.get("bind", "127.0.0.1")
|
|
306
|
+
if bind not in ("127.0.0.1", "0.0.0.0"):
|
|
307
|
+
raise ConfigError(f"dashboard.bind must be '127.0.0.1' or '0.0.0.0', got {bind!r}")
|
|
308
|
+
port = section.get("port", 8787)
|
|
309
|
+
if not isinstance(port, int) or isinstance(port, bool) or not 1 <= port <= 65535:
|
|
310
|
+
raise ConfigError(f"dashboard.port must be an integer between 1 and 65535, got {port!r}")
|
|
311
|
+
domain = section.get("allowed_email_domain", "")
|
|
312
|
+
if not isinstance(domain, str) or "@" in domain:
|
|
313
|
+
raise ConfigError(f"dashboard.allowed_email_domain must be a bare domain like 'example.com', got {domain!r}")
|
|
314
|
+
if enabled and not domain:
|
|
315
|
+
raise ConfigError("dashboard.allowed_email_domain is required when the dashboard is enabled")
|
|
316
|
+
admin_emails = section.get("admin_emails") or []
|
|
317
|
+
if not isinstance(admin_emails, list) or not all(isinstance(item, str) and "@" in item for item in admin_emails):
|
|
318
|
+
raise ConfigError(f"dashboard.admin_emails must be a list of email addresses, got {admin_emails!r}")
|
|
319
|
+
dev_user_email = section.get("dev_user_email")
|
|
320
|
+
if dev_user_email is not None and not isinstance(dev_user_email, str):
|
|
321
|
+
raise ConfigError(f"dashboard.dev_user_email must be a string or null, got {dev_user_email!r}")
|
|
322
|
+
expected_alb_arn = section.get("expected_alb_arn", "")
|
|
323
|
+
if not isinstance(expected_alb_arn, str):
|
|
324
|
+
raise ConfigError(f"dashboard.expected_alb_arn must be a string, got {expected_alb_arn!r}")
|
|
325
|
+
public_url = section.get("public_url", "")
|
|
326
|
+
if not isinstance(public_url, str) or (public_url and not public_url.startswith(("http://", "https://"))):
|
|
327
|
+
raise ConfigError(f"dashboard.public_url must be an http(s) url or empty, got {public_url!r}")
|
|
328
|
+
commit = section.get("auto_commit") or {}
|
|
329
|
+
if not isinstance(commit, dict):
|
|
330
|
+
raise ConfigError("dashboard.auto_commit must be a mapping")
|
|
331
|
+
commit_repo = commit.get("repo", "")
|
|
332
|
+
if not isinstance(commit_repo, str) or (commit_repo and "/" not in commit_repo):
|
|
333
|
+
raise ConfigError(f"dashboard.auto_commit.repo must be 'owner/name', got {commit_repo!r}")
|
|
334
|
+
commit_branch = commit.get("branch", "main")
|
|
335
|
+
if not isinstance(commit_branch, str) or not commit_branch:
|
|
336
|
+
raise ConfigError(f"dashboard.auto_commit.branch must be a non-empty string, got {commit_branch!r}")
|
|
337
|
+
committer_name = commit.get("committer_name") or f"{agent_name} Dashboard"
|
|
338
|
+
if not isinstance(committer_name, str) or not committer_name:
|
|
339
|
+
raise ConfigError(f"dashboard.auto_commit.committer_name must be a non-empty string, got {committer_name!r}")
|
|
340
|
+
committer_email = commit.get("committer_email", "")
|
|
341
|
+
if not isinstance(committer_email, str):
|
|
342
|
+
raise ConfigError(f"dashboard.auto_commit.committer_email must be a string, got {committer_email!r}")
|
|
343
|
+
if commit_repo and not committer_email:
|
|
344
|
+
raise ConfigError("dashboard.auto_commit.committer_email is required when auto_commit.repo is set")
|
|
345
|
+
return DashboardConfig(
|
|
346
|
+
enabled=enabled,
|
|
347
|
+
bind=bind,
|
|
348
|
+
port=port,
|
|
349
|
+
allowed_email_domain=domain.lower(),
|
|
350
|
+
admin_emails=[item.lower() for item in admin_emails],
|
|
351
|
+
dev_user_email=dev_user_email or None,
|
|
352
|
+
expected_alb_arn=expected_alb_arn,
|
|
353
|
+
public_url=public_url.rstrip("/"),
|
|
354
|
+
commit_repo=commit_repo,
|
|
355
|
+
commit_branch=commit_branch,
|
|
356
|
+
committer_name=committer_name,
|
|
357
|
+
committer_email=committer_email,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _slack_config(section: dict, config_path: str, enabled: bool = False) -> SlackConfig:
|
|
362
|
+
if not isinstance(section, dict):
|
|
363
|
+
raise ConfigError("slack section must be a mapping")
|
|
364
|
+
legacy = [key for key in ("allowed_users", "admins") if key in section]
|
|
365
|
+
if legacy:
|
|
366
|
+
raise ConfigError(f"slack.{legacy[0]} is no longer supported; migrate Slack users and model-override permissions to the top-level roles section")
|
|
367
|
+
if "bot_name" in section:
|
|
368
|
+
raise ConfigError("slack.bot_name moved to agent.name")
|
|
369
|
+
team_id = section.get("team_id") or ""
|
|
370
|
+
if not isinstance(team_id, str):
|
|
371
|
+
raise ConfigError(f"slack.team_id must be a string, got {team_id!r}")
|
|
372
|
+
ack_reaction = section.get("ack_reaction", True)
|
|
373
|
+
if not isinstance(ack_reaction, bool):
|
|
374
|
+
raise ConfigError(f"slack.ack_reaction must be a boolean, got {ack_reaction!r}")
|
|
375
|
+
debug_channel = section.get("debug_channel", "")
|
|
376
|
+
if not isinstance(debug_channel, str):
|
|
377
|
+
raise ConfigError(f"slack.debug_channel must be a string, got {debug_channel!r}")
|
|
378
|
+
messages_file = section.get("task_started_messages_file", "task_started_messages.yaml")
|
|
379
|
+
if not isinstance(messages_file, str):
|
|
380
|
+
raise ConfigError(f"slack.task_started_messages_file must be a string, got {messages_file!r}")
|
|
381
|
+
messages_path = str((Path(config_path).resolve().parent / messages_file).resolve()) if messages_file else None
|
|
382
|
+
return SlackConfig(
|
|
383
|
+
team_id=team_id,
|
|
384
|
+
allowed_channels=_str_list(section, "allowed_channels"),
|
|
385
|
+
enabled=enabled,
|
|
386
|
+
ack_reaction=ack_reaction,
|
|
387
|
+
debug_channel=debug_channel,
|
|
388
|
+
task_started_messages_path=messages_path,
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _str_list(section: dict, key: str) -> list[str]:
|
|
393
|
+
value = section.get(key) or []
|
|
394
|
+
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
|
|
395
|
+
raise ConfigError(f"slack.{key} must be a list of strings, got {value!r}")
|
|
396
|
+
return value
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def role_for(roles: dict[str, Role], user_id: str) -> Role | None:
|
|
400
|
+
for role in roles.values():
|
|
401
|
+
if user_id in role.members:
|
|
402
|
+
return role
|
|
403
|
+
for role in roles.values():
|
|
404
|
+
if "*" in role.members:
|
|
405
|
+
return role
|
|
406
|
+
return None
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _roles_config(raw: dict, slack: SlackConfig) -> dict[str, Role]:
|
|
410
|
+
section = raw.get("roles") or {}
|
|
411
|
+
if not isinstance(section, dict):
|
|
412
|
+
raise ConfigError("roles section must be a mapping")
|
|
413
|
+
if slack.enabled and not section:
|
|
414
|
+
raise ConfigError("roles must contain at least one role when Slack intake is enabled")
|
|
415
|
+
profiles = raw.get("profiles") or {}
|
|
416
|
+
approved_repos = set((raw.get("github") or {}).get("approved_repos") or [])
|
|
417
|
+
roles: dict[str, Role] = {}
|
|
418
|
+
owners: dict[str, str] = {}
|
|
419
|
+
wildcard_roles = 0
|
|
420
|
+
for name, value in section.items():
|
|
421
|
+
if not isinstance(name, str) or not isinstance(value, dict):
|
|
422
|
+
raise ConfigError(f"roles.{name} must be a mapping")
|
|
423
|
+
members = _role_str_list(value, "members", name)
|
|
424
|
+
allowed_profiles = _role_str_list(value, "allowed_profiles", name)
|
|
425
|
+
unknown_profiles = set(allowed_profiles) - set(profiles)
|
|
426
|
+
if unknown_profiles:
|
|
427
|
+
raise ConfigError(f"roles.{name}.allowed_profiles contains unknown profiles: {sorted(unknown_profiles)}")
|
|
428
|
+
model_override = value.get("model_override", False)
|
|
429
|
+
if not isinstance(model_override, bool):
|
|
430
|
+
raise ConfigError(f"roles.{name}.model_override must be a boolean, got {model_override!r}")
|
|
431
|
+
max_budget = value.get("max_budget_usd")
|
|
432
|
+
if max_budget is not None and (not isinstance(max_budget, (int, float)) or isinstance(max_budget, bool) or max_budget < 0):
|
|
433
|
+
raise ConfigError(f"roles.{name}.max_budget_usd must be a non-negative number or null, got {max_budget!r}")
|
|
434
|
+
repos_value = value.get("repos")
|
|
435
|
+
repos = None if repos_value is None else _role_str_list(value, "repos", name)
|
|
436
|
+
unknown_repos = set(repos or []) - approved_repos
|
|
437
|
+
if unknown_repos:
|
|
438
|
+
raise ConfigError(f"roles.{name}.repos contains repositories outside github.approved_repos: {sorted(unknown_repos)}")
|
|
439
|
+
for member in members:
|
|
440
|
+
if member == "*":
|
|
441
|
+
wildcard_roles += 1
|
|
442
|
+
continue
|
|
443
|
+
if member in owners:
|
|
444
|
+
raise ConfigError(f"user {member!r} belongs to both roles {owners[member]!r} and {name!r}")
|
|
445
|
+
owners[member] = name
|
|
446
|
+
roles[name] = Role(name=name, members=members, allowed_profiles=allowed_profiles, model_override=model_override, max_budget_usd=float(max_budget) if max_budget is not None else None, repos=repos)
|
|
447
|
+
if wildcard_roles > 1:
|
|
448
|
+
raise ConfigError("at most one role may contain the wildcard member '*'")
|
|
449
|
+
return roles
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _role_str_list(section: dict, key: str, role_name: str) -> list[str]:
|
|
453
|
+
value = section.get(key) or []
|
|
454
|
+
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
|
|
455
|
+
raise ConfigError(f"roles.{role_name}.{key} must be a list of strings, got {value!r}")
|
|
456
|
+
return value
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _validate_profiles(raw: dict) -> None:
|
|
460
|
+
profiles = raw.get("profiles") or {}
|
|
461
|
+
if not isinstance(profiles, dict):
|
|
462
|
+
raise ConfigError("profiles section must be a mapping")
|
|
463
|
+
for name, profile in profiles.items():
|
|
464
|
+
if not isinstance(profile, dict):
|
|
465
|
+
raise ConfigError(f"profiles.{name} must be a mapping")
|
|
466
|
+
effort = profile.get("effort")
|
|
467
|
+
if effort is not None and effort not in EFFORT_LEVELS:
|
|
468
|
+
raise ConfigError(f"profiles.{name}.effort must be one of {', '.join(EFFORT_LEVELS)}, got {effort!r}")
|
|
469
|
+
thinking = profile.get("thinking")
|
|
470
|
+
if thinking is None:
|
|
471
|
+
continue
|
|
472
|
+
if not isinstance(thinking, dict) or thinking.get("type") not in {"adaptive", "enabled", "disabled"}:
|
|
473
|
+
raise ConfigError(f"profiles.{name}.thinking must be a mapping with type adaptive, enabled, or disabled")
|
|
474
|
+
if thinking["type"] == "enabled":
|
|
475
|
+
budget = thinking.get("budget_tokens")
|
|
476
|
+
if not isinstance(budget, int) or isinstance(budget, bool):
|
|
477
|
+
raise ConfigError(f"profiles.{name}.thinking enabled requires an integer budget_tokens")
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _validate_skills(raw: dict) -> None:
|
|
481
|
+
section = raw.get("skills")
|
|
482
|
+
if section is None:
|
|
483
|
+
section = {}
|
|
484
|
+
if not isinstance(section, dict):
|
|
485
|
+
raise ConfigError("skills section must be a mapping")
|
|
486
|
+
tier = section.get("tier", "opus")
|
|
487
|
+
profile = section.get("profile", "standard")
|
|
488
|
+
models = raw.get("models")
|
|
489
|
+
profiles = raw.get("profiles")
|
|
490
|
+
if not isinstance(tier, str) or (isinstance(models, dict) and tier not in models):
|
|
491
|
+
raise ConfigError(f"skills.tier {tier!r} is not in the model catalog")
|
|
492
|
+
if not isinstance(profile, str) or (isinstance(profiles, dict) and profile not in profiles):
|
|
493
|
+
raise ConfigError(f"skills.profile {profile!r} is not configured")
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _file_path(raw: dict, config_path: str, section_name: str) -> str | None:
|
|
497
|
+
if section_name not in raw:
|
|
498
|
+
return None
|
|
499
|
+
section = raw[section_name]
|
|
500
|
+
if not isinstance(section, dict):
|
|
501
|
+
raise ConfigError(f"{section_name} section must be a mapping")
|
|
502
|
+
file_value = section.get("file")
|
|
503
|
+
if not isinstance(file_value, str):
|
|
504
|
+
raise ConfigError(f"{section_name}.file must be a string, got {file_value!r}")
|
|
505
|
+
if not file_value:
|
|
506
|
+
return None
|
|
507
|
+
resolved = (Path(config_path).resolve().parent / file_value).resolve()
|
|
508
|
+
if not resolved.is_file():
|
|
509
|
+
raise ConfigError(f"{section_name}.file not found: {resolved}")
|
|
510
|
+
return str(resolved)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _agent_config(raw: dict, config_path: str) -> tuple[str, str | None]:
|
|
514
|
+
if "personality" in raw:
|
|
515
|
+
raise ConfigError("the top-level personality section moved to agent.personality_file")
|
|
516
|
+
section = raw.get("agent")
|
|
517
|
+
if section is None:
|
|
518
|
+
section = {}
|
|
519
|
+
if not isinstance(section, dict):
|
|
520
|
+
raise ConfigError("agent section must be a mapping")
|
|
521
|
+
name = section.get("name", "Agent")
|
|
522
|
+
if not isinstance(name, str) or not name:
|
|
523
|
+
raise ConfigError(f"agent.name must be a non-empty string, got {name!r}")
|
|
524
|
+
file_value = section.get("personality_file", "")
|
|
525
|
+
if not isinstance(file_value, str):
|
|
526
|
+
raise ConfigError(f"agent.personality_file must be a string, got {file_value!r}")
|
|
527
|
+
if not file_value:
|
|
528
|
+
return name, None
|
|
529
|
+
resolved = (Path(config_path).resolve().parent / file_value).resolve()
|
|
530
|
+
if not resolved.is_file():
|
|
531
|
+
raise ConfigError(f"agent.personality_file not found: {resolved}")
|
|
532
|
+
return name, str(resolved)
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _int_at_least(section: dict, key: str, minimum: int) -> int:
|
|
536
|
+
value = section.get(key)
|
|
537
|
+
if not isinstance(value, int) or isinstance(value, bool) or value < minimum:
|
|
538
|
+
raise ConfigError(f"orchestrator.{key} must be an integer >= {minimum}, got {value!r}")
|
|
539
|
+
return value
|