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.
Files changed (95) hide show
  1. taskboy/__init__.py +0 -0
  2. taskboy/adapters/__init__.py +0 -0
  3. taskboy/adapters/_util.py +30 -0
  4. taskboy/adapters/aws_read.py +107 -0
  5. taskboy/adapters/confluence.py +88 -0
  6. taskboy/adapters/github_api.py +564 -0
  7. taskboy/adapters/issues.py +435 -0
  8. taskboy/adapters/jira.py +263 -0
  9. taskboy/adapters/sentry.py +82 -0
  10. taskboy/adapters/slack_history.py +204 -0
  11. taskboy/assets.py +27 -0
  12. taskboy/audit.py +78 -0
  13. taskboy/broker.py +266 -0
  14. taskboy/classifier.py +219 -0
  15. taskboy/cli.py +161 -0
  16. taskboy/config.py +539 -0
  17. taskboy/dashboard/__init__.py +5 -0
  18. taskboy/dashboard/api.py +963 -0
  19. taskboy/dashboard/app.py +78 -0
  20. taskboy/dashboard/auth.py +117 -0
  21. taskboy/dashboard/editors.py +140 -0
  22. taskboy/dashboard/gitops.py +60 -0
  23. taskboy/dashboard/render.py +48 -0
  24. taskboy/debug_feed.py +170 -0
  25. taskboy/deploy/env.example +17 -0
  26. taskboy/deploy/git-cred-helper.py +36 -0
  27. taskboy/deploy/install.sh +101 -0
  28. taskboy/deploy/remote-update.sh +52 -0
  29. taskboy/deploy/taskboy-restart.path +10 -0
  30. taskboy/deploy/taskboy-restart.service +8 -0
  31. taskboy/deploy/taskboy.service +22 -0
  32. taskboy/hooks.py +208 -0
  33. taskboy/issue_runs.py +203 -0
  34. taskboy/llm.py +125 -0
  35. taskboy/main.py +269 -0
  36. taskboy/memory.py +59 -0
  37. taskboy/models.py +103 -0
  38. taskboy/mrkdwn.py +19 -0
  39. taskboy/notify.py +41 -0
  40. taskboy/orchestrator.py +401 -0
  41. taskboy/personality.py +17 -0
  42. taskboy/prompts.py +288 -0
  43. taskboy/quick.py +234 -0
  44. taskboy/redact.py +49 -0
  45. taskboy/repocache.py +117 -0
  46. taskboy/review_requests.py +388 -0
  47. taskboy/router.py +146 -0
  48. taskboy/runner.py +581 -0
  49. taskboy/scheduler.py +301 -0
  50. taskboy/secrets.py +83 -0
  51. taskboy/settings.py +19 -0
  52. taskboy/setup_checks.py +184 -0
  53. taskboy/setup_wizard.py +699 -0
  54. taskboy/skills.py +95 -0
  55. taskboy/slack.py +666 -0
  56. taskboy/slack_users.py +32 -0
  57. taskboy/started_messages.py +33 -0
  58. taskboy/store.py +1393 -0
  59. taskboy/task_actions.py +86 -0
  60. taskboy/templates/config.example.yaml +149 -0
  61. taskboy/templates/conventions.md +60 -0
  62. taskboy/templates/help.example.md +29 -0
  63. taskboy/templates/personality_agent.example.md +5 -0
  64. taskboy/templates/personality_reviewer.example.md +5 -0
  65. taskboy/templates/services/aws.yaml +7 -0
  66. taskboy/templates/services/confluence.yaml +4 -0
  67. taskboy/templates/services/github.yaml +14 -0
  68. taskboy/templates/services/jira.yaml +6 -0
  69. taskboy/templates/services/sentry.yaml +4 -0
  70. taskboy/templates/services/slack.yaml +9 -0
  71. taskboy/templates/skills/README.md +33 -0
  72. taskboy/templates/skills/discoverissues/SKILL.md +46 -0
  73. taskboy/templates/skills/implementapprovedissues/SKILL.md +41 -0
  74. taskboy/templates/skills/jira2pr/SKILL.md +66 -0
  75. taskboy/templates/skills/monitor/SKILL.md +32 -0
  76. taskboy/templates/skills/monitornew/SKILL.md +27 -0
  77. taskboy/templates/skills/refineissue/SKILL.md +35 -0
  78. taskboy/templates/skills/release/SKILL.md +36 -0
  79. taskboy/templates/skills/review/SKILL.md +89 -0
  80. taskboy/templates/skills/reviewandmonitor/SKILL.md +22 -0
  81. taskboy/templates/skills/reviews/SKILL.md +26 -0
  82. taskboy/templates/skills/slack2jira/SKILL.md +47 -0
  83. taskboy/templates/skills/slack2pr/SKILL.md +64 -0
  84. taskboy/templates/skills/spec2pr/SKILL.md +35 -0
  85. taskboy/templates/slack_app_manifest.yaml +48 -0
  86. taskboy/templates/task_started_messages.yaml +14 -0
  87. taskboy/ui_dist/assets/index-DmYxR9Qy.css +1 -0
  88. taskboy/ui_dist/assets/index-LDDO4iT2.js +15 -0
  89. taskboy/ui_dist/index.html +15 -0
  90. taskboy/workspace.py +86 -0
  91. taskboy-0.1.1.dist-info/METADATA +120 -0
  92. taskboy-0.1.1.dist-info/RECORD +95 -0
  93. taskboy-0.1.1.dist-info/WHEEL +5 -0
  94. taskboy-0.1.1.dist-info/entry_points.txt +2 -0
  95. taskboy-0.1.1.dist-info/top_level.txt +1 -0
@@ -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()