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/dashboard/app.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""fastapi application factory for mission control.
|
|
2
|
+
|
|
3
|
+
serves the json api plus the built react spa. the alb terminates tls and runs auth0
|
|
4
|
+
oidc; this app verifies the alb's identity header per request (auth.py). /healthz is
|
|
5
|
+
the only unauthenticated data route — alb health checks hit the target directly.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
12
|
+
from fastapi.responses import FileResponse, JSONResponse
|
|
13
|
+
from starlette.staticfiles import StaticFiles
|
|
14
|
+
|
|
15
|
+
from taskboy.dashboard.api import router
|
|
16
|
+
from taskboy.dashboard.auth import NotAuthenticated, NotAuthorized
|
|
17
|
+
from taskboy.dashboard.editors import EditorError
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger("taskboy.dashboard")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def create_app(store, config, notifier, secrets, orchestrator=None, ui_dist: str = "ui/dist") -> FastAPI:
|
|
23
|
+
app = FastAPI(title="agent dashboard", docs_url=None, redoc_url=None, openapi_url=None)
|
|
24
|
+
app.state.store = store
|
|
25
|
+
app.state.config = config
|
|
26
|
+
app.state.notifier = notifier
|
|
27
|
+
app.state.secrets = secrets
|
|
28
|
+
app.state.orchestrator = orchestrator
|
|
29
|
+
app.state.alb_key_cache = {}
|
|
30
|
+
dist = Path(ui_dist)
|
|
31
|
+
|
|
32
|
+
@app.middleware("http")
|
|
33
|
+
async def security_headers(request: Request, call_next):
|
|
34
|
+
response = await call_next(request)
|
|
35
|
+
response.headers["Content-Security-Policy"] = "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'"
|
|
36
|
+
response.headers["X-Frame-Options"] = "DENY"
|
|
37
|
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
38
|
+
response.headers["Referrer-Policy"] = "no-referrer"
|
|
39
|
+
if request.url.path.startswith("/api/"):
|
|
40
|
+
response.headers["Cache-Control"] = "no-store"
|
|
41
|
+
return response
|
|
42
|
+
|
|
43
|
+
@app.exception_handler(NotAuthenticated)
|
|
44
|
+
async def unauthenticated(request: Request, e: NotAuthenticated):
|
|
45
|
+
return JSONResponse({"detail": str(e)}, status_code=401)
|
|
46
|
+
|
|
47
|
+
@app.exception_handler(NotAuthorized)
|
|
48
|
+
async def unauthorized(request: Request, e: NotAuthorized):
|
|
49
|
+
return JSONResponse({"detail": str(e)}, status_code=403)
|
|
50
|
+
|
|
51
|
+
@app.exception_handler(EditorError)
|
|
52
|
+
async def editor_missing(request: Request, e: EditorError):
|
|
53
|
+
return JSONResponse({"detail": str(e)}, status_code=404)
|
|
54
|
+
|
|
55
|
+
@app.get("/healthz")
|
|
56
|
+
async def healthz() -> dict:
|
|
57
|
+
return {"status": "ok"}
|
|
58
|
+
|
|
59
|
+
app.include_router(router)
|
|
60
|
+
|
|
61
|
+
if (dist / "assets").is_dir():
|
|
62
|
+
app.mount("/assets", StaticFiles(directory=dist / "assets"), name="assets")
|
|
63
|
+
|
|
64
|
+
@app.get("/{full_path:path}")
|
|
65
|
+
async def spa(full_path: str):
|
|
66
|
+
# client-side routing: any non-api path gets the app shell; real files in dist are served as-is
|
|
67
|
+
if full_path.startswith("api/"):
|
|
68
|
+
raise HTTPException(status_code=404, detail="not found")
|
|
69
|
+
if full_path and "/" not in full_path and ".." not in full_path:
|
|
70
|
+
candidate = dist / full_path
|
|
71
|
+
if candidate.is_file():
|
|
72
|
+
return FileResponse(candidate)
|
|
73
|
+
index = dist / "index.html"
|
|
74
|
+
if not index.is_file():
|
|
75
|
+
return JSONResponse({"detail": "ui build not found — run `npm run build` in ui/"}, status_code=503)
|
|
76
|
+
return FileResponse(index, headers={"Cache-Control": "no-cache"})
|
|
77
|
+
|
|
78
|
+
return app
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""viewer identity from the alb's signed oidc header.
|
|
2
|
+
|
|
3
|
+
the alb runs the auth0 sign-in flow at the edge; every forwarded request carries
|
|
4
|
+
x-amzn-oidc-data, an ES256 jwt signed by the alb. the regional key endpoint serves
|
|
5
|
+
signing keys for every alb in the region across all aws accounts, so a validly-signed
|
|
6
|
+
token alone doesn't prove it came from our alb (sg misconfig, vpc pivot, ssrf) - pinning
|
|
7
|
+
the header's signer claim to dashboard.expected_alb_arn is what makes it trustworthy.
|
|
8
|
+
the app also enforces the email domain because auth0 cannot restrict it at the alb.
|
|
9
|
+
|
|
10
|
+
read access: any account under dashboard.allowed_email_domain.
|
|
11
|
+
write access: emails listed in dashboard.admin_emails (config.yaml).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import base64
|
|
15
|
+
import binascii
|
|
16
|
+
import json
|
|
17
|
+
import time
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
|
|
20
|
+
import jwt
|
|
21
|
+
from fastapi import Request
|
|
22
|
+
|
|
23
|
+
from taskboy import settings
|
|
24
|
+
from taskboy.config import DashboardConfig
|
|
25
|
+
|
|
26
|
+
OIDC_DATA_HEADER = "x-amzn-oidc-data"
|
|
27
|
+
CSRF_HEADER = "x-harness-dashboard" # custom header on mutating requests: cross-site forms can't set it, cross-site fetch fails preflight
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class NotAuthenticated(Exception):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class NotAuthorized(Exception):
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class Viewer:
|
|
40
|
+
email: str
|
|
41
|
+
admin: bool
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _b64url_decode(segment: str) -> bytes:
|
|
45
|
+
return base64.urlsafe_b64decode(segment + "=" * (-len(segment) % 4))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def _public_key(request: Request, kid: str) -> str:
|
|
49
|
+
"""alb signing keys are fetched per key id and cached on the app; they rotate rarely."""
|
|
50
|
+
cache: dict[str, str] = request.app.state.alb_key_cache
|
|
51
|
+
if kid not in cache:
|
|
52
|
+
import aiohttp
|
|
53
|
+
|
|
54
|
+
url = f"https://public-keys.auth.elb.{settings.REGION}.amazonaws.com/{kid}"
|
|
55
|
+
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session:
|
|
56
|
+
async with session.get(url) as response:
|
|
57
|
+
response.raise_for_status()
|
|
58
|
+
cache[kid] = await response.text()
|
|
59
|
+
return cache[kid]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def viewer_from_request(request: Request) -> Viewer:
|
|
63
|
+
config: DashboardConfig = request.app.state.config.dashboard
|
|
64
|
+
token = request.headers.get(OIDC_DATA_HEADER, "").strip()
|
|
65
|
+
if not token:
|
|
66
|
+
# local dev runs without an alb; a configured dev identity stands in
|
|
67
|
+
if settings.ENVIRONMENT == "local" and config.dev_user_email:
|
|
68
|
+
return _authorize(config, config.dev_user_email)
|
|
69
|
+
raise NotAuthenticated("no identity header")
|
|
70
|
+
try:
|
|
71
|
+
header = json.loads(_b64url_decode(token.split(".")[0]))
|
|
72
|
+
kid = header["kid"]
|
|
73
|
+
# the regional key endpoint serves every alb in the region, so pin the signer
|
|
74
|
+
if header.get("signer") != config.expected_alb_arn:
|
|
75
|
+
raise NotAuthenticated("identity header signer does not match the expected alb")
|
|
76
|
+
key = await _public_key(request, kid)
|
|
77
|
+
claims = jwt.decode(token, key, algorithms=["ES256"])
|
|
78
|
+
except (jwt.PyJWTError, KeyError, ValueError, IndexError, binascii.Error, json.JSONDecodeError) as e:
|
|
79
|
+
raise NotAuthenticated(f"identity header rejected: {type(e).__name__}")
|
|
80
|
+
if int(claims.get("exp", 0)) < int(time.time()) - 60:
|
|
81
|
+
raise NotAuthenticated("identity expired")
|
|
82
|
+
email = str(claims.get("email") or "").lower()
|
|
83
|
+
if not email:
|
|
84
|
+
raise NotAuthenticated("identity has no email claim")
|
|
85
|
+
email_verified = claims.get("email_verified")
|
|
86
|
+
if "email_verified" in claims and (not email_verified or str(email_verified).lower() == "false"):
|
|
87
|
+
raise NotAuthorized("email is not verified")
|
|
88
|
+
return _authorize(config, email)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _authorize(config: DashboardConfig, email: str) -> Viewer:
|
|
92
|
+
email = email.lower()
|
|
93
|
+
if not email.endswith("@" + config.allowed_email_domain):
|
|
94
|
+
raise NotAuthorized(f"{email} is outside the allowed domain")
|
|
95
|
+
return Viewer(email=email, admin=email in config.admin_emails)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def require_viewer(request: Request) -> Viewer:
|
|
99
|
+
return await viewer_from_request(request)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def require_viewer_write(request: Request) -> Viewer:
|
|
103
|
+
# any workspace member may submit (e.g. task feedback), with the same csrf guard as admin writes
|
|
104
|
+
viewer = await viewer_from_request(request)
|
|
105
|
+
if request.method not in ("GET", "HEAD", "OPTIONS") and request.headers.get(CSRF_HEADER) != "1":
|
|
106
|
+
raise NotAuthorized("missing dashboard request header")
|
|
107
|
+
return viewer
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def require_admin(request: Request) -> Viewer:
|
|
111
|
+
viewer = await viewer_from_request(request)
|
|
112
|
+
if not viewer.admin:
|
|
113
|
+
request.app.state.store.add_admin_event(viewer.email, "authorize", request.url.path, "denied", {"reason": "not in dashboard.admin_emails"})
|
|
114
|
+
raise NotAuthorized("management actions require an email listed in dashboard.admin_emails")
|
|
115
|
+
if request.method not in ("GET", "HEAD", "OPTIONS") and request.headers.get(CSRF_HEADER) != "1":
|
|
116
|
+
raise NotAuthorized("missing dashboard request header")
|
|
117
|
+
return viewer
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""the management edit engine: resolve targets, validate, diff, and write atomically.
|
|
2
|
+
|
|
3
|
+
each editable kind maps to one live file (what the service reads) and one repo path (what
|
|
4
|
+
gitops commits). validation runs the same loader the service uses, so a save that
|
|
5
|
+
passes here cannot brick the service.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import difflib
|
|
9
|
+
import hashlib
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
import stat
|
|
13
|
+
import tempfile
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
|
|
18
|
+
from taskboy import personality, settings, skills, started_messages
|
|
19
|
+
from taskboy.config import KNOWN_SERVICES, Config, load_config
|
|
20
|
+
from taskboy.dashboard.render import SECRET_KEY
|
|
21
|
+
from taskboy.redact import redactor
|
|
22
|
+
|
|
23
|
+
EDITABLE_KINDS = ("config", "service", "personality", "reviewer_personality", "help", "started", "skill", "conventions")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class EditorError(Exception):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def content_hash(content: str) -> str:
|
|
31
|
+
return hashlib.sha256(content.encode()).hexdigest()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def target_for(config: Config, kind: str, name: str | None) -> tuple[Path, str, str]:
|
|
35
|
+
"""returns (live_path, repo_path, title). repo layout keeps config files under config/ and skills under skills/."""
|
|
36
|
+
if kind == "config":
|
|
37
|
+
return Path(settings.CONFIG_PATH), "config/config.yaml", "System configuration"
|
|
38
|
+
if kind == "service" and name in KNOWN_SERVICES:
|
|
39
|
+
live = Path(settings.CONFIG_PATH).parent / "services" / f"{name}.yaml"
|
|
40
|
+
if live.is_file(): # legacy configs keep service sections inline in config.yaml and get no per-service editor
|
|
41
|
+
return live, f"config/services/{name}.yaml", f"{name} service"
|
|
42
|
+
if kind == "personality" and config.personality_path:
|
|
43
|
+
return Path(config.personality_path), f"config/{Path(config.personality_path).name}", "Personality"
|
|
44
|
+
if kind == "reviewer_personality" and config.reviewer.personality_path:
|
|
45
|
+
return Path(config.reviewer.personality_path), f"config/{Path(config.reviewer.personality_path).name}", "Reviewer personality"
|
|
46
|
+
if kind == "help" and config.help_path:
|
|
47
|
+
return Path(config.help_path), f"config/{Path(config.help_path).name}", "Help"
|
|
48
|
+
if kind == "started" and config.slack.task_started_messages_path:
|
|
49
|
+
return Path(config.slack.task_started_messages_path), f"config/{Path(config.slack.task_started_messages_path).name}", "Task Started messages"
|
|
50
|
+
if kind == "conventions" and config.conventions_path:
|
|
51
|
+
return Path(config.conventions_path), f"config/{Path(config.conventions_path).name}", "Engineering conventions"
|
|
52
|
+
if kind == "skill" and name and name in skills.available(settings.SKILLS_ROOT):
|
|
53
|
+
return Path(settings.SKILLS_ROOT) / name / "SKILL.md", f"skills/{name}/SKILL.md", f"Skill /{name}"
|
|
54
|
+
raise EditorError("editable target not found")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def contains_secret_submission(kind: str, content: str) -> bool:
|
|
58
|
+
if redactor.redact(content) != content:
|
|
59
|
+
return True
|
|
60
|
+
if kind not in ("config", "service"):
|
|
61
|
+
return False
|
|
62
|
+
try:
|
|
63
|
+
value = yaml.safe_load(content)
|
|
64
|
+
except yaml.YAMLError:
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
def has_secret(item: object, key: str = "") -> bool:
|
|
68
|
+
# only a non-empty string under a secret-named key is a real secret; numeric config like
|
|
69
|
+
# usage_limits.*_tokens matches the "token" name but is a count, not a credential
|
|
70
|
+
if SECRET_KEY.search(key) and isinstance(item, str) and item:
|
|
71
|
+
return True
|
|
72
|
+
if isinstance(item, dict):
|
|
73
|
+
return any(has_secret(child, str(child_key)) for child_key, child in item.items())
|
|
74
|
+
if isinstance(item, list):
|
|
75
|
+
return any(has_secret(child, key) for child in item)
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
return has_secret(value)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def validate(kind: str, name: str | None, content: str, target: Path) -> None:
|
|
82
|
+
if contains_secret_submission(kind, content):
|
|
83
|
+
raise ValueError("secret-looking values are not accepted here; use the configured secrets store")
|
|
84
|
+
if kind == "config":
|
|
85
|
+
handle, temp_name = tempfile.mkstemp(prefix=".dashboard-validate-", suffix=".yaml", dir=target.parent)
|
|
86
|
+
os.close(handle)
|
|
87
|
+
try:
|
|
88
|
+
Path(temp_name).write_text(content)
|
|
89
|
+
load_config(temp_name)
|
|
90
|
+
finally:
|
|
91
|
+
Path(temp_name).unlink(missing_ok=True)
|
|
92
|
+
elif kind == "service" and name:
|
|
93
|
+
# a service file only validates in context, so run the full loader over a scratch copy of the config dir
|
|
94
|
+
config_path = Path(settings.CONFIG_PATH)
|
|
95
|
+
with tempfile.TemporaryDirectory() as root:
|
|
96
|
+
scratch = Path(root) / "config"
|
|
97
|
+
shutil.copytree(config_path.parent, scratch)
|
|
98
|
+
(scratch / "services").mkdir(exist_ok=True)
|
|
99
|
+
(scratch / "services" / f"{name}.yaml").write_text(content)
|
|
100
|
+
load_config(str(scratch / config_path.name))
|
|
101
|
+
elif kind in ("personality", "reviewer_personality", "help"):
|
|
102
|
+
handle, temp_name = tempfile.mkstemp(prefix=".dashboard-validate-", suffix=".md", dir=target.parent)
|
|
103
|
+
os.close(handle)
|
|
104
|
+
try:
|
|
105
|
+
Path(temp_name).write_text(content)
|
|
106
|
+
if personality.load(temp_name) is None:
|
|
107
|
+
raise ValueError(f"{kind} must contain non-whitespace text")
|
|
108
|
+
finally:
|
|
109
|
+
Path(temp_name).unlink(missing_ok=True)
|
|
110
|
+
elif kind == "started":
|
|
111
|
+
started_messages.validate_content(content)
|
|
112
|
+
elif kind == "conventions":
|
|
113
|
+
if not content.strip():
|
|
114
|
+
raise ValueError("conventions must contain non-whitespace text")
|
|
115
|
+
elif kind == "skill" and name:
|
|
116
|
+
with tempfile.TemporaryDirectory() as root:
|
|
117
|
+
path = Path(root) / name / "SKILL.md"
|
|
118
|
+
path.parent.mkdir()
|
|
119
|
+
path.write_text(content)
|
|
120
|
+
skills.load(root, name)
|
|
121
|
+
else:
|
|
122
|
+
raise ValueError("unsupported editor")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def unified_diff(previous: str, content: str, title: str) -> str:
|
|
126
|
+
return "".join(difflib.unified_diff(previous.splitlines(keepends=True), content.splitlines(keepends=True), fromfile=f"{title} (current)", tofile=f"{title} (proposed)"))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def atomic_write(path: Path, content: str) -> None:
|
|
130
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
131
|
+
existing_mode = stat.S_IMODE(path.stat().st_mode) if path.exists() else 0o640
|
|
132
|
+
handle, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
133
|
+
os.close(handle)
|
|
134
|
+
temp_path = Path(temp_name)
|
|
135
|
+
try:
|
|
136
|
+
temp_path.write_text(content)
|
|
137
|
+
temp_path.chmod(existing_mode)
|
|
138
|
+
os.replace(temp_path, path)
|
|
139
|
+
finally:
|
|
140
|
+
temp_path.unlink(missing_ok=True)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""auto-commit dashboard edits back to the repo via the github contents api.
|
|
2
|
+
|
|
3
|
+
edits apply to the live host files immediately; this pushes the same change to git so
|
|
4
|
+
the repo stays the source of truth and the next deploy re-ships what is already live.
|
|
5
|
+
dashboard commits go straight to main; the agent's app tokens are for task sessions, which
|
|
6
|
+
are blocked from pushing to protected branches, so the dashboard uses its own contents-only pat.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("taskboy.dashboard")
|
|
14
|
+
|
|
15
|
+
API_BASE = "https://api.github.com"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class GitOpsError(Exception):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def _request(method: str, url: str, token: str, payload: dict | None = None) -> tuple[int, dict]:
|
|
23
|
+
"""the network seam — patched in unit tests."""
|
|
24
|
+
import aiohttp
|
|
25
|
+
|
|
26
|
+
headers = {"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28"}
|
|
27
|
+
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=20)) as session:
|
|
28
|
+
async with session.request(method, url, headers=headers, data=json.dumps(payload) if payload is not None else None) as response:
|
|
29
|
+
body = await response.text()
|
|
30
|
+
try:
|
|
31
|
+
data = json.loads(body) if body else {}
|
|
32
|
+
except json.JSONDecodeError:
|
|
33
|
+
data = {}
|
|
34
|
+
return response.status, data
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def commit_file(token: str, repo: str, branch: str, repo_path: str, content: str, message: str, actor: str, committer_name: str = "Dashboard", committer_email: str = "") -> dict:
|
|
38
|
+
"""create or update one file on the branch; returns {'commit_sha', 'html_url'}."""
|
|
39
|
+
url = f"{API_BASE}/repos/{repo}/contents/{repo_path}"
|
|
40
|
+
status, existing = await _request("GET", f"{url}?ref={branch}", token)
|
|
41
|
+
if status == 200 and existing.get("content") is not None:
|
|
42
|
+
current = base64.b64decode(existing["content"]).decode()
|
|
43
|
+
if current == content:
|
|
44
|
+
return {"commit_sha": "", "html_url": "", "unchanged": True}
|
|
45
|
+
if status not in (200, 404):
|
|
46
|
+
raise GitOpsError(f"github read of {repo_path} failed with status {status}")
|
|
47
|
+
payload = {
|
|
48
|
+
"message": message,
|
|
49
|
+
"content": base64.b64encode(content.encode()).decode(),
|
|
50
|
+
"branch": branch,
|
|
51
|
+
"committer": {"name": committer_name, "email": committer_email},
|
|
52
|
+
}
|
|
53
|
+
if status == 200 and existing.get("sha"):
|
|
54
|
+
payload["sha"] = existing["sha"]
|
|
55
|
+
status, data = await _request("PUT", url, token, payload)
|
|
56
|
+
if status not in (200, 201):
|
|
57
|
+
raise GitOpsError(f"github commit of {repo_path} failed with status {status}: {str(data.get('message') or '')[:200]}")
|
|
58
|
+
commit = data.get("commit") or {}
|
|
59
|
+
logger.info("committed %s to %s@%s (%s, by %s)", repo_path, repo, branch, commit.get("sha", ""), actor)
|
|
60
|
+
return {"commit_sha": commit.get("sha", ""), "html_url": commit.get("html_url", ""), "unchanged": False}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""redacted presentation helpers: everything leaving the api passes through the shared redactor."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import asdict, is_dataclass
|
|
6
|
+
from typing import Any, cast
|
|
7
|
+
|
|
8
|
+
from taskboy.redact import redactor
|
|
9
|
+
|
|
10
|
+
SECRET_KEY = re.compile(r"(?i)(secret|password|passwd|token|credential|private[_-]?key|api[_-]?key)")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def safe_text(value: object) -> str:
|
|
14
|
+
if value is None:
|
|
15
|
+
return ""
|
|
16
|
+
if not isinstance(value, str):
|
|
17
|
+
value = json.dumps(value, default=str, sort_keys=True)
|
|
18
|
+
return redactor.redact(value)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def bounded_text(value: object, limit: int = 10000) -> str:
|
|
22
|
+
text = safe_text(value)
|
|
23
|
+
return text if len(text) <= limit else text[:limit] + "\n[… truncated in dashboard …]"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def redact_value(value: object, key: str = "") -> object:
|
|
27
|
+
# mask only string values under a secret-named key; numeric config (e.g. usage_limits.*_tokens) is not a secret
|
|
28
|
+
if SECRET_KEY.search(key) and isinstance(value, str):
|
|
29
|
+
return "••••••••" if value else "not configured"
|
|
30
|
+
if is_dataclass(value):
|
|
31
|
+
value = asdict(cast(Any, value))
|
|
32
|
+
if isinstance(value, dict):
|
|
33
|
+
return {str(item_key): redact_value(item_value, str(item_key)) for item_key, item_value in value.items()}
|
|
34
|
+
if isinstance(value, (list, tuple)):
|
|
35
|
+
return [redact_value(item) for item in value]
|
|
36
|
+
return safe_text(value) if isinstance(value, str) else value
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def redact_bounded_value(value: object, key: str = "") -> object:
|
|
40
|
+
if SECRET_KEY.search(key) and isinstance(value, str):
|
|
41
|
+
return "••••••••" if value else "not configured"
|
|
42
|
+
if is_dataclass(value):
|
|
43
|
+
value = asdict(cast(Any, value))
|
|
44
|
+
if isinstance(value, dict):
|
|
45
|
+
return {str(item_key): redact_bounded_value(item_value, str(item_key)) for item_key, item_value in value.items()}
|
|
46
|
+
if isinstance(value, (list, tuple)):
|
|
47
|
+
return [redact_bounded_value(item) for item in value]
|
|
48
|
+
return bounded_text(value) if isinstance(value, str) else value
|
taskboy/debug_feed.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""best-effort, token-free Slack debug threads built only from stored text."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import time
|
|
6
|
+
import traceback
|
|
7
|
+
|
|
8
|
+
from taskboy.models import Task
|
|
9
|
+
from taskboy.mrkdwn import to_mrkdwn
|
|
10
|
+
from taskboy.redact import redactor
|
|
11
|
+
from taskboy.store import Store
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("taskboy.debug_feed")
|
|
14
|
+
|
|
15
|
+
SNIPPET_THRESHOLD = 3500
|
|
16
|
+
|
|
17
|
+
# repeats of the same (component, message) are suppressed this long; in-memory, resets on restart
|
|
18
|
+
SYSTEM_ERROR_COOLDOWN_SECONDS = 900
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _now() -> float:
|
|
22
|
+
return time.monotonic()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class DebugFeed:
|
|
26
|
+
def __init__(self, client, store: Store, channel_id: str, dashboard_url: str = ""):
|
|
27
|
+
self.client = client
|
|
28
|
+
self.store = store
|
|
29
|
+
self.channel_id = channel_id
|
|
30
|
+
self.dashboard_url = dashboard_url.rstrip("/")
|
|
31
|
+
# (component, message) -> last_posted_monotonic
|
|
32
|
+
self._system_error_state: dict[tuple[str, str], float] = {}
|
|
33
|
+
|
|
34
|
+
def _record_failure(self, operation: str, error: Exception, task_id: str | None = None) -> None:
|
|
35
|
+
logger.warning("debug feed %s failed", operation, exc_info=True)
|
|
36
|
+
try:
|
|
37
|
+
self.store.add_error("debug_feed", type(error).__name__, str(error), task_id=task_id, traceback=traceback.format_exc(), context={"operation": operation})
|
|
38
|
+
except Exception:
|
|
39
|
+
logger.exception("could not record debug feed failure")
|
|
40
|
+
|
|
41
|
+
async def open_thread(self, *, user_id: str, channel_id: str, request_text: str, user_profile: dict | None = None) -> tuple[str, str] | None:
|
|
42
|
+
profile = user_profile or {}
|
|
43
|
+
name = profile.get("real_name") or profile.get("display_name") or profile.get("username")
|
|
44
|
+
requester = f"<@{user_id}>" + (f" ({name})" if name else "")
|
|
45
|
+
source_channel = f"<#{channel_id}>" if channel_id else "n/a"
|
|
46
|
+
text = f"Requester: {requester}\nSource channel: {source_channel}\nRequest:\n{request_text}"
|
|
47
|
+
try:
|
|
48
|
+
response = await self.client.chat_postMessage(channel=self.channel_id, text=redactor.redact(to_mrkdwn(text)), unfurl_links=False, unfurl_media=False)
|
|
49
|
+
thread_ts = str(response.get("ts") or "")
|
|
50
|
+
if not thread_ts:
|
|
51
|
+
raise RuntimeError("debug root post returned no timestamp")
|
|
52
|
+
link = await self.client.chat_getPermalink(channel=self.channel_id, message_ts=thread_ts)
|
|
53
|
+
permalink = str(link.get("permalink") or "")
|
|
54
|
+
return thread_ts, permalink
|
|
55
|
+
except Exception as e:
|
|
56
|
+
self._record_failure("open_thread", e)
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
async def post(self, thread_ts: str | None, text: str, task_id: str | None = None) -> None:
|
|
60
|
+
if not thread_ts:
|
|
61
|
+
return
|
|
62
|
+
try:
|
|
63
|
+
await self.client.chat_postMessage(channel=self.channel_id, thread_ts=thread_ts, text=redactor.redact(to_mrkdwn(text))[:39000], unfurl_links=False, unfurl_media=False)
|
|
64
|
+
except Exception as e:
|
|
65
|
+
self._record_failure("post", e, task_id)
|
|
66
|
+
|
|
67
|
+
async def post_file(self, thread_ts: str | None, filename: str, title: str, content: str, initial_comment: str = "", task_id: str | None = None) -> None:
|
|
68
|
+
if not thread_ts:
|
|
69
|
+
return
|
|
70
|
+
try:
|
|
71
|
+
await self.client.files_upload_v2(
|
|
72
|
+
channel=self.channel_id,
|
|
73
|
+
thread_ts=thread_ts,
|
|
74
|
+
filename=filename,
|
|
75
|
+
title=title,
|
|
76
|
+
content=redactor.redact(content),
|
|
77
|
+
initial_comment=redactor.redact(to_mrkdwn(initial_comment)),
|
|
78
|
+
)
|
|
79
|
+
except Exception as e:
|
|
80
|
+
self._record_failure("post_file", e, task_id)
|
|
81
|
+
await self.post(thread_ts, f"{initial_comment}\n\n{content[:SNIPPET_THRESHOLD]}", task_id)
|
|
82
|
+
|
|
83
|
+
async def classified(self, task: Task) -> None:
|
|
84
|
+
await self.post(task.debug_thread_ts, f"Classification: {task.classification_json or '{}'}\nRouting: model={task.model_alias or 'n/a'} ({task.model_id or 'n/a'}), profile={task.profile or 'n/a'}\nRationale: {task.routing_rationale or 'n/a'}", task.task_id)
|
|
85
|
+
|
|
86
|
+
async def started(self, task: Task) -> None:
|
|
87
|
+
await self.post(task.debug_thread_ts, f"Started task `{task.task_id}`\nModel: {task.model_alias or 'n/a'} ({task.model_id or 'n/a'})\nProfile: {task.profile or 'n/a'}\nAttempt: {task.attempt}", task.task_id)
|
|
88
|
+
|
|
89
|
+
async def progress(self, task: Task, message: str) -> None:
|
|
90
|
+
await self.post(task.debug_thread_ts, f"Progress: {message}", task.task_id)
|
|
91
|
+
|
|
92
|
+
async def prompt_file(self, task: Task, prompt_text: str) -> None:
|
|
93
|
+
await self.post_file(task.debug_thread_ts, f"{task.task_id}-prompt.md", f"Task {task.task_id} sub-agent prompt", prompt_text, "Full sub-agent prompt attached.", task.task_id)
|
|
94
|
+
|
|
95
|
+
async def completed(self, task: Task) -> None:
|
|
96
|
+
report = task.result_summary or ""
|
|
97
|
+
if len(report) > SNIPPET_THRESHOLD:
|
|
98
|
+
await self.post_file(task.debug_thread_ts, f"{task.task_id}-report.md", f"Task {task.task_id} Final Report", report, "Task completed. Full Final Report attached.", task.task_id)
|
|
99
|
+
else:
|
|
100
|
+
await self.post(task.debug_thread_ts, f"Task completed.\n\n{report}", task.task_id)
|
|
101
|
+
footer = self._metrics(task)
|
|
102
|
+
if self.dashboard_url:
|
|
103
|
+
footer += f"\n[Rate this task]({self.dashboard_url}/tasks/{task.task_id}/feedback)"
|
|
104
|
+
await self.post(task.debug_thread_ts, footer, task.task_id)
|
|
105
|
+
|
|
106
|
+
async def failed(self, task: Task, error: str) -> None:
|
|
107
|
+
await self.post(task.debug_thread_ts, f"Task failed: {error}\n\n{self._metrics(task)}", task.task_id)
|
|
108
|
+
|
|
109
|
+
async def blocked(self, task: Task) -> None:
|
|
110
|
+
await self.post(task.debug_thread_ts, f"Task blocked: {task.blocked_reason or ''}\n\n{self._metrics(task)}", task.task_id)
|
|
111
|
+
|
|
112
|
+
async def questions(self, task: Task, questions: str) -> None:
|
|
113
|
+
await self.post(task.debug_thread_ts, f"Task waiting on requester answers:\n{questions}\n\n{self._metrics(task)}", task.task_id)
|
|
114
|
+
|
|
115
|
+
async def recovered(self, task: Task) -> None:
|
|
116
|
+
await self.post(task.debug_thread_ts, f"Task recovered and requeued (attempt {task.attempt}).", task.task_id)
|
|
117
|
+
|
|
118
|
+
async def refused(self, task: Task, reason: str) -> None:
|
|
119
|
+
await self.post(task.debug_thread_ts, f"Task refused: {reason}", task.task_id)
|
|
120
|
+
|
|
121
|
+
async def quick_answer(self, task: Task, latency: float | None = None, usage: dict | None = None) -> None:
|
|
122
|
+
if latency is None:
|
|
123
|
+
for event in self.store.events_for(task.task_id):
|
|
124
|
+
if event["kind"] != "quick_answer":
|
|
125
|
+
continue
|
|
126
|
+
try:
|
|
127
|
+
latency = float(json.loads(event["detail_json"]).get("latency_s") or 0)
|
|
128
|
+
except (TypeError, ValueError, json.JSONDecodeError):
|
|
129
|
+
latency = 0.0
|
|
130
|
+
detail = f"Quick answer selected.\nLatency: {latency if latency is not None else 0:.3f}s\nAnswer:\n{task.reply or task.result_summary or ''}"
|
|
131
|
+
await self.post(task.debug_thread_ts, detail, task.task_id)
|
|
132
|
+
await self.post(task.debug_thread_ts, self._metrics(task), task.task_id)
|
|
133
|
+
|
|
134
|
+
async def intake_refusal(self, thread_ts: str | None, reason: str) -> None:
|
|
135
|
+
await self.post(thread_ts, f"Intake refused: {reason}")
|
|
136
|
+
|
|
137
|
+
async def system_error(self, component: str, message: str) -> bool:
|
|
138
|
+
"""returns False when the repeat was suppressed, so callers don't mark it as delivered."""
|
|
139
|
+
key = (component, message)
|
|
140
|
+
now = _now()
|
|
141
|
+
last_posted = self._system_error_state.get(key)
|
|
142
|
+
if last_posted is not None and now - last_posted < SYSTEM_ERROR_COOLDOWN_SECONDS:
|
|
143
|
+
return False
|
|
144
|
+
# evict entries whose cooldown has fully elapsed, to keep the dict from growing unbounded
|
|
145
|
+
self._system_error_state = {k: v for k, v in self._system_error_state.items() if now - v < SYSTEM_ERROR_COOLDOWN_SECONDS}
|
|
146
|
+
# claim the window before the post so concurrent calls suppress instead of all posting
|
|
147
|
+
self._system_error_state[key] = now
|
|
148
|
+
try:
|
|
149
|
+
await self.client.chat_postMessage(channel=self.channel_id, text=redactor.redact(to_mrkdwn(f"System error ({component}): {message}")), unfurl_links=False, unfurl_media=False)
|
|
150
|
+
except Exception as e:
|
|
151
|
+
self._record_failure("system_error", e)
|
|
152
|
+
# the claim didn't hold, and restoring an expired last_posted is the same as having no entry
|
|
153
|
+
self._system_error_state.pop(key, None)
|
|
154
|
+
return False
|
|
155
|
+
return True
|
|
156
|
+
|
|
157
|
+
def _metrics(self, task: Task) -> str:
|
|
158
|
+
usage_lines = []
|
|
159
|
+
for row in self.store.usage_for(task.task_id):
|
|
160
|
+
usage_lines.append(f"- {row['source']} ({row['model']}): input={row['input_tokens'] or 0}, output={row['output_tokens'] or 0}, cache_read={row['cache_read_tokens'] or 0}, cache_write={row['cache_write_tokens'] or 0}, cost=${float(row['cost_usd'] or 0):.6f}")
|
|
161
|
+
timing_lines = []
|
|
162
|
+
for event in self.store.events_for(task.task_id):
|
|
163
|
+
if event["kind"] != "timing":
|
|
164
|
+
continue
|
|
165
|
+
try:
|
|
166
|
+
detail = json.loads(event["detail_json"])
|
|
167
|
+
except (TypeError, json.JSONDecodeError):
|
|
168
|
+
continue
|
|
169
|
+
timing_lines.append(f"- {detail.get('stage', 'unknown')}: {float(detail.get('seconds') or 0):.3f}s")
|
|
170
|
+
return "Usage:\n" + ("\n".join(usage_lines) or "- none recorded") + "\nTiming:\n" + ("\n".join(timing_lines) or "- none recorded")
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# /etc/taskboy/env — non-secret deployment settings (EnvironmentFile for the systemd unit).
|
|
2
|
+
# secrets normally come from aws secrets manager (TASKBOY_SECRETS_{ENV}) via the instance role.
|
|
3
|
+
# for a no-aws single-box install you can instead put the secret env vars here directly
|
|
4
|
+
# (SLACK_BOT_TOKEN, GITHUB_APP_ID, REVIEWER_GITHUB_APP_ID, ... — see taskboy/secrets.py);
|
|
5
|
+
# this file is root-owned mode 640 so only root and the service user can read it.
|
|
6
|
+
ENVIRONMENT=staging
|
|
7
|
+
TASKBOY_DB_PATH=/var/lib/taskboy/taskboy.db
|
|
8
|
+
TASKBOY_CONFIG_PATH=/etc/taskboy/config.yaml
|
|
9
|
+
TASKBOY_WORKSPACES_ROOT=/var/lib/taskboy/workspaces
|
|
10
|
+
TASKBOY_REPOS_ROOT=/var/lib/taskboy/repos
|
|
11
|
+
TASKBOY_MEMORY_ROOT=/var/lib/taskboy/memory
|
|
12
|
+
TASKBOY_SKILLS_ROOT=/opt/taskboy/skills
|
|
13
|
+
TASKBOY_BROKER_SOCKET=/run/taskboy/broker.sock
|
|
14
|
+
TASKBOY_REVIEWER_BROKER_SOCKET=/run/taskboy/broker-reviewer.sock
|
|
15
|
+
# the dashboard ui and git credential helper ship inside the installed package; override
|
|
16
|
+
# TASKBOY_UI_DIST / TASKBOY_GIT_CRED_HELPER here only to point elsewhere
|
|
17
|
+
AWS_REGION=us-east-1
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""git credential helper: fetches a fresh installation token from the taskboy broker at use-time.
|
|
3
|
+
|
|
4
|
+
wired into each task session via GIT_CONFIG_* env vars — tokens never touch disk, git config,
|
|
5
|
+
or argv. stdlib only; runs under the task's (non-privileged) user.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import socket
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main() -> None:
|
|
15
|
+
if len(sys.argv) < 2 or sys.argv[1] != "get":
|
|
16
|
+
return # store/erase are no-ops
|
|
17
|
+
sys.stdin.read() # drain the credential description git sends
|
|
18
|
+
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
|
|
19
|
+
sock.connect(os.environ["TASKBOY_BROKER_SOCKET"])
|
|
20
|
+
sock.sendall((json.dumps({"op": "git-credentials", "nonce": os.environ["TASKBOY_TASK_NONCE"]}) + "\n").encode())
|
|
21
|
+
data = b""
|
|
22
|
+
while not data.endswith(b"\n"):
|
|
23
|
+
chunk = sock.recv(4096)
|
|
24
|
+
if not chunk:
|
|
25
|
+
break
|
|
26
|
+
data += chunk
|
|
27
|
+
response = json.loads(data)
|
|
28
|
+
if "error" in response:
|
|
29
|
+
print(f"taskboy credential helper: {response['error']}", file=sys.stderr)
|
|
30
|
+
sys.exit(1)
|
|
31
|
+
print(f"username={response['username']}")
|
|
32
|
+
print(f"password={response['password']}")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
if __name__ == "__main__":
|
|
36
|
+
main()
|