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
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))
@@ -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"