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/audit.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""audit trail tamper-evidence and off-host shipping (§10).
|
|
2
|
+
|
|
3
|
+
every task_event carries a hash chained to the previous event (computed in store._insert_event);
|
|
4
|
+
verify_chain recomputes it. ship_once exports new events as jsonl to the object-locked s3 bucket,
|
|
5
|
+
so even a compromised host cannot rewrite history that has already shipped.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
from taskboy.store import Store, admin_event_hash, event_hash
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("taskboy.audit")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def verify_chain(store: Store) -> tuple[bool, int]:
|
|
18
|
+
"""recompute the hash chain; returns (intact, events_checked). rows predating the hash column are skipped."""
|
|
19
|
+
prev = ""
|
|
20
|
+
checked = 0
|
|
21
|
+
for row in store.events_after(0, limit=1_000_000):
|
|
22
|
+
if row["hash"] is not None:
|
|
23
|
+
expected = event_hash(prev, row["ts"], row["task_id"], row["kind"], row["tool_name"], row["detail_json"])
|
|
24
|
+
if row["hash"] != expected:
|
|
25
|
+
logger.error("audit chain broken at event id %s", row["id"])
|
|
26
|
+
return False, checked
|
|
27
|
+
checked += 1
|
|
28
|
+
prev = row["hash"] or ""
|
|
29
|
+
return True, checked
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def ship_once(store: Store, bucket: str, prefix: str = "audit") -> int:
|
|
33
|
+
"""upload events newer than the last shipped id; the meta cursor makes retries idempotent.
|
|
34
|
+
|
|
35
|
+
sqlite stays on the event-loop thread (the connection is not shareable across threads);
|
|
36
|
+
only the s3 upload runs in a worker.
|
|
37
|
+
"""
|
|
38
|
+
last_shipped = int(store.meta_get("audit_shipped_id") or 0)
|
|
39
|
+
events = store.events_after(last_shipped)
|
|
40
|
+
if not events:
|
|
41
|
+
return 0
|
|
42
|
+
key = f"{prefix}/events-{events[0]['id']:012d}-{events[-1]['id']:012d}.jsonl"
|
|
43
|
+
await asyncio.to_thread(_put, bucket, key, "\n".join(json.dumps(event) for event in events))
|
|
44
|
+
store.meta_set("audit_shipped_id", str(events[-1]["id"]))
|
|
45
|
+
logger.info("shipped %s audit events to s3://%s/%s", len(events), bucket, key)
|
|
46
|
+
return len(events)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def verify_admin_chain(store: Store) -> tuple[bool, int]:
|
|
50
|
+
prev = ""
|
|
51
|
+
checked = 0
|
|
52
|
+
for row in store.admin_events_after(0, limit=1_000_000):
|
|
53
|
+
expected = admin_event_hash(prev, row["ts"], row["actor"], row["action"], row["target"], row["outcome"], row["detail_json"])
|
|
54
|
+
if row["hash"] != expected:
|
|
55
|
+
logger.error("admin audit chain broken at event id %s", row["id"])
|
|
56
|
+
return False, checked
|
|
57
|
+
prev = row["hash"]
|
|
58
|
+
checked += 1
|
|
59
|
+
return True, checked
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def ship_admin_once(store: Store, bucket: str, prefix: str = "admin-audit") -> int:
|
|
63
|
+
last_shipped = int(store.meta_get("admin_audit_shipped_id") or 0)
|
|
64
|
+
events = store.admin_events_after(last_shipped)
|
|
65
|
+
if not events:
|
|
66
|
+
return 0
|
|
67
|
+
key = f"{prefix}/events-{events[0]['id']:012d}-{events[-1]['id']:012d}.jsonl"
|
|
68
|
+
await asyncio.to_thread(_put, bucket, key, "\n".join(json.dumps(event) for event in events))
|
|
69
|
+
store.meta_set("admin_audit_shipped_id", str(events[-1]["id"]))
|
|
70
|
+
logger.info("shipped %s admin audit events to s3://%s/%s", len(events), bucket, key)
|
|
71
|
+
return len(events)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _put(bucket: str, key: str, body: str) -> None:
|
|
75
|
+
"""the s3 seam — patched in unit tests."""
|
|
76
|
+
import boto3
|
|
77
|
+
|
|
78
|
+
boto3.client("s3").put_object(Bucket=bucket, Key=key, Body=body.encode())
|
taskboy/broker.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""credential broker: short-lived, down-scoped github app installation tokens per task (GIT-014, TOL-007, EC2-009).
|
|
2
|
+
|
|
3
|
+
sub-agents never hold a long-lived credential. git asks this broker at use-time through a
|
|
4
|
+
credential helper over a unix socket, authenticated by a per-task nonce; the broker mints an
|
|
5
|
+
installation token scoped to the task's profile (read-only vs write) and target repositories,
|
|
6
|
+
caches it, and re-mints when close to expiry — so github's ~1h ttl never breaks a long task.
|
|
7
|
+
task A's nonce is useless for task B, and every minted token is registered with the redactor.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import secrets as pysecrets
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import jwt
|
|
21
|
+
|
|
22
|
+
from taskboy.adapters.github_api import GitHubStatusError
|
|
23
|
+
from taskboy.models import Task
|
|
24
|
+
from taskboy.redact import redactor
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger("taskboy.broker")
|
|
27
|
+
|
|
28
|
+
GITHUB_API = "https://api.github.com"
|
|
29
|
+
REFRESH_MARGIN_SECONDS = 600 # re-mint when less than 10 minutes remain
|
|
30
|
+
|
|
31
|
+
# github app permissions requested per routing profile: allowlist, hook, and credential all agree (§8.4)
|
|
32
|
+
PROFILE_PERMISSIONS = {
|
|
33
|
+
"read_only": {"contents": "read", "metadata": "read", "pull_requests": "read"},
|
|
34
|
+
"standard": {"contents": "write", "metadata": "read", "pull_requests": "write"},
|
|
35
|
+
"deep": {"contents": "write", "metadata": "read", "pull_requests": "write"},
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class TaskGrant:
|
|
41
|
+
task_id: str
|
|
42
|
+
nonce: str
|
|
43
|
+
repositories: list[str] # short repo names (github's api takes names, not org/name)
|
|
44
|
+
permissions: dict
|
|
45
|
+
token: str | None = field(default=None, repr=False)
|
|
46
|
+
expires_at: float = 0.0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class CredentialBroker:
|
|
50
|
+
def __init__(self, app_id: str, installation_id: str, private_key: str, socket_path: str, helper_path: str):
|
|
51
|
+
self.app_id = app_id
|
|
52
|
+
self.installation_id = installation_id
|
|
53
|
+
self.private_key = private_key
|
|
54
|
+
self.socket_path = socket_path
|
|
55
|
+
self.helper_path = helper_path
|
|
56
|
+
self.grants: dict[str, TaskGrant] = {}
|
|
57
|
+
self._nonce_index: dict[str, str] = {}
|
|
58
|
+
self._server: asyncio.AbstractServer | None = None
|
|
59
|
+
self._app_slug_cache: str | None = None
|
|
60
|
+
self.accessible_repos: set[str] | None = None # short names; None = never discovered (disables the 422 fallback)
|
|
61
|
+
|
|
62
|
+
# -- task lifecycle --------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
def register_task(self, task: Task, approved_repos: list[str], granted_repos: list[str] | None = None, *, hooks_path: str) -> dict[str, str]:
|
|
65
|
+
"""returns the env vars for the task session; the future token is scoped to profile + target repos.
|
|
66
|
+
|
|
67
|
+
granted_repos are repos an operator approved mid-task; they widen the token scope beyond the task's
|
|
68
|
+
original classification so live git ops against a granted repo authenticate instead of 403ing (§8.4)."""
|
|
69
|
+
permissions = PROFILE_PERMISSIONS.get(task.profile or "", PROFILE_PERMISSIONS["read_only"])
|
|
70
|
+
targets = (json.loads(task.classification_json) if task.classification_json else {}).get("target_repos") or []
|
|
71
|
+
repos = [repo for repo in targets if repo in approved_repos]
|
|
72
|
+
for repo in granted_repos or []:
|
|
73
|
+
if repo in approved_repos and repo not in repos:
|
|
74
|
+
repos.append(repo)
|
|
75
|
+
repos = repos or list(approved_repos)
|
|
76
|
+
nonce = pysecrets.token_urlsafe(32)
|
|
77
|
+
redactor.register(nonce)
|
|
78
|
+
self.grants[task.task_id] = TaskGrant(task.task_id, nonce, [repo.split("/", 1)[-1] for repo in repos], permissions)
|
|
79
|
+
self._nonce_index[nonce] = task.task_id
|
|
80
|
+
return {
|
|
81
|
+
"TASKBOY_BROKER_SOCKET": self.socket_path,
|
|
82
|
+
"TASKBOY_TASK_NONCE": nonce,
|
|
83
|
+
"GIT_TERMINAL_PROMPT": "0",
|
|
84
|
+
"GIT_CONFIG_COUNT": "2",
|
|
85
|
+
"GIT_CONFIG_KEY_0": "credential.helper",
|
|
86
|
+
"GIT_CONFIG_VALUE_0": self.helper_path,
|
|
87
|
+
# every repo the session touches — mirror-seeded or agent-cloned — runs the workspace pre-push hook (#95)
|
|
88
|
+
"GIT_CONFIG_KEY_1": "core.hooksPath",
|
|
89
|
+
"GIT_CONFIG_VALUE_1": hooks_path,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
def release_task(self, task_id: str) -> None:
|
|
93
|
+
grant = self.grants.pop(task_id, None)
|
|
94
|
+
if grant is None:
|
|
95
|
+
return
|
|
96
|
+
self._nonce_index.pop(grant.nonce, None)
|
|
97
|
+
redactor.unregister(grant.nonce)
|
|
98
|
+
redactor.unregister(grant.token)
|
|
99
|
+
|
|
100
|
+
async def verify(self, approved_repos: list[str] | None = None) -> None:
|
|
101
|
+
"""startup probe: mint a minimal token so app-id/key/installation problems surface at boot, not mid-task.
|
|
102
|
+
|
|
103
|
+
also snapshots which of approved_repos the installation can actually see, so a stale
|
|
104
|
+
config.yaml entry gets a clear warning now instead of a raw 422 mid-task (§8.4)."""
|
|
105
|
+
data = await self._mint_token({"permissions": {"metadata": "read"}})
|
|
106
|
+
token = str(data.get("token"))
|
|
107
|
+
redactor.register(token)
|
|
108
|
+
self.accessible_repos = await self._discover_accessible_repos(token)
|
|
109
|
+
if self.accessible_repos is not None:
|
|
110
|
+
for repo in approved_repos or []:
|
|
111
|
+
if repo.split("/", 1)[-1] not in self.accessible_repos:
|
|
112
|
+
logger.warning(
|
|
113
|
+
"github.approved_repos entry %s is not accessible to this GitHub App installation — install the app on it or remove it from approved_repos",
|
|
114
|
+
repo,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
async def _discover_accessible_repos(self, token: str) -> set[str] | None:
|
|
118
|
+
"""paginated GET /installation/repositories; returns None (disabling the fallback) if listing fails."""
|
|
119
|
+
try:
|
|
120
|
+
accessible: set[str] = set()
|
|
121
|
+
page = 1
|
|
122
|
+
while page <= 10:
|
|
123
|
+
result = await self._get_github(f"/installation/repositories?per_page=100&page={page}", token)
|
|
124
|
+
entries = result.get("repositories") or []
|
|
125
|
+
accessible.update(str(entry["name"]) for entry in entries if entry.get("name"))
|
|
126
|
+
if len(entries) < 100:
|
|
127
|
+
break
|
|
128
|
+
page += 1
|
|
129
|
+
return accessible
|
|
130
|
+
except Exception as e:
|
|
131
|
+
logger.warning("failed to list installation repositories — approved_repos drift check disabled: %s", e)
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
async def read_token(self, repositories: list[str], permissions: dict | None = None) -> tuple[str, float]:
|
|
135
|
+
"""fetch-only installation token for the mirror cache (contents:read, metadata:read).
|
|
136
|
+
|
|
137
|
+
returns (token, expires_at_epoch), using github's actual expiry when present."""
|
|
138
|
+
payload: dict = {"permissions": permissions if permissions is not None else {"contents": "read", "metadata": "read"}}
|
|
139
|
+
if repositories:
|
|
140
|
+
payload["repositories"] = [repo.split("/", 1)[-1] for repo in repositories]
|
|
141
|
+
data = await self._mint_token(payload)
|
|
142
|
+
token = str(data["token"])
|
|
143
|
+
redactor.register(token)
|
|
144
|
+
expires_at = _parse_expiry(data.get("expires_at")) or time.time() + 55 * 60
|
|
145
|
+
return token, expires_at
|
|
146
|
+
|
|
147
|
+
async def app_slug(self) -> str:
|
|
148
|
+
if self._app_slug_cache is None:
|
|
149
|
+
data = await self._get_github("/app", self._app_jwt())
|
|
150
|
+
slug = str(data.get("slug") or "")
|
|
151
|
+
if not slug:
|
|
152
|
+
raise RuntimeError("github app response did not include a slug")
|
|
153
|
+
self._app_slug_cache = slug
|
|
154
|
+
return self._app_slug_cache
|
|
155
|
+
|
|
156
|
+
# -- token minting -----------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
async def credentials_for_nonce(self, nonce: str) -> dict:
|
|
159
|
+
task_id = self._nonce_index.get(nonce)
|
|
160
|
+
if task_id is None:
|
|
161
|
+
raise PermissionError("unknown task nonce")
|
|
162
|
+
return {"username": "x-access-token", "password": await self.token_for_task(task_id)}
|
|
163
|
+
|
|
164
|
+
async def token_for_task(self, task_id: str) -> str:
|
|
165
|
+
grant = self.grants.get(task_id)
|
|
166
|
+
if grant is None:
|
|
167
|
+
raise PermissionError(f"no credential grant for task {task_id}")
|
|
168
|
+
if grant.token is None or grant.expires_at - time.time() < REFRESH_MARGIN_SECONDS:
|
|
169
|
+
await self._mint(grant)
|
|
170
|
+
assert grant.token is not None
|
|
171
|
+
return grant.token
|
|
172
|
+
|
|
173
|
+
async def _mint(self, grant: TaskGrant) -> None:
|
|
174
|
+
payload: dict = {"permissions": grant.permissions}
|
|
175
|
+
if grant.repositories:
|
|
176
|
+
payload["repositories"] = grant.repositories
|
|
177
|
+
data = await self._mint_token(payload)
|
|
178
|
+
redactor.unregister(grant.token)
|
|
179
|
+
grant.token = str(data["token"])
|
|
180
|
+
grant.expires_at = _parse_expiry(data.get("expires_at")) or time.time() + 55 * 60
|
|
181
|
+
redactor.register(grant.token)
|
|
182
|
+
logger.info("minted github token for %s (repos=%s, perms=%s)", grant.task_id, grant.repositories, grant.permissions)
|
|
183
|
+
|
|
184
|
+
async def _mint_token(self, payload: dict) -> dict:
|
|
185
|
+
"""mint an installation token; on a 422 caused by inaccessible repos, retry with only accessible ones."""
|
|
186
|
+
try:
|
|
187
|
+
return await self._post_github(f"/app/installations/{self.installation_id}/access_tokens", self._app_jwt(), payload)
|
|
188
|
+
except GitHubStatusError as e:
|
|
189
|
+
requested = payload.get("repositories")
|
|
190
|
+
if e.status != 422 or not requested or self.accessible_repos is None or "not accessible" not in str(e):
|
|
191
|
+
raise
|
|
192
|
+
usable = [repo for repo in requested if repo in self.accessible_repos]
|
|
193
|
+
if not usable:
|
|
194
|
+
raise RuntimeError(f"the GitHub App is not installed on {', '.join(requested)} — install it on those repositories or remove them from github.approved_repos") from e
|
|
195
|
+
if usable == list(requested):
|
|
196
|
+
raise # 422 was not about our repo list after all
|
|
197
|
+
logger.warning("token mint 422ed; retrying without inaccessible repos %s", sorted(set(requested) - set(usable)))
|
|
198
|
+
return await self._post_github(f"/app/installations/{self.installation_id}/access_tokens", self._app_jwt(), {**payload, "repositories": usable})
|
|
199
|
+
|
|
200
|
+
def _app_jwt(self) -> str:
|
|
201
|
+
now = int(time.time())
|
|
202
|
+
return jwt.encode({"iat": now - 60, "exp": now + 540, "iss": str(self.app_id)}, self.private_key, algorithm="RS256")
|
|
203
|
+
|
|
204
|
+
async def _post_github(self, path: str, bearer: str, payload: dict) -> dict:
|
|
205
|
+
"""the http seam — patched in unit tests."""
|
|
206
|
+
import aiohttp
|
|
207
|
+
|
|
208
|
+
async with aiohttp.ClientSession() as session:
|
|
209
|
+
async with session.post(GITHUB_API + path, json=payload, headers={"Authorization": f"Bearer {bearer}", "Accept": "application/vnd.github+json"}) as response:
|
|
210
|
+
if response.status >= 300:
|
|
211
|
+
body = redactor.redact(await response.text())[:300]
|
|
212
|
+
raise GitHubStatusError(response.status, f"github token mint failed: {response.status} — {body}")
|
|
213
|
+
return await response.json()
|
|
214
|
+
|
|
215
|
+
async def _get_github(self, path: str, bearer: str) -> dict:
|
|
216
|
+
"""the app-metadata http seam — patched in unit tests."""
|
|
217
|
+
import aiohttp
|
|
218
|
+
|
|
219
|
+
async with aiohttp.ClientSession() as session:
|
|
220
|
+
async with session.get(GITHUB_API + path, headers={"Authorization": f"Bearer {bearer}", "Accept": "application/vnd.github+json"}) as response:
|
|
221
|
+
if response.status >= 300:
|
|
222
|
+
body = redactor.redact(await response.text())[:300]
|
|
223
|
+
raise RuntimeError(f"github api GET {path} failed: {response.status} — {body}")
|
|
224
|
+
return await response.json()
|
|
225
|
+
|
|
226
|
+
# -- unix socket server -------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
async def start(self) -> None:
|
|
229
|
+
path = Path(self.socket_path)
|
|
230
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
231
|
+
path.unlink(missing_ok=True)
|
|
232
|
+
self._server = await asyncio.start_unix_server(self._handle, path=self.socket_path)
|
|
233
|
+
os.chmod(self.socket_path, 0o666) # task slot users must be able to connect; the nonce is the auth
|
|
234
|
+
|
|
235
|
+
async def stop(self) -> None:
|
|
236
|
+
if self._server is not None:
|
|
237
|
+
self._server.close()
|
|
238
|
+
await self._server.wait_closed()
|
|
239
|
+
Path(self.socket_path).unlink(missing_ok=True)
|
|
240
|
+
|
|
241
|
+
async def _handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
242
|
+
try:
|
|
243
|
+
request = json.loads(await reader.readline())
|
|
244
|
+
if request.get("op") == "git-credentials":
|
|
245
|
+
response = await self.credentials_for_nonce(str(request.get("nonce", "")))
|
|
246
|
+
else:
|
|
247
|
+
response = {"error": "unknown op"}
|
|
248
|
+
except PermissionError as e:
|
|
249
|
+
response = {"error": str(e)}
|
|
250
|
+
except Exception as e:
|
|
251
|
+
logger.exception("broker request failed")
|
|
252
|
+
response = {"error": redactor.redact(str(e))}
|
|
253
|
+
try:
|
|
254
|
+
writer.write((json.dumps(response) + "\n").encode())
|
|
255
|
+
await writer.drain()
|
|
256
|
+
finally:
|
|
257
|
+
writer.close()
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _parse_expiry(value) -> float | None:
|
|
261
|
+
if not value:
|
|
262
|
+
return None
|
|
263
|
+
try:
|
|
264
|
+
return datetime.fromisoformat(str(value).replace("Z", "+00:00")).timestamp()
|
|
265
|
+
except ValueError:
|
|
266
|
+
return None
|
taskboy/classifier.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""task classification: one small structured-output model call, then config-driven routing (MOD-001/002).
|
|
2
|
+
|
|
3
|
+
failures never guess silently: one retry, then the fixed fallback classification, always audited (MOD-005/009).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
from taskboy import settings, skills
|
|
11
|
+
from taskboy.config import Config, ConfigError, Role, role_for
|
|
12
|
+
from taskboy.llm import _extract_json, extract_usage, structured_call # noqa: F401 (extract_usage re-exported for callers/tests)
|
|
13
|
+
from taskboy.models import EFFORT_LEVELS, Task
|
|
14
|
+
from taskboy.prompts import CLASSIFICATION_SCHEMA, classifier_prompt, trim_context
|
|
15
|
+
from taskboy.router import RoleRefusal, RoutingDecision, route, route_skill
|
|
16
|
+
from taskboy.store import Store
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger("taskboy.classifier")
|
|
19
|
+
|
|
20
|
+
FALLBACK_CLASSIFICATION = {
|
|
21
|
+
"task_type": "investigation",
|
|
22
|
+
"complexity": "standard",
|
|
23
|
+
"risk": "read_only",
|
|
24
|
+
"expected_duration": "under_hour",
|
|
25
|
+
"required_integrations": [],
|
|
26
|
+
"target_repos": [],
|
|
27
|
+
"jira_keys": [],
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
# "effort" is optional in CLASSIFICATION_SCHEMA (never a hard failure), but a stray/unrecognized value from a
|
|
31
|
+
# stored triage classification or a lenient model call should be dropped rather than reach the router (MOD-009)
|
|
32
|
+
VALID_EFFORT_VALUES = set(EFFORT_LEVELS) | {"auto"}
|
|
33
|
+
|
|
34
|
+
# issue #55: prompt guidance (classification_guidance() in prompts.py) tells the model that
|
|
35
|
+
# "address/fix/respond to/resolve review comments on a PR" is bug_fix, not pr_review — but a
|
|
36
|
+
# haiku-tier classifier following prose isn't reliable on plural/no-URL phrasings, so this is
|
|
37
|
+
# re-checked deterministically after the model call, before routing, and always audited.
|
|
38
|
+
# matches either order — "address ... comments" and "comments ... resolve them" both trigger,
|
|
39
|
+
# since same-intent requests routinely put the verb after the noun (issue #55 follow-up).
|
|
40
|
+
_REVIEW_COMMENTS_VERB_RE = re.compile(
|
|
41
|
+
r"\b(address|fix|respond to|resolve)\b.{0,40}\b(review )?comments\b" r"|\b(review )?comments\b.{0,40}\b(address|fix|respond to|resolve)\b",
|
|
42
|
+
re.IGNORECASE | re.DOTALL,
|
|
43
|
+
)
|
|
44
|
+
_PR_REFERENCE_RE = re.compile(r"\bPRs?\b|\bpull requests?\b|/pull/\d+", re.IGNORECASE)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _apply_review_comments_guard(classification: dict, request_text: str) -> tuple[dict, str | None]:
|
|
48
|
+
"""returns the (possibly overridden) classification and a guard name to audit, or None if untouched."""
|
|
49
|
+
if classification.get("task_type") != "pr_review":
|
|
50
|
+
return classification, None
|
|
51
|
+
if not (_REVIEW_COMMENTS_VERB_RE.search(request_text) and _PR_REFERENCE_RE.search(request_text)):
|
|
52
|
+
return classification, None
|
|
53
|
+
overridden = {**classification, "task_type": "bug_fix", "risk": "writes_code"}
|
|
54
|
+
return overridden, "review-comments-override"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def stub_classify(task: Task) -> dict:
|
|
58
|
+
"""dev/echo-mode classifier: no model call, fixed fields."""
|
|
59
|
+
return {"task_type": "investigation", "complexity": "standard", "routing_rationale": "stub classifier"}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Classifier:
|
|
63
|
+
def __init__(self, store: Store, config: Config):
|
|
64
|
+
self.store = store
|
|
65
|
+
self.config = config
|
|
66
|
+
tier = (config.raw.get("classifier") or {}).get("tier", "haiku")
|
|
67
|
+
models = config.raw.get("models") or {}
|
|
68
|
+
if tier not in models:
|
|
69
|
+
raise ConfigError(f"classifier.tier {tier!r} is not in the model catalog")
|
|
70
|
+
self.model_alias = tier
|
|
71
|
+
self.model_id = str(models[tier]["id"])
|
|
72
|
+
|
|
73
|
+
async def classify(self, task: Task) -> dict:
|
|
74
|
+
return await self._classify(task)
|
|
75
|
+
|
|
76
|
+
async def _classify(self, task: Task) -> dict:
|
|
77
|
+
"""returns the task fields set when the task moves received -> queued."""
|
|
78
|
+
role = role_for(self.config.roles, task.slack_user_id)
|
|
79
|
+
if role is None:
|
|
80
|
+
raise RoleRefusal(f"user {task.slack_user_id!r} has no configured role")
|
|
81
|
+
invocation = skills.parse_invocation(task.request_text)
|
|
82
|
+
if invocation and invocation[0] in skills.available(settings.SKILLS_ROOT):
|
|
83
|
+
name, args = invocation
|
|
84
|
+
skill_config = self.config.raw.get("skills") or {}
|
|
85
|
+
loaded = skills.load(settings.SKILLS_ROOT, name)
|
|
86
|
+
profile = str(loaded.profile or skill_config.get("profile", "standard"))
|
|
87
|
+
approved_repos = ((self.config.raw.get("github") or {}).get("approved_repos") or []) if self.config.service_enabled("github") else []
|
|
88
|
+
if role.repos is not None:
|
|
89
|
+
approved_repos = [repo for repo in approved_repos if repo in role.repos]
|
|
90
|
+
target_repos = []
|
|
91
|
+
for repo in approved_repos:
|
|
92
|
+
short_name = repo.split("/", 1)[-1]
|
|
93
|
+
if re.search(rf"(?<![a-z0-9_-]){re.escape(short_name)}(?![a-z0-9_-])", args, flags=re.IGNORECASE):
|
|
94
|
+
target_repos.append(repo)
|
|
95
|
+
classification: dict = {
|
|
96
|
+
"task_type": "skill",
|
|
97
|
+
"complexity": "standard",
|
|
98
|
+
"risk": "read_only" if profile == "read_only" else "writes_code_and_jira",
|
|
99
|
+
"expected_duration": "hours",
|
|
100
|
+
"required_integrations": [],
|
|
101
|
+
"target_repos": target_repos,
|
|
102
|
+
"jira_keys": [],
|
|
103
|
+
"skill": name,
|
|
104
|
+
"skill_args": args,
|
|
105
|
+
"skill_internal_tools": loaded.internal_tools,
|
|
106
|
+
}
|
|
107
|
+
decision = route_skill(task.model_override, self.config.raw, role=role, skill_tier=loaded.model, skill_profile=loaded.profile)
|
|
108
|
+
self.store.add_event(task.task_id, "classified", {**classification, "fallback": False})
|
|
109
|
+
self.store.add_event(task.task_id, "model_routing", {"model_alias": decision.model_alias, "model_id": decision.model_id, "profile": decision.profile, "rationale": decision.rationale})
|
|
110
|
+
return _task_fields(classification, decision, _review_persona(classification, self.config.reviewer.enabled))
|
|
111
|
+
if task.classification_json:
|
|
112
|
+
try:
|
|
113
|
+
stored = json.loads(task.classification_json)
|
|
114
|
+
classification = validate_classification(stored)
|
|
115
|
+
except (TypeError, ValueError, json.JSONDecodeError):
|
|
116
|
+
pass
|
|
117
|
+
else:
|
|
118
|
+
classification, guard = _apply_review_comments_guard(classification, task.request_text)
|
|
119
|
+
decision = route(classification["task_type"], classification["complexity"], task.model_override, self.config.raw, role=role, classifier_effort=classification.get("effort"))
|
|
120
|
+
detail = {**classification, "fallback": False, "source": "triage"}
|
|
121
|
+
if guard:
|
|
122
|
+
detail["guard"] = guard
|
|
123
|
+
self.store.add_event(task.task_id, "classified", detail)
|
|
124
|
+
self.store.add_event(task.task_id, "model_routing", {"model_alias": decision.model_alias, "model_id": decision.model_id, "profile": decision.profile, "rationale": decision.rationale})
|
|
125
|
+
return _task_fields(classification, decision, _review_persona(classification, self.config.reviewer.enabled))
|
|
126
|
+
classification, usage, fell_back = await self._classify_once(task, role)
|
|
127
|
+
classification, guard = _apply_review_comments_guard(classification, task.request_text)
|
|
128
|
+
decision = route(classification["task_type"], classification["complexity"], task.model_override, self.config.raw, role=role, classifier_effort=classification.get("effort"))
|
|
129
|
+
detail = {**classification, "fallback": fell_back}
|
|
130
|
+
if guard:
|
|
131
|
+
detail["guard"] = guard
|
|
132
|
+
self.store.add_event(task.task_id, "classified", detail)
|
|
133
|
+
self.store.add_event(task.task_id, "model_routing", {"model_alias": decision.model_alias, "model_id": decision.model_id, "profile": decision.profile, "rationale": decision.rationale})
|
|
134
|
+
if usage:
|
|
135
|
+
self.store.add_usage(task.task_id, "classifier", self.model_id, **usage)
|
|
136
|
+
return _task_fields(classification, decision, _review_persona(classification, self.config.reviewer.enabled))
|
|
137
|
+
|
|
138
|
+
async def _classify_once(self, task: Task, role: Role | None = None) -> tuple[dict, dict | None, bool]:
|
|
139
|
+
github = (self.config.raw.get("github") or {}) if self.config.service_enabled("github") else {}
|
|
140
|
+
approved_repos = github.get("approved_repos") or []
|
|
141
|
+
if role is not None and role.repos is not None:
|
|
142
|
+
approved_repos = [repo for repo in approved_repos if repo in role.repos]
|
|
143
|
+
self_repo = str(github.get("self_repo") or "")
|
|
144
|
+
prompt = classifier_prompt(
|
|
145
|
+
task.request_text,
|
|
146
|
+
approved_repos,
|
|
147
|
+
self.config.enabled_integrations(),
|
|
148
|
+
trim_context(task.thread_context),
|
|
149
|
+
self_repo=self_repo if self_repo in approved_repos else None,
|
|
150
|
+
bot_name=self.config.agent_name,
|
|
151
|
+
)
|
|
152
|
+
# structured_call already retries once internally, so one call here bounds the worst case at two model calls before fallback
|
|
153
|
+
try:
|
|
154
|
+
classification, usage = await self._call_model(prompt)
|
|
155
|
+
return classification, usage, False
|
|
156
|
+
except Exception as e:
|
|
157
|
+
logger.warning("classifier call failed for %s: %s", task.task_id, e)
|
|
158
|
+
self.store.add_event(task.task_id, "classifier_failed", {"what": "classifier", "error": str(e)})
|
|
159
|
+
self.store.add_error("classifier", type(e).__name__, str(e), task_id=task.task_id)
|
|
160
|
+
return dict(FALLBACK_CLASSIFICATION), None, True
|
|
161
|
+
|
|
162
|
+
async def _call_model(self, prompt: str) -> tuple[dict, dict | None]:
|
|
163
|
+
"""the per-call API seam — patched in unit tests."""
|
|
164
|
+
classification, usage = await structured_call(self.model_id, prompt, CLASSIFICATION_SCHEMA)
|
|
165
|
+
return validate_classification(classification), usage
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _review_persona(classification: dict, reviewer_enabled: bool) -> str | None:
|
|
169
|
+
"""only a genuine /review skill invocation runs as the reviewer: a model-labeled pr_review can be a
|
|
170
|
+
misclassified "address the review comments" request, which writes code and must stay with the main agent."""
|
|
171
|
+
if reviewer_enabled and classification.get("skill") == "review":
|
|
172
|
+
return "reviewer"
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _task_fields(classification: dict, decision: RoutingDecision, persona: str | None = None) -> dict:
|
|
177
|
+
fields = {
|
|
178
|
+
"classification_json": json.dumps(classification),
|
|
179
|
+
"task_type": classification["task_type"],
|
|
180
|
+
"complexity": classification["complexity"],
|
|
181
|
+
"risk": classification["risk"],
|
|
182
|
+
"model_alias": decision.model_alias,
|
|
183
|
+
"model_id": decision.model_id,
|
|
184
|
+
"profile": decision.profile,
|
|
185
|
+
"routing_rationale": decision.rationale,
|
|
186
|
+
"max_budget_usd": decision.max_budget_usd,
|
|
187
|
+
"max_turns": decision.max_turns,
|
|
188
|
+
"max_runtime_minutes": decision.max_runtime_minutes,
|
|
189
|
+
"effort": decision.effort,
|
|
190
|
+
}
|
|
191
|
+
if persona is not None:
|
|
192
|
+
fields["persona"] = persona
|
|
193
|
+
return fields
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def validate_classification(candidate) -> dict:
|
|
197
|
+
"""lenient: only task_type/complexity are fatal (no safe default); other missing fields fill from FALLBACK_CLASSIFICATION."""
|
|
198
|
+
if not isinstance(candidate, dict):
|
|
199
|
+
raise ValueError("classifier returned no structured output")
|
|
200
|
+
missing = [key for key in CLASSIFICATION_SCHEMA["required"] if key not in candidate]
|
|
201
|
+
if "task_type" in missing or "complexity" in missing:
|
|
202
|
+
raise ValueError(f"classification missing fields: {missing}")
|
|
203
|
+
if missing:
|
|
204
|
+
logger.info("classification filled missing fields %s with defaults", missing)
|
|
205
|
+
filled = dict(candidate)
|
|
206
|
+
for key in missing:
|
|
207
|
+
filled[key] = FALLBACK_CLASSIFICATION[key]
|
|
208
|
+
if "effort" in filled and filled["effort"] not in VALID_EFFORT_VALUES:
|
|
209
|
+
filled.pop("effort")
|
|
210
|
+
return filled
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def parse_classification(message) -> dict:
|
|
214
|
+
"""tolerant of sdk result shapes: structured_output attr, json in result, or json embedded in prose."""
|
|
215
|
+
candidate = getattr(message, "structured_output", None)
|
|
216
|
+
if candidate is None:
|
|
217
|
+
result = getattr(message, "result", None)
|
|
218
|
+
candidate = _extract_json(result) if isinstance(result, str) else result
|
|
219
|
+
return validate_classification(candidate)
|