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/scheduler.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""dashboard-defined task scheduler: fire due schedules as tasks, then advance them.
|
|
2
|
+
|
|
3
|
+
deterministic Python like the rest of the orchestrator — the loop only enqueues tasks; the classifier
|
|
4
|
+
and runner do the model work. schedules run as the system identity (team 'github') with requester 'cli'
|
|
5
|
+
so an explicit model choice is honored like any operator-initiated task; lifecycle posts land top-level
|
|
6
|
+
in the debug channel with a "Scheduled Task Started" announcement instead of the quip pool.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import logging
|
|
11
|
+
import sys
|
|
12
|
+
import traceback
|
|
13
|
+
from datetime import datetime, time, timedelta, timezone
|
|
14
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
15
|
+
|
|
16
|
+
from taskboy.config import Config
|
|
17
|
+
from taskboy.issue_runs import start_implementation_run
|
|
18
|
+
from taskboy.models import utcnow
|
|
19
|
+
from taskboy.orchestrator import _notify_safe, accept_task, intake_paused, queue_at_capacity
|
|
20
|
+
from taskboy.store import Store
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("taskboy.scheduler")
|
|
23
|
+
|
|
24
|
+
TICK_SECONDS = 30
|
|
25
|
+
SCHEDULE_TEAM = "github" # notifier treats github-team tasks as silent when no channel is set
|
|
26
|
+
SCHEDULE_USER = "cli" # the operator-task identity; its role permits the model dropdown's overrides
|
|
27
|
+
|
|
28
|
+
CLI_UPDATE_NEXT_RUN_META_KEY = "cli_update_next_run_at"
|
|
29
|
+
CLI_UPDATE_PACKAGE = "claude-agent-sdk" # bundles the claude cli (see the packaged install.sh)
|
|
30
|
+
CLI_UPDATE_SERVICE_UNIT = "taskboy"
|
|
31
|
+
# NoNewPrivileges=yes blocks sudo/setuid, so we can't restart the unit from inside this process. instead
|
|
32
|
+
# touch this flag; the root-owned taskboy-restart.path unit watches it and restarts us (packaged install.sh)
|
|
33
|
+
CLI_UPDATE_RESTART_FLAG = "/run/taskboy/restart-requested"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _zone(name: str | None):
|
|
37
|
+
if not name:
|
|
38
|
+
return timezone.utc
|
|
39
|
+
try:
|
|
40
|
+
return ZoneInfo(name)
|
|
41
|
+
except (ZoneInfoNotFoundError, ValueError):
|
|
42
|
+
return timezone.utc
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def next_run_after(kind: str, *, interval_minutes: int | None, at_time: str | None, run_at: str | None, tzname: str | None, after: datetime) -> datetime | None:
|
|
46
|
+
"""the next UTC fire time strictly after `after`, or None when the schedule has no future run (a one-off already due).
|
|
47
|
+
|
|
48
|
+
`after` must be an aware UTC datetime. used both to seed a schedule's first run (after=now) and to advance it after a fire."""
|
|
49
|
+
if kind == "once":
|
|
50
|
+
target = datetime.fromisoformat(run_at) if run_at else None
|
|
51
|
+
return target if target is not None and target > after else None
|
|
52
|
+
if kind == "interval":
|
|
53
|
+
minutes = max(int(interval_minutes or 0), 1)
|
|
54
|
+
return after + timedelta(minutes=minutes)
|
|
55
|
+
if kind == "daily":
|
|
56
|
+
tz = _zone(tzname)
|
|
57
|
+
local = after.astimezone(tz)
|
|
58
|
+
hour, minute = (int(part) for part in str(at_time or "00:00").split(":"))
|
|
59
|
+
target = datetime.combine(local.date(), time(hour, minute), tzinfo=tz)
|
|
60
|
+
if target <= local:
|
|
61
|
+
target = datetime.combine(local.date() + timedelta(days=1), time(hour, minute), tzinfo=tz)
|
|
62
|
+
return target.astimezone(timezone.utc)
|
|
63
|
+
raise ValueError(f"unknown schedule kind {kind!r}")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _now() -> datetime:
|
|
67
|
+
return datetime.now(timezone.utc)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _is_implementation_run(request_text: str) -> bool:
|
|
71
|
+
# matches store.active_implementation_run()'s own LIKE '/implementapprovedissues%' check
|
|
72
|
+
return request_text.strip().startswith("/implementapprovedissues")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def fire_due(store: Store, config: Config, notifier, now: datetime) -> int:
|
|
76
|
+
"""fire every schedule due at `now`, advancing each. returns how many fired."""
|
|
77
|
+
fired = 0
|
|
78
|
+
for schedule in store.due_schedules(now.isoformat(timespec="seconds")):
|
|
79
|
+
try:
|
|
80
|
+
# key the task to the slot that triggered it so a double-tick can't create two tasks for one slot
|
|
81
|
+
slot = str(schedule["next_run_at"])
|
|
82
|
+
notify_key = f"schedule_fire_refused:{schedule['id']}"
|
|
83
|
+
# slot + hour bucket: a schedule stuck for days still re-pages every hour instead of paging once ever
|
|
84
|
+
notify_value = f"{slot}@{now:%Y-%m-%dT%H}"
|
|
85
|
+
gate_status = "paused" if intake_paused(store) else "queue_full" if queue_at_capacity(store, config) else None
|
|
86
|
+
if gate_status is not None:
|
|
87
|
+
# gated before the attempt so a known-refused schedule never re-enters accept_task (and its
|
|
88
|
+
# own error-row write, or the implementation path's reserve/release of a batch) every tick
|
|
89
|
+
if store.meta_get(notify_key) != notify_value:
|
|
90
|
+
store.meta_set(notify_key, notify_value)
|
|
91
|
+
detail = f"schedule {schedule['id']} ({schedule['name']}) fire refused ({gate_status}); slot not consumed, will retry next tick"
|
|
92
|
+
store.add_error("scheduler", "fire_refused", detail, context={"schedule_id": schedule["id"], "schedule_name": schedule["name"], "status": gate_status})
|
|
93
|
+
debug = getattr(notifier, "debug", None)
|
|
94
|
+
if debug is not None:
|
|
95
|
+
await _notify_safe(debug.system_error, "scheduler", detail)
|
|
96
|
+
logger.warning("schedule %s fire refused (%s); slot %s not consumed", schedule["id"], gate_status, slot)
|
|
97
|
+
continue
|
|
98
|
+
if _is_implementation_run(schedule["request_text"]):
|
|
99
|
+
# reserve the batch before the coordinator exists so an empty queue never starts one
|
|
100
|
+
task, status, _active = await start_implementation_run(
|
|
101
|
+
store,
|
|
102
|
+
config,
|
|
103
|
+
notifier,
|
|
104
|
+
request_text=schedule["request_text"],
|
|
105
|
+
user_id=SCHEDULE_USER,
|
|
106
|
+
channel_id=config.slack.debug_channel,
|
|
107
|
+
thread_key=f"schedule:{schedule['id']}@{slot}",
|
|
108
|
+
model_override=schedule["model_alias"] or None,
|
|
109
|
+
effort_override=schedule["effort"] or None,
|
|
110
|
+
schedule_name=schedule["name"],
|
|
111
|
+
)
|
|
112
|
+
else:
|
|
113
|
+
task, status = await accept_task(
|
|
114
|
+
store,
|
|
115
|
+
config,
|
|
116
|
+
notifier,
|
|
117
|
+
team_id=SCHEDULE_TEAM,
|
|
118
|
+
channel_id=config.slack.debug_channel,
|
|
119
|
+
thread_ts=f"schedule:{schedule['id']}@{slot}",
|
|
120
|
+
message_ts=f"schedule:{schedule['id']}@{slot}",
|
|
121
|
+
user_id=SCHEDULE_USER,
|
|
122
|
+
text=schedule["request_text"],
|
|
123
|
+
model_override=schedule["model_alias"] or None,
|
|
124
|
+
effort_override=schedule["effort"] or None,
|
|
125
|
+
schedule_name=schedule["name"],
|
|
126
|
+
)
|
|
127
|
+
task_id = task.task_id if task else None
|
|
128
|
+
nxt = next_run_after(schedule["kind"], interval_minutes=schedule["interval_minutes"], at_time=schedule["at_time"], run_at=schedule["run_at"], tzname=schedule["timezone"], after=now)
|
|
129
|
+
run_count_after = int(schedule["run_count"]) + 1
|
|
130
|
+
exhausted = schedule["max_runs"] is not None and run_count_after >= int(schedule["max_runs"])
|
|
131
|
+
enabled = nxt is not None and not exhausted
|
|
132
|
+
store.record_schedule_fire(schedule["id"], task_id, nxt.isoformat(timespec="seconds") if nxt else slot, enabled)
|
|
133
|
+
fired += 1
|
|
134
|
+
logger.info("schedule %s fired -> task %s (%s), next=%s enabled=%s", schedule["id"], task_id, status, nxt, enabled)
|
|
135
|
+
except Exception as e:
|
|
136
|
+
# advance past this slot so a broken schedule doesn't refire every tick; keep it enabled for a retry next slot
|
|
137
|
+
nxt = next_run_after(schedule["kind"], interval_minutes=schedule["interval_minutes"], at_time=schedule["at_time"], run_at=schedule["run_at"], tzname=schedule["timezone"], after=now)
|
|
138
|
+
store.record_schedule_fire(schedule["id"], None, nxt.isoformat(timespec="seconds") if nxt else str(schedule["next_run_at"]), enabled=nxt is not None)
|
|
139
|
+
store.add_error("scheduler", type(e).__name__, str(e), traceback=traceback.format_exc(), context={"schedule_id": schedule["id"]})
|
|
140
|
+
logger.exception("schedule %s failed to fire", schedule["id"])
|
|
141
|
+
return fired
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
async def fire_schedule_now(store: Store, config: Config, notifier, schedule: dict):
|
|
145
|
+
"""fire a schedule immediately without advancing its next_run_at (a manual run-now from the dashboard)."""
|
|
146
|
+
slot = utcnow()
|
|
147
|
+
if _is_implementation_run(schedule["request_text"]):
|
|
148
|
+
task, status, _active = await start_implementation_run(
|
|
149
|
+
store,
|
|
150
|
+
config,
|
|
151
|
+
notifier,
|
|
152
|
+
request_text=schedule["request_text"],
|
|
153
|
+
user_id=SCHEDULE_USER,
|
|
154
|
+
channel_id=config.slack.debug_channel,
|
|
155
|
+
thread_key=f"schedule:{schedule['id']}:manual@{slot}",
|
|
156
|
+
model_override=schedule["model_alias"] or None,
|
|
157
|
+
effort_override=schedule["effort"] or None,
|
|
158
|
+
schedule_name=schedule["name"],
|
|
159
|
+
)
|
|
160
|
+
return task, status
|
|
161
|
+
return await accept_task(
|
|
162
|
+
store,
|
|
163
|
+
config,
|
|
164
|
+
notifier,
|
|
165
|
+
team_id=SCHEDULE_TEAM,
|
|
166
|
+
channel_id=config.slack.debug_channel,
|
|
167
|
+
thread_ts=f"schedule:{schedule['id']}:manual@{slot}",
|
|
168
|
+
message_ts=f"schedule:{schedule['id']}:manual@{slot}",
|
|
169
|
+
user_id=SCHEDULE_USER,
|
|
170
|
+
text=schedule["request_text"],
|
|
171
|
+
model_override=schedule["model_alias"] or None,
|
|
172
|
+
effort_override=schedule["effort"] or None,
|
|
173
|
+
schedule_name=schedule["name"],
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def cli_update_due(next_run_iso: str | None, at_time: str, tzname: str, now: datetime) -> tuple[bool, str]:
|
|
178
|
+
"""pure scheduling decision for the off-peak cli auto-update: whether it is due now, and the next_run_at
|
|
179
|
+
to persist either way. `next_run_iso` is None on the very first check (seeds a run time without firing)."""
|
|
180
|
+
if next_run_iso is None:
|
|
181
|
+
nxt = next_run_after("daily", interval_minutes=None, at_time=at_time, run_at=None, tzname=tzname, after=now)
|
|
182
|
+
assert nxt is not None # "daily" always has a next run
|
|
183
|
+
return False, nxt.isoformat(timespec="seconds")
|
|
184
|
+
next_run = datetime.fromisoformat(next_run_iso)
|
|
185
|
+
if now < next_run:
|
|
186
|
+
return False, next_run_iso
|
|
187
|
+
# advance immediately, before the upgrade runs, so a slow or crashing upgrade can't refire the same slot
|
|
188
|
+
nxt = next_run_after("daily", interval_minutes=None, at_time=at_time, run_at=None, tzname=tzname, after=now)
|
|
189
|
+
assert nxt is not None
|
|
190
|
+
return True, nxt.isoformat(timespec="seconds")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
async def _run_subprocess(*args: str) -> tuple[bool, str]:
|
|
194
|
+
"""the process-execution seam — patched in unit tests so they never actually pip install."""
|
|
195
|
+
proc = await asyncio.create_subprocess_exec(*args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT)
|
|
196
|
+
stdout, _ = await proc.communicate()
|
|
197
|
+
output = (stdout or b"").decode(errors="replace")
|
|
198
|
+
return proc.returncode == 0, output
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _request_restart() -> tuple[bool, str]:
|
|
202
|
+
"""the restart seam — patched in unit tests. touches the flag the root-side path unit watches; this needs
|
|
203
|
+
no privileges (NoNewPrivileges-safe), unlike sudo/systemctl from inside the sandboxed service process."""
|
|
204
|
+
try:
|
|
205
|
+
with open(CLI_UPDATE_RESTART_FLAG, "w") as flag:
|
|
206
|
+
flag.write(utcnow())
|
|
207
|
+
return True, CLI_UPDATE_RESTART_FLAG
|
|
208
|
+
except OSError as e:
|
|
209
|
+
return False, str(e)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
async def run_cli_update(store: Store) -> None:
|
|
213
|
+
"""off-peak upgrade of the claude-agent-sdk package (which bundles the claude cli, packaged install.sh)
|
|
214
|
+
straight from pypi, always the latest release and ungated by ci, then a self-restart so the new version
|
|
215
|
+
takes effect (restarts are safe: reconciliation requeues/resumes interrupted tasks, REL-002/003). a failed
|
|
216
|
+
pip pull is recorded and must never restart the service (a broken upgrade should stay visibly broken, not
|
|
217
|
+
take down a working deployment)."""
|
|
218
|
+
ok, output = await _run_subprocess(sys.executable, "-m", "pip", "install", "--upgrade", CLI_UPDATE_PACKAGE)
|
|
219
|
+
if not ok:
|
|
220
|
+
store.add_error("cli_update", "pip_upgrade_failed", output[-2000:])
|
|
221
|
+
logger.error("cli auto-update: pip upgrade of %s failed: %s", CLI_UPDATE_PACKAGE, output[-500:])
|
|
222
|
+
return
|
|
223
|
+
if "Successfully installed" not in output:
|
|
224
|
+
# pip exits 0 for a no-op upgrade too ("Requirement already satisfied") — only a real
|
|
225
|
+
# version bump needs a restart; skip it so an already-current install doesn't interrupt tasks nightly
|
|
226
|
+
logger.info("cli auto-update: %s already up to date, no restart needed", CLI_UPDATE_PACKAGE)
|
|
227
|
+
return
|
|
228
|
+
logger.info("cli auto-update: %s upgraded; requesting restart of %s", CLI_UPDATE_PACKAGE, CLI_UPDATE_SERVICE_UNIT)
|
|
229
|
+
# sudo/systemctl can't work here (NoNewPrivileges=yes); the root-side taskboy-restart.path unit watches
|
|
230
|
+
# this flag and does the restart outside our sandbox (packaged install.sh)
|
|
231
|
+
requested, restart_output = _request_restart()
|
|
232
|
+
if not requested:
|
|
233
|
+
store.add_error("cli_update", "restart_failed", restart_output[-2000:])
|
|
234
|
+
logger.error("cli auto-update: upgraded %s but restart request failed: %s", CLI_UPDATE_PACKAGE, restart_output[-500:])
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
async def maybe_run_cli_update(store: Store, config: Config, now: datetime) -> bool:
|
|
238
|
+
"""checked every scheduler tick; fires at most once per configured window, off by default (config.cli_update)."""
|
|
239
|
+
cli_update = config.cli_update
|
|
240
|
+
if not cli_update.enabled:
|
|
241
|
+
return False
|
|
242
|
+
due, next_run_at = cli_update_due(store.meta_get(CLI_UPDATE_NEXT_RUN_META_KEY), cli_update.at_time, cli_update.tzname, now)
|
|
243
|
+
store.meta_set(CLI_UPDATE_NEXT_RUN_META_KEY, next_run_at)
|
|
244
|
+
if not due:
|
|
245
|
+
return False
|
|
246
|
+
await run_cli_update(store)
|
|
247
|
+
return True
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
async def scheduler_loop(store: Store, config: Config, notifier) -> None:
|
|
251
|
+
while True:
|
|
252
|
+
try:
|
|
253
|
+
await fire_due(store, config, notifier, _now())
|
|
254
|
+
await maybe_run_cli_update(store, config, _now())
|
|
255
|
+
except Exception as e:
|
|
256
|
+
store.add_error("scheduler", type(e).__name__, str(e), traceback=traceback.format_exc())
|
|
257
|
+
logger.exception("scheduler tick failed")
|
|
258
|
+
await asyncio.sleep(TICK_SECONDS)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# shipped defaults, all daily Pacific with the model left blank: discovery/implementation route via the skills'
|
|
262
|
+
# own frontmatter; the warm-up is a trivial question that routes to haiku and opens the Claude Max usage
|
|
263
|
+
# window before the workday (it replaces the former one-off warmup config — edit or disable it in the UI).
|
|
264
|
+
SEED_SCHEDULES = [
|
|
265
|
+
{"seed_key": "implementapprovedissues-daily", "name": "Implement approved issues (daily)", "request_text": "/implementapprovedissues", "at_time": "03:00"},
|
|
266
|
+
{"seed_key": "warmup-daily", "name": "Claude usage-window warm-up (daily)", "request_text": "What does the HTTP status code 200 mean?", "at_time": "04:00"},
|
|
267
|
+
]
|
|
268
|
+
SEED_TIMEZONE = "America/Los_Angeles"
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def seed_default_schedules(store: Store, self_repo: str = "", github_enabled: bool = True) -> None:
|
|
272
|
+
"""install the shipped issues schedules exactly once, so an operator can later edit or delete them."""
|
|
273
|
+
if store.meta_get("schedules_seeded") == "1":
|
|
274
|
+
return
|
|
275
|
+
now = _now()
|
|
276
|
+
seeds = list(SEED_SCHEDULES)
|
|
277
|
+
if not github_enabled:
|
|
278
|
+
# the issues pipeline needs github; a github-less install only gets the warm-up
|
|
279
|
+
seeds = [seed for seed in seeds if seed["seed_key"] == "warmup-daily"]
|
|
280
|
+
# daily issue discovery only makes sense against a repo; default to the agent's own when configured
|
|
281
|
+
if self_repo and github_enabled:
|
|
282
|
+
seeds.insert(0, {"seed_key": "discoverissues-daily", "name": "Discover issues (daily)", "request_text": f"/discoverissues {self_repo}", "at_time": "00:00"})
|
|
283
|
+
for seed in seeds:
|
|
284
|
+
nxt = next_run_after("daily", interval_minutes=None, at_time=seed["at_time"], run_at=None, tzname=SEED_TIMEZONE, after=now)
|
|
285
|
+
assert nxt is not None
|
|
286
|
+
store.seed_schedule_once(
|
|
287
|
+
seed["seed_key"],
|
|
288
|
+
name=seed["name"],
|
|
289
|
+
request_text=seed["request_text"],
|
|
290
|
+
model_alias=None,
|
|
291
|
+
kind="daily",
|
|
292
|
+
interval_minutes=None,
|
|
293
|
+
at_time=seed["at_time"],
|
|
294
|
+
run_at=None,
|
|
295
|
+
timezone=SEED_TIMEZONE,
|
|
296
|
+
max_runs=None,
|
|
297
|
+
next_run_at=nxt.isoformat(timespec="seconds"),
|
|
298
|
+
created_by="system",
|
|
299
|
+
)
|
|
300
|
+
store.meta_set("schedules_seeded", "1")
|
|
301
|
+
logger.info("seeded default issues schedules")
|
taskboy/secrets.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""credential loading: env vars for local dev, one aws secrets manager json bundle on the host (§8.2).
|
|
2
|
+
|
|
3
|
+
rotation = update the secret + restart the service; nothing here touches disk. every loaded
|
|
4
|
+
credential is registered with the redactor so it cannot survive any output sink (AC-17).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
from taskboy import settings
|
|
12
|
+
from taskboy.redact import redactor
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Secrets:
|
|
17
|
+
slack_bot_token: str = ""
|
|
18
|
+
slack_app_token: str = ""
|
|
19
|
+
github_app_id: str = ""
|
|
20
|
+
github_installation_id: str = ""
|
|
21
|
+
github_app_private_key: str = ""
|
|
22
|
+
reviewer_github_app_id: str = ""
|
|
23
|
+
reviewer_github_installation_id: str = ""
|
|
24
|
+
reviewer_github_app_private_key: str = ""
|
|
25
|
+
jira_email: str = ""
|
|
26
|
+
jira_api_token: str = ""
|
|
27
|
+
sentry_token: str = ""
|
|
28
|
+
claude_oauth_token: str = "" # populated from the bundle on the host; local dev exports it directly
|
|
29
|
+
dashboard_github_token: str = "" # contents-only pat for dashboard commits straight to protected main
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def github_enabled(self) -> bool:
|
|
33
|
+
return bool(self.github_app_id and self.github_installation_id and self.github_app_private_key)
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def reviewer_github_enabled(self) -> bool:
|
|
37
|
+
return bool(self.reviewer_github_app_id and self.reviewer_github_installation_id and self.reviewer_github_app_private_key)
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def jira_enabled(self) -> bool:
|
|
41
|
+
return bool(self.jira_email and self.jira_api_token)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def load_secrets() -> Secrets:
|
|
45
|
+
# local dev never touches secrets manager: env vars only, missing values stay empty
|
|
46
|
+
if settings.ENVIRONMENT == "local" or os.environ.get("SLACK_BOT_TOKEN") or os.environ.get("GITHUB_APP_ID") or os.environ.get("REVIEWER_GITHUB_APP_ID"):
|
|
47
|
+
secrets = Secrets(
|
|
48
|
+
slack_bot_token=os.environ.get("SLACK_BOT_TOKEN", ""),
|
|
49
|
+
slack_app_token=os.environ.get("SLACK_APP_TOKEN", ""),
|
|
50
|
+
github_app_id=os.environ.get("GITHUB_APP_ID", ""),
|
|
51
|
+
github_installation_id=os.environ.get("GITHUB_INSTALLATION_ID", ""),
|
|
52
|
+
github_app_private_key=os.environ.get("GITHUB_APP_PRIVATE_KEY", ""),
|
|
53
|
+
reviewer_github_app_id=os.environ.get("REVIEWER_GITHUB_APP_ID", ""),
|
|
54
|
+
reviewer_github_installation_id=os.environ.get("REVIEWER_GITHUB_INSTALLATION_ID", ""),
|
|
55
|
+
reviewer_github_app_private_key=os.environ.get("REVIEWER_GITHUB_APP_PRIVATE_KEY", ""),
|
|
56
|
+
jira_email=os.environ.get("JIRA_EMAIL", ""),
|
|
57
|
+
jira_api_token=os.environ.get("JIRA_API_TOKEN", ""),
|
|
58
|
+
sentry_token=os.environ.get("SENTRY_TOKEN", ""),
|
|
59
|
+
dashboard_github_token=os.environ.get("DASHBOARD_GITHUB_TOKEN", ""),
|
|
60
|
+
)
|
|
61
|
+
else:
|
|
62
|
+
import boto3 # lazy: only needed on the ec2 host
|
|
63
|
+
|
|
64
|
+
client = boto3.client("secretsmanager", region_name=settings.REGION)
|
|
65
|
+
blob = json.loads(client.get_secret_value(SecretId=settings.SECRETS_NAME)["SecretString"])
|
|
66
|
+
secrets = Secrets(
|
|
67
|
+
slack_bot_token=blob.get("slack_bot_token", ""),
|
|
68
|
+
slack_app_token=blob.get("slack_app_token", ""),
|
|
69
|
+
github_app_id=blob.get("github_app_id", ""),
|
|
70
|
+
github_installation_id=blob.get("github_installation_id", ""),
|
|
71
|
+
github_app_private_key=blob.get("github_app_private_key", ""),
|
|
72
|
+
reviewer_github_app_id=blob.get("reviewer_github_app_id", ""),
|
|
73
|
+
reviewer_github_installation_id=blob.get("reviewer_github_installation_id", ""),
|
|
74
|
+
reviewer_github_app_private_key=blob.get("reviewer_github_app_private_key", ""),
|
|
75
|
+
jira_email=blob.get("jira_email", ""),
|
|
76
|
+
jira_api_token=blob.get("jira_api_token", ""),
|
|
77
|
+
sentry_token=blob.get("sentry_token", ""),
|
|
78
|
+
claude_oauth_token=blob.get("claude_oauth_token", ""),
|
|
79
|
+
dashboard_github_token=blob.get("dashboard_github_token", ""),
|
|
80
|
+
)
|
|
81
|
+
for value in (secrets.slack_bot_token, secrets.slack_app_token, secrets.github_app_private_key, secrets.reviewer_github_app_private_key, secrets.jira_api_token, secrets.sentry_token, secrets.claude_oauth_token, secrets.dashboard_github_token):
|
|
82
|
+
redactor.register(value)
|
|
83
|
+
return secrets
|
taskboy/settings.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""deployment-level settings from the environment. operator policy lives in config.yaml; credentials arrive via secrets manager in phase 2."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from taskboy import assets
|
|
6
|
+
|
|
7
|
+
ENVIRONMENT = os.environ.get("ENVIRONMENT", "local")
|
|
8
|
+
DB_PATH = os.environ.get("TASKBOY_DB_PATH", "local/taskboy.db")
|
|
9
|
+
CONFIG_PATH = os.environ.get("TASKBOY_CONFIG_PATH", "config/config.yaml")
|
|
10
|
+
WORKSPACES_ROOT = os.environ.get("TASKBOY_WORKSPACES_ROOT", "local/workspaces")
|
|
11
|
+
REPOS_ROOT = os.environ.get("TASKBOY_REPOS_ROOT", "local/repos")
|
|
12
|
+
MEMORY_ROOT = os.environ.get("TASKBOY_MEMORY_ROOT", "local/memory")
|
|
13
|
+
SKILLS_ROOT = os.environ.get("TASKBOY_SKILLS_ROOT", "skills")
|
|
14
|
+
UI_DIST = os.environ.get("TASKBOY_UI_DIST", str(assets.UI_DIST))
|
|
15
|
+
REGION = os.environ.get("AWS_REGION", "us-east-1")
|
|
16
|
+
SECRETS_NAME = os.environ.get("TASKBOY_SECRETS_NAME", f"TASKBOY_SECRETS_{ENVIRONMENT.upper()}")
|
|
17
|
+
BROKER_SOCKET = os.environ.get("TASKBOY_BROKER_SOCKET", "local/broker.sock")
|
|
18
|
+
REVIEWER_BROKER_SOCKET = os.environ.get("TASKBOY_REVIEWER_BROKER_SOCKET", "local/broker-reviewer.sock")
|
|
19
|
+
GIT_CRED_HELPER = os.environ.get("TASKBOY_GIT_CRED_HELPER", str(assets.GIT_CRED_HELPER))
|
taskboy/setup_checks.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""live credential validators for the setup wizard and `taskboy setup --check`.
|
|
2
|
+
|
|
3
|
+
each check is a plain synchronous function returning (ok, detail). they use urllib so the
|
|
4
|
+
wizard works before any optional dependency is installed, and never log or return the
|
|
5
|
+
credential itself — only identifiers safe to echo.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import base64
|
|
9
|
+
import json
|
|
10
|
+
import time
|
|
11
|
+
import urllib.error
|
|
12
|
+
import urllib.request
|
|
13
|
+
|
|
14
|
+
USER_AGENT = "taskboy-setup"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _get_json(url: str, headers: dict[str, str]) -> tuple[int, dict]:
|
|
18
|
+
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, **headers})
|
|
19
|
+
try:
|
|
20
|
+
with urllib.request.urlopen(request, timeout=15) as response:
|
|
21
|
+
return response.status, json.loads(response.read().decode() or "{}")
|
|
22
|
+
except urllib.error.HTTPError as e:
|
|
23
|
+
try:
|
|
24
|
+
return e.code, json.loads(e.read().decode() or "{}")
|
|
25
|
+
except (ValueError, OSError):
|
|
26
|
+
return e.code, {}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _post_json(url: str, headers: dict[str, str], body: dict | None = None) -> tuple[int, dict]:
|
|
30
|
+
data = json.dumps(body or {}).encode()
|
|
31
|
+
request = urllib.request.Request(url, data=data, method="POST", headers={"User-Agent": USER_AGENT, "Content-Type": "application/json", **headers})
|
|
32
|
+
try:
|
|
33
|
+
with urllib.request.urlopen(request, timeout=15) as response:
|
|
34
|
+
return response.status, json.loads(response.read().decode() or "{}")
|
|
35
|
+
except urllib.error.HTTPError as e:
|
|
36
|
+
try:
|
|
37
|
+
return e.code, json.loads(e.read().decode() or "{}")
|
|
38
|
+
except (ValueError, OSError):
|
|
39
|
+
return e.code, {}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _basic_auth(email: str, api_token: str) -> str:
|
|
43
|
+
return "Basic " + base64.b64encode(f"{email}:{api_token}".encode()).decode()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def check_claude(token: str) -> tuple[bool, str]:
|
|
47
|
+
"""one tiny haiku call through the sdk — proves headless auth works before anything else."""
|
|
48
|
+
if not token:
|
|
49
|
+
return False, "no token provided"
|
|
50
|
+
import asyncio
|
|
51
|
+
import os
|
|
52
|
+
import tempfile
|
|
53
|
+
|
|
54
|
+
async def probe() -> str:
|
|
55
|
+
from claude_agent_sdk import ClaudeAgentOptions, query
|
|
56
|
+
|
|
57
|
+
options = ClaudeAgentOptions(model="haiku", max_turns=3, allowed_tools=[], setting_sources=[], cwd=tempfile.mkdtemp())
|
|
58
|
+
result = ""
|
|
59
|
+
async for message in query(prompt="Reply with exactly: GO", options=options):
|
|
60
|
+
result = str(getattr(message, "result", "") or result)
|
|
61
|
+
return result
|
|
62
|
+
|
|
63
|
+
previous = os.environ.get("CLAUDE_CODE_OAUTH_TOKEN")
|
|
64
|
+
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = token
|
|
65
|
+
try:
|
|
66
|
+
result = asyncio.run(probe())
|
|
67
|
+
except Exception as e:
|
|
68
|
+
return False, f"{type(e).__name__}: {e}"
|
|
69
|
+
finally:
|
|
70
|
+
if previous is None:
|
|
71
|
+
os.environ.pop("CLAUDE_CODE_OAUTH_TOKEN", None)
|
|
72
|
+
else:
|
|
73
|
+
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = previous
|
|
74
|
+
if "GO" in result:
|
|
75
|
+
return True, "claude answered"
|
|
76
|
+
return False, f"unexpected reply: {result[:80]!r}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def check_slack(bot_token: str, team_id: str = "") -> tuple[bool, str]:
|
|
80
|
+
status, body = _post_json("https://slack.com/api/auth.test", {"Authorization": f"Bearer {bot_token}"})
|
|
81
|
+
if status != 200 or not body.get("ok"):
|
|
82
|
+
return False, f"auth.test failed: {body.get('error', status)}"
|
|
83
|
+
if team_id and body.get("team_id") != team_id:
|
|
84
|
+
return False, f"token belongs to team {body.get('team_id')}, not {team_id}"
|
|
85
|
+
return True, f"authed as @{body.get('user')} in team {body.get('team_id')}"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def check_slack_channel(bot_token: str, channel_id: str) -> tuple[bool, str]:
|
|
89
|
+
status, body = _get_json(f"https://slack.com/api/conversations.info?channel={channel_id}", {"Authorization": f"Bearer {bot_token}"})
|
|
90
|
+
if status != 200 or not body.get("ok"):
|
|
91
|
+
return False, f"{channel_id}: {body.get('error', status)}"
|
|
92
|
+
return True, f"{channel_id} = #{(body.get('channel') or {}).get('name', '?')}"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def check_slack_app_token(app_token: str) -> tuple[bool, str]:
|
|
96
|
+
if not app_token.startswith("xapp-"):
|
|
97
|
+
return False, "app-level token must start with xapp-"
|
|
98
|
+
return True, "app token format ok"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def github_app_jwt(app_id: str, private_key_pem: str) -> str:
|
|
102
|
+
import jwt
|
|
103
|
+
|
|
104
|
+
now = int(time.time())
|
|
105
|
+
return jwt.encode({"iat": now - 60, "exp": now + 540, "iss": app_id}, private_key_pem, algorithm="RS256")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def check_github_app(app_id: str, installation_id: str, private_key_pem: str, approved_repos: list[str] | None = None) -> tuple[bool, str]:
|
|
109
|
+
try:
|
|
110
|
+
token_jwt = github_app_jwt(app_id, private_key_pem)
|
|
111
|
+
except Exception as e:
|
|
112
|
+
return False, f"could not sign app jwt: {e}"
|
|
113
|
+
status, app = _get_json("https://api.github.com/app", {"Authorization": f"Bearer {token_jwt}", "Accept": "application/vnd.github+json"})
|
|
114
|
+
if status != 200:
|
|
115
|
+
return False, f"GET /app failed ({status}) — wrong app id or private key"
|
|
116
|
+
status, minted = _post_json(f"https://api.github.com/app/installations/{installation_id}/access_tokens", {"Authorization": f"Bearer {token_jwt}", "Accept": "application/vnd.github+json"})
|
|
117
|
+
if status != 201:
|
|
118
|
+
return False, f"token mint failed ({status}) — wrong installation id?"
|
|
119
|
+
installation_token = str(minted.get("token", ""))
|
|
120
|
+
status, repos = _get_json("https://api.github.com/installation/repositories?per_page=100", {"Authorization": f"Bearer {installation_token}", "Accept": "application/vnd.github+json"})
|
|
121
|
+
if status != 200:
|
|
122
|
+
return False, f"listing installation repositories failed ({status})"
|
|
123
|
+
accessible = {str(repo.get("full_name", "")) for repo in repos.get("repositories") or []}
|
|
124
|
+
missing = [repo for repo in (approved_repos or []) if repo not in accessible]
|
|
125
|
+
if missing:
|
|
126
|
+
return False, f"app '{app.get('slug')}' cannot access: {', '.join(missing)} — install it on those repositories"
|
|
127
|
+
return True, f"app '{app.get('slug')}' ok, {len(accessible)} repositories accessible"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def check_github_pat_repo(token: str, repo: str) -> tuple[bool, str]:
|
|
131
|
+
status, body = _get_json(f"https://api.github.com/repos/{repo}", {"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json"})
|
|
132
|
+
if status != 200:
|
|
133
|
+
return False, f"GET /repos/{repo} failed ({status})"
|
|
134
|
+
return True, f"{repo} reachable"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def check_jira(site: str, email: str, api_token: str) -> tuple[bool, str]:
|
|
138
|
+
status, body = _get_json(f"{site.rstrip('/')}/rest/api/3/myself", {"Authorization": _basic_auth(email, api_token)})
|
|
139
|
+
if status != 200:
|
|
140
|
+
return False, f"/myself failed ({status})"
|
|
141
|
+
return True, f"authed as {body.get('displayName')} ({body.get('emailAddress')})"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def check_jira_project(site: str, email: str, api_token: str, key: str) -> tuple[bool, str]:
|
|
145
|
+
status, body = _get_json(f"{site.rstrip('/')}/rest/api/3/project/{key}", {"Authorization": _basic_auth(email, api_token)})
|
|
146
|
+
if status != 200:
|
|
147
|
+
return False, f"project {key}: {status}"
|
|
148
|
+
return True, f"project {key} = {body.get('name')}"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def check_confluence(site: str, email: str, api_token: str) -> tuple[bool, str]:
|
|
152
|
+
status, body = _get_json(f"{site.rstrip('/')}/wiki/rest/api/space?limit=1", {"Authorization": _basic_auth(email, api_token)})
|
|
153
|
+
if status != 200:
|
|
154
|
+
return False, f"space list failed ({status})"
|
|
155
|
+
return True, "confluence reachable"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def check_sentry(organization: str, token: str) -> tuple[bool, str]:
|
|
159
|
+
status, body = _get_json(f"https://sentry.io/api/0/organizations/{organization}/", {"Authorization": f"Bearer {token}"})
|
|
160
|
+
if status != 200:
|
|
161
|
+
return False, f"org fetch failed ({status})"
|
|
162
|
+
return True, f"org '{body.get('slug')}' reachable"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def check_aws_role(role_arn: str, external_id: str) -> tuple[bool, str]:
|
|
166
|
+
try:
|
|
167
|
+
import boto3
|
|
168
|
+
|
|
169
|
+
sts = boto3.client("sts")
|
|
170
|
+
sts.assume_role(RoleArn=role_arn, RoleSessionName="taskboy-setup-check", ExternalId=external_id, DurationSeconds=900)
|
|
171
|
+
except Exception as e:
|
|
172
|
+
return False, f"{type(e).__name__}: {e}"
|
|
173
|
+
return True, f"assumed {role_arn.rsplit('/', 1)[-1]}"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def check_aws_secret(secret_name: str, region: str) -> tuple[bool, str]:
|
|
177
|
+
try:
|
|
178
|
+
import boto3
|
|
179
|
+
|
|
180
|
+
client = boto3.client("secretsmanager", region_name=region)
|
|
181
|
+
blob = json.loads(client.get_secret_value(SecretId=secret_name)["SecretString"])
|
|
182
|
+
except Exception as e:
|
|
183
|
+
return False, f"{type(e).__name__}: {e}"
|
|
184
|
+
return True, f"{secret_name}: {len(blob)} keys"
|