taskuary 0.2.0__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 (62) hide show
  1. taskuary/__init__.py +2 -0
  2. taskuary/agents.py +218 -0
  3. taskuary/artifacts.py +209 -0
  4. taskuary/aws.py +226 -0
  5. taskuary/azure.py +334 -0
  6. taskuary/blackboard.py +150 -0
  7. taskuary/channels.py +768 -0
  8. taskuary/ci.py +237 -0
  9. taskuary/cli.py +47 -0
  10. taskuary/coder.py +132 -0
  11. taskuary/config.py +62 -0
  12. taskuary/db.py +63 -0
  13. taskuary/desktop.py +72 -0
  14. taskuary/devtools.py +352 -0
  15. taskuary/digest.py +104 -0
  16. taskuary/docsync.py +124 -0
  17. taskuary/github.py +136 -0
  18. taskuary/histgen.py +220 -0
  19. taskuary/imapmail.py +150 -0
  20. taskuary/ingest.py +344 -0
  21. taskuary/learn.py +181 -0
  22. taskuary/llm.py +175 -0
  23. taskuary/logs.py +17 -0
  24. taskuary/mcp.py +78 -0
  25. taskuary/messengers.py +205 -0
  26. taskuary/mssql.py +64 -0
  27. taskuary/outbound.py +221 -0
  28. taskuary/phone.py +88 -0
  29. taskuary/pm.py +310 -0
  30. taskuary/policy.py +59 -0
  31. taskuary/proof.py +194 -0
  32. taskuary/proposals.py +132 -0
  33. taskuary/reports.py +508 -0
  34. taskuary/reshape.py +201 -0
  35. taskuary/responder.py +199 -0
  36. taskuary/routing.py +115 -0
  37. taskuary/scopes.py +88 -0
  38. taskuary/server.py +1506 -0
  39. taskuary/store.py +711 -0
  40. taskuary/templates/coder.md +35 -0
  41. taskuary/templates/digest.md +5 -0
  42. taskuary/templates/learned.md +31 -0
  43. taskuary/templates/soul.md +44 -0
  44. taskuary/templates/style.md +14 -0
  45. taskuary/templates/triage.md +22 -0
  46. taskuary/terminal.py +971 -0
  47. taskuary/toil.py +57 -0
  48. taskuary/triage.py +123 -0
  49. taskuary/verdicts.py +73 -0
  50. taskuary/web/assets/index-6GBZ9nXN.css +32 -0
  51. taskuary/web/assets/index-Cjj87C2X.js +401 -0
  52. taskuary/web/favicon.ico +0 -0
  53. taskuary/web/favicon.png +0 -0
  54. taskuary/web/index.html +25 -0
  55. taskuary/whatsapp/bridge.mjs +101 -0
  56. taskuary/whatsapp/package.json +11 -0
  57. taskuary-0.2.0.dist-info/METADATA +424 -0
  58. taskuary-0.2.0.dist-info/RECORD +62 -0
  59. taskuary-0.2.0.dist-info/WHEEL +5 -0
  60. taskuary-0.2.0.dist-info/entry_points.txt +3 -0
  61. taskuary-0.2.0.dist-info/licenses/LICENSE +21 -0
  62. taskuary-0.2.0.dist-info/top_level.txt +1 -0
taskuary/ci.py ADDED
@@ -0,0 +1,237 @@
1
+ """Closing the git loop: a task's branch becomes a DRAFT pull request, its CI is watched,
2
+ and a red build - or a human's review comment - goes back to the agent that wrote the code
3
+ instead of to nobody.
4
+
5
+ The human gate never moves. Taskuary opens drafts, never merges; pushing at all still needs
6
+ the GitHub card's 'Agents may push / deploy'. What this adds is the half that was missing:
7
+ after the agent stops typing, something watches what its work did to CI and hands the
8
+ failure back with the failing check named.
9
+
10
+ The PR lives on the task as a comment marker (PR_MARK) - no schema change, and the whole
11
+ history of what was opened stays readable in the task itself.
12
+ """
13
+ import json, re
14
+ from datetime import datetime
15
+ from loguru import logger
16
+
17
+ PR_MARK = 'PULL REQUEST' # comment marker: 'PULL REQUEST {json}'
18
+ PUSH_MARK = 'PUSHED' # the same, for work landed straight on the default branch
19
+ CI_MARK = 'CI FEEDBACK' # what we have already handed back, so it goes back ONCE
20
+ BRANCH_SAFE = re.compile(r'[^a-zA-Z0-9._/-]+')
21
+ TOKEN_URL = re.compile(r'https://[^@/]*@') # never let a PAT reach a log line or a comment
22
+
23
+
24
+ def _conn(store):
25
+ c = store.get_connector_by_type('github', with_secret=True)
26
+ if not (c and c.get('Secret')): raise RuntimeError('no GitHub PAT saved (Connectors → GitHub)')
27
+ return c
28
+
29
+
30
+ def _cfg(store) -> dict:
31
+ c = store.get_connector_by_type('github') or {}
32
+ try: return json.loads(c.get('ConfigJson') or '{}')
33
+ except ValueError: return {}
34
+
35
+
36
+ def pr_of(store, task_id: int):
37
+ """The pull request opened for this task, if any - read back off its comments."""
38
+ for c in reversed(store.list_comments(task_id)):
39
+ body = str(c.get('Body') or '')
40
+ if body.startswith(PR_MARK):
41
+ try: return json.loads(body[len(PR_MARK):].strip())
42
+ except ValueError: return None
43
+ return None
44
+
45
+
46
+ def landing_of(store, task_id: int):
47
+ """Where this task's work actually WENT - a pull request or a direct push, whichever
48
+ happened last. Both are the same question to everything downstream (what commit do I
49
+ check CI on?), so both answer it the same way: {kind, repo, sha, url, ...}."""
50
+ for c in reversed(store.list_comments(task_id)):
51
+ body = str(c.get('Body') or '')
52
+ for mark, kind in ((PR_MARK, 'pr'), (PUSH_MARK, 'push')):
53
+ if body.startswith(mark):
54
+ try: return {**json.loads(body[len(mark):].strip()), 'kind': kind}
55
+ except ValueError: return None
56
+ return None
57
+
58
+
59
+ def _save_pr(store, task_id: int, pr: dict, actor='ci'):
60
+ store.add_comment(task_id, actor, 'agent', f'{PR_MARK} {json.dumps(pr)}')
61
+
62
+
63
+ def _save_push(store, task_id: int, info: dict, actor='ci'):
64
+ store.add_comment(task_id, actor, 'agent', f'{PUSH_MARK} {json.dumps(info)}')
65
+
66
+
67
+ def flow(store) -> str:
68
+ """'pr' (a draft pull request) or 'direct' (straight onto the default branch). The
69
+ owner's call: on your own repo the PR is ceremony, on a shared one it is the review."""
70
+ return store.get_settings().get('git_flow', 'pr')
71
+
72
+
73
+ def branch_for(store, task_id: int, cwd: str) -> str:
74
+ """The branch the agent actually worked on. Whatever is checked out IS the answer - the
75
+ agent chose it (or stayed on the default), and guessing a name we did not create would
76
+ open a PR for a branch that does not exist."""
77
+ from .agents import _git
78
+ b = (_git(cwd, 'rev-parse', '--abbrev-ref', 'HEAD') or '').strip()
79
+ return BRANCH_SAFE.sub('-', b) if b and b != 'HEAD' else ''
80
+
81
+
82
+ def open_for_task(store, task_id: int, actor='owner') -> dict:
83
+ """Open (or find) the draft PR for this task's branch. Refuses the default branch: a PR
84
+ from main to main is not a thing, and it is the shape a mis-detected branch takes."""
85
+ from . import github, terminal as hub_term
86
+ from .store import task_ref
87
+ c = _conn(store)
88
+ if not store.get_settings().get('agent_push_enabled') == '1':
89
+ raise RuntimeError("pushing is off - flip 'Agents may push / deploy' on the GitHub card first")
90
+ ses = hub_term.session_for(task_id)
91
+ cwd = getattr(ses, 'cwd', None)
92
+ if not cwd: raise RuntimeError('no session on this task to read a branch from')
93
+ repo, _ = hub_term.guess_repo(store, task_id, {})
94
+ repo = repo or _cfg(store).get('default_repo')
95
+ if not repo: raise RuntimeError('no repository known for this task')
96
+ branch = branch_for(store, task_id, cwd)
97
+ base = _cfg(store).get('default_base') or 'master'
98
+ if not branch or branch == base:
99
+ raise RuntimeError(f"the checkout is on '{branch or 'a detached HEAD'}' - the agent needs its own "
100
+ 'branch before a pull request can be opened')
101
+ t = store.get_task(task_id) or {}
102
+ body = (f"Opened by Taskuary for {task_ref(task_id)}.\n\n{(t.get('Summary') or '')[:1500]}\n\n"
103
+ '_Draft: review and merge yourself - Taskuary never merges._')
104
+ pr = github.open_pr(c['Secret'], repo, branch, base, f"[{task_ref(task_id)}] {t.get('Title') or 'work'}"[:120], body)
105
+ pr['repo'], pr['checks'], pr['checked_at'] = repo, None, None
106
+ _save_pr(store, task_id, pr, actor)
107
+ store.audit('task', task_id, 'pr_opened', actor, detail={'repo': repo, 'number': pr['number']})
108
+ logger.info(f'PR #{pr["number"]} opened for task {task_id} ({repo} {branch} -> {base})')
109
+ return pr
110
+
111
+
112
+ def _where(store, task_id: int):
113
+ """(cwd, repo, base, branch) for a task with a session - everything the two landing
114
+ roads both need, and the same refusals for both."""
115
+ from . import terminal as hub_term
116
+ ses = hub_term.session_for(task_id)
117
+ cwd = getattr(ses, 'cwd', None)
118
+ if not cwd: raise RuntimeError('no session on this task to read a checkout from')
119
+ repo, _ = hub_term.guess_repo(store, task_id, {})
120
+ repo = repo or _cfg(store).get('default_repo')
121
+ if not repo: raise RuntimeError('no repository known for this task')
122
+ base = _cfg(store).get('default_base') or 'master'
123
+ return cwd, repo, base, branch_for(store, task_id, cwd)
124
+
125
+
126
+ def push_direct(store, task_id: int, actor='owner') -> dict:
127
+ """Land the work straight on the default branch - no pull request. What the owner of
128
+ their own repo usually wants, and never the default.
129
+
130
+ Deliberately narrow: it pushes COMMITS THAT ALREADY EXIST. A dirty tree is refused
131
+ rather than committed on the agent's behalf (a generated commit message over changes
132
+ nobody read is exactly the step this product does not take), and nothing ahead of the
133
+ remote is 'nothing to do', not an error. Force is never passed: a rejected push means
134
+ the branch moved underneath you, and the answer is to pull, not to overwrite."""
135
+ from .agents import _git
136
+ from .store import task_ref
137
+ if store.get_settings().get('agent_push_enabled') != '1':
138
+ raise RuntimeError("pushing is off - flip 'Agents may push / deploy' on the GitHub card first")
139
+ cwd, repo, base, branch = _where(store, task_id)
140
+ if _git(cwd, 'status', '--porcelain').strip():
141
+ raise RuntimeError('the checkout has uncommitted changes - the agent should commit them first '
142
+ '(Taskuary will not write a commit message for work nobody has read)')
143
+ _git(cwd, 'fetch', 'origin', base)
144
+ ahead = (_git(cwd, 'rev-list', '--count', f'origin/{base}..HEAD') or '0').strip()
145
+ if ahead in ('', '0'):
146
+ raise RuntimeError(f'nothing to push - HEAD is not ahead of origin/{base}')
147
+ sha = (_git(cwd, 'rev-parse', 'HEAD') or '').strip()
148
+ out = _git(cwd, 'push', 'origin', f'HEAD:{base}')
149
+ # git says nothing useful on success; a rejection is what we must not swallow
150
+ if re.search(r'rejected|error:|fatal:', out or '', re.I):
151
+ raise RuntimeError('git refused the push: ' + TOKEN_URL.sub('https://', out)[:300]
152
+ + ' - pull and rebase, then push again (Taskuary never force-pushes)')
153
+ info = {'repo': repo, 'branch': base, 'from': branch, 'sha': sha, 'commits': int(ahead),
154
+ 'url': f'https://github.com/{repo}/commit/{sha}', 'state': 'pushed',
155
+ 'checks': None, 'checked_at': None}
156
+ _save_push(store, task_id, info, actor)
157
+ store.add_comment(task_id, actor, 'human',
158
+ f"Pushed {ahead} commit(s) straight to {base} ({sha[:7]}) - no pull request.")
159
+ store.audit('task', task_id, 'pushed_direct', actor,
160
+ detail={'repo': repo, 'base': base, 'sha': sha, 'commits': int(ahead)})
161
+ logger.info(f'task {task_id}: {ahead} commit(s) pushed to {repo} {base} ({sha[:7]})')
162
+ return info
163
+
164
+
165
+ def land(store, task_id: int, actor='owner') -> dict:
166
+ """Publish this task's work the way the owner configured it: a draft pull request, or
167
+ straight onto the default branch. One door, so the button, the endpoint and an approved
168
+ proposal cannot disagree about which flow is in force."""
169
+ return (push_direct if flow(store) == 'direct' else open_for_task)(store, task_id, actor)
170
+
171
+
172
+ def _already_fed(store, task_id: int) -> set:
173
+ """Which failures this task has already been told about - a red build must reach the
174
+ agent once, not on every poll until it is fixed."""
175
+ out = set()
176
+ for c in store.list_comments(task_id):
177
+ b = str(c.get('Body') or '')
178
+ if b.startswith(CI_MARK): out.add(b.split('\n', 1)[0][len(CI_MARK):].strip())
179
+ return out
180
+
181
+
182
+ def check_task(store, task_id: int, llm=None) -> dict:
183
+ """Poll where this task's work landed - a PR or a direct push - refresh its checks, and
184
+ when they are RED hand the failure back to the live session (or put the task back on
185
+ the owner when nothing is running). Never raises into the poller."""
186
+ from . import github, terminal as hub_term
187
+ at = landing_of(store, task_id)
188
+ if not at: return {'state': 'not-landed'}
189
+ try:
190
+ c = _conn(store)
191
+ if at['kind'] == 'pr':
192
+ fresh = github.pr(c['Secret'], at['repo'], at['number'])
193
+ at = {**at, **fresh}
194
+ ck = github.checks(c['Secret'], at['repo'], at['sha'])
195
+ except Exception as e:
196
+ logger.warning(f'ci check failed for task {task_id}: {e}')
197
+ return {'state': 'error', 'error': str(e)[:200]}
198
+ at = {**at, 'checks': ck, 'checked_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
199
+ where = f"the pull request for this task (#{at['number']}" if at['kind'] == 'pr' \
200
+ else f"{at['branch']} (the commit this task pushed"
201
+ (_save_pr if at['kind'] == 'pr' else _save_push)(store, task_id, {k: v for k, v in at.items() if k != 'kind'})
202
+ ref = at.get('number') if at['kind'] == 'pr' else at['sha'][:7]
203
+ if ck['state'] != 'failure': return {'state': ck['state'], 'at': ref, 'kind': at['kind']}
204
+ key = f"{at['sha'][:10]}:{','.join(sorted(f['name'] or '?' for f in ck['failed']))}"
205
+ if key in _already_fed(store, task_id): return {'state': 'failure', 'fed': False, 'at': ref}
206
+ named = '\n'.join(f"- {f['name']}: {f['summary'] or 'see the run log'}" for f in ck['failed'])
207
+ # direct mode has no PR to hold the change back, so the instruction changes with it:
208
+ # the broken commit is ALREADY on the branch, and that is the thing to say out loud
209
+ tail = ('Fix the cause and push again; do not merge.' if at['kind'] == 'pr'
210
+ else 'This commit is ALREADY on the branch - fix it forward and push again.')
211
+ text = (f"CI is failing on {where}, commit {at['sha'][:7]}). "
212
+ f"The failing checks are:\n{named}\n{tail}")
213
+ store.add_comment(task_id, 'ci', 'agent', f'{CI_MARK} {key}\n{text}')
214
+ handed = hub_term.say_to_task(store, task_id, {'FromName': 'CI', 'Channel': 'github', 'BodyText': text}, 'ci')
215
+ if not handed:
216
+ # nobody is at the keyboard: it becomes work waiting on the owner, not a silent red
217
+ store.update_task(task_id, {'Status': 'open'}, 'ci')
218
+ store.audit('task', task_id, 'ci_failure', 'ci', detail={'at': ref, 'kind': at['kind'],
219
+ 'checks': [f['name'] for f in ck['failed']],
220
+ 'handed_to_agent': handed})
221
+ return {'state': 'failure', 'fed': True, 'handed': handed, 'at': ref, 'kind': at['kind']}
222
+
223
+
224
+ def poll(store, llm=None) -> int:
225
+ """Every task whose work has landed - PR or direct push - checked once. Called from the
226
+ same sync as everything else; off unless the owner turned ci_watch on."""
227
+ if store.get_settings().get('ci_watch', 'off') == 'off': return 0
228
+ n = 0
229
+ for t in store.list_tasks():
230
+ if t['Status'] in ('done', 'dropped'): continue
231
+ at = landing_of(store, t['TaskId'])
232
+ # a merged/closed PR is finished business; a direct push is only ever checked while
233
+ # its checks could still be running, which the state below settles
234
+ if not at or at.get('state') == 'closed': continue
235
+ out = check_task(store, t['TaskId'], llm)
236
+ n += 1 if out.get('fed') else 0
237
+ return n
taskuary/cli.py ADDED
@@ -0,0 +1,47 @@
1
+ """`taskuary` - start the local server and open the app. Everything lives in ~/.taskuary."""
2
+ import argparse, socket, threading, time, webbrowser
3
+ import uvicorn
4
+ from . import __version__, config
5
+
6
+
7
+ def _busy(host, port):
8
+ with socket.socket() as s: return s.connect_ex((host, port)) == 0
9
+
10
+ def _is_taskuary(url):
11
+ try:
12
+ import requests
13
+ return requests.get(f'{url}/api/settings', timeout=2).status_code in (200, 401)
14
+ except Exception:
15
+ return False
16
+
17
+
18
+ def main():
19
+ ap = argparse.ArgumentParser(prog='taskuary', description='Automate your job - the local-first agent work hub.')
20
+ ap.add_argument('--port', type=int, help='override [server].port')
21
+ ap.add_argument('--no-browser', action='store_true')
22
+ ap.add_argument('--debug', action='store_true', help='verbose console logging (requests, report runs, errors)')
23
+ ap.add_argument('--version', action='version', version=f'taskuary {__version__}')
24
+ args = ap.parse_args()
25
+ from .logs import setup as setup_logs
26
+ setup_logs(args.debug)
27
+ cfg = config.load()
28
+ host, port = cfg['server']['host'], args.port or cfg['server']['port']
29
+ url = f'http://{host}:{port}'
30
+ if _busy(host, port):
31
+ if _is_taskuary(url):
32
+ # don't crash into 'address already in use' - reuse the running instance
33
+ print(f'Taskuary is already running at {url} - opening it.')
34
+ if not args.no_browser: webbrowser.open(url)
35
+ return
36
+ from .desktop import free_port
37
+ old, port = port, free_port(host)
38
+ url = f'http://{host}:{port}'
39
+ print(f'port {old} is in use by something else - using {port} instead')
40
+ print(f'Taskuary {__version__} - {url} (data: {config.db_path()})')
41
+ if not args.no_browser:
42
+ threading.Thread(target=lambda: (time.sleep(1.2), webbrowser.open(url)), daemon=True).start()
43
+ uvicorn.run('taskuary.server:app', host=host, port=port, log_level='warning')
44
+
45
+
46
+ if __name__ == '__main__':
47
+ main()
taskuary/coder.py ADDED
@@ -0,0 +1,132 @@
1
+ """How a coding task ENDS. The work itself happens in a live session you watch (terminal.py);
2
+ this closes the loop: the transcript becomes the report, the responder drafts the reply the
3
+ sender gets, and the task waits on you to send it.
4
+ """
5
+ import json, re
6
+ from loguru import logger
7
+
8
+ FIELDS = ('determination', 'actions', 'summary')
9
+
10
+
11
+ TRANSCRIPT_SYSTEM = (
12
+ 'You are reading the terminal transcript of a coding agent that has just worked a task for '
13
+ "the owner, who has now closed the session. Write the owner's record of what happened, from "
14
+ 'the transcript ALONE - never a step it did not take, never a claim it did not make.\n'
15
+ 'Output ONLY this JSON: {"determination": "...", "actions": "...", "summary": "..."} - '
16
+ 'determination is what was decided and why, actions is what was actually changed (files, '
17
+ 'commands, records, ids), summary is the two-sentence version for someone who read none of it.')
18
+
19
+
20
+ def report_from_transcript(store, task_id: int, transcript: str, agent: str = 'coder') -> dict:
21
+ """The report, written from what is already on screen. The agent is never asked for prose:
22
+ by the time you click Done you are done talking to it, and a transcript cannot argue. No AI
23
+ configured (or a bad answer) files the transcript tail itself - the record never disappears."""
24
+ from .llm import build_llm
25
+ blank = dict.fromkeys(FIELDS, '')
26
+ if not (transcript or '').strip(): return blank | {'summary': '(the session ended with nothing on screen)'}
27
+ try:
28
+ llm = build_llm(store)
29
+ if not llm: raise RuntimeError('no AI connector is set up to write the report')
30
+ out = llm(TRANSCRIPT_SYSTEM, f"Task: {(store.get_task(task_id) or {}).get('Title') or ''}\n\n"
31
+ f'Transcript:\n{transcript}', max_tokens=900)
32
+ j = json.loads(re.sub(r'^```(json)?|```$', '', (out or '').strip(), flags=re.M))
33
+ rep = blank | {k: str(j.get(k) or '') for k in ('determination', 'actions', 'summary')}
34
+ if not any(rep.values()): raise ValueError('empty report')
35
+ return rep
36
+ except Exception as e:
37
+ logger.warning(f'transcript report failed for task {task_id}: {e}')
38
+ return blank | {'summary': transcript[-2000:]}
39
+
40
+
41
+ PAUSE_MARKER = 'HANDOVER NOTE'
42
+ PAUSE_SYSTEM = (
43
+ 'You are reading the terminal transcript of a coding agent that has been PAUSED mid-task - the '
44
+ 'owner is stopping for now and the same work will be picked up later, by an agent with no memory '
45
+ 'of this session. Write the handover note it will be given. From the transcript ALONE.\n'
46
+ 'Output ONLY this JSON: {"found": "...", "did": "...", "next": "..."} - found is what it worked '
47
+ 'out about the problem (causes, file and record names, ids, dead ends worth not repeating), did '
48
+ 'is what it already changed, next is the concrete next step it was about to take. Say "nothing '
49
+ 'yet" in a field rather than inventing.')
50
+
51
+
52
+ def pause_note(store, task_id: int, transcript: str) -> str:
53
+ """What a paused session knew, in the words the next session needs. A pty has no resumable
54
+ id (no --resume for a TUI), so the note IS the continuity - it gets typed into the next
55
+ session by terminal.seed_text. No AI, or a bad answer, keeps the transcript tail instead."""
56
+ from .llm import build_llm
57
+ if not (transcript or '').strip(): return 'Nothing on screen - the session was paused before it did anything.'
58
+ try:
59
+ llm = build_llm(store)
60
+ if not llm: raise RuntimeError('no AI connector is set up to write the note')
61
+ out = llm(PAUSE_SYSTEM, f"Task: {(store.get_task(task_id) or {}).get('Title') or ''}\n\n"
62
+ f'Transcript:\n{transcript}', max_tokens=900)
63
+ j = json.loads(re.sub(r'^```(json)?|```$', '', (out or '').strip(), flags=re.M))
64
+ note = '\n'.join(f'{k.capitalize()}: {j[k]}' for k in ('found', 'did', 'next') if str(j.get(k) or '').strip())
65
+ if not note: raise ValueError('empty note')
66
+ return note
67
+ except Exception as e:
68
+ logger.warning(f'pause note failed for task {task_id}: {e}')
69
+ return f'(no AI note - the last of the session, verbatim)\n{transcript[-2000:]}'
70
+
71
+
72
+ def resolution_text(rep: dict) -> str:
73
+ return '\n'.join(f'{k.capitalize()}: {rep[k]}' for k in ('determination', 'actions', 'summary') if rep.get(k))
74
+
75
+
76
+ def reply_target(store, task_id: int):
77
+ """Which message a reply answers: the last one that came IN. Our own sent mail rides in
78
+ the chain as 'context', and answering that would mail ourselves."""
79
+ return next((m['MessageId'] for m in reversed(store.list_messages(task_id)) if m.get('Status') != 'context'), None)
80
+
81
+
82
+ def finish(store, task_id: int, rep: dict, run_id: int = None, actor: str = 'coder') -> dict:
83
+ """The end of finished work: the responder drafts the reply the sender gets and the task waits
84
+ on you to send it. Nothing to reply to means nothing to wait for, so it just closes."""
85
+ # a held draft is itself proof there is someone waiting on an answer, so it names the message
86
+ # to reply to when reply_target cannot find one (a chat thread, a promoted feed item)
87
+ held = store.held_review(task_id) or {}
88
+ mid = reply_target(store, task_id) or held.get('MessageId')
89
+ # can this channel carry a reply at all? outbound.can_reply is the ONE answer - the
90
+ # owner's per-channel setting, plus the rules that are not theirs to change (a public
91
+ # GitHub comment needs that card's switch; a tracker is read-only by design). With it
92
+ # off, finished work just closes: the report lands on the task, no dead-end draft.
93
+ if mid:
94
+ from .outbound import can_reply
95
+ if not can_reply(store, (store.get_message(mid) or {}).get('Channel')): mid = None
96
+ if mid: raise_reply(store, task_id, mid, run_id, rep)
97
+ store.update_task(task_id, {'Status': 'waiting' if mid else 'done'}, actor)
98
+ return {'drafting': bool(mid), 'message_id': mid}
99
+
100
+
101
+ def raise_reply(store, task_id: int, mid: int, run_id: int, rep: dict) -> None:
102
+ """The session reported; the responder writes what the sender actually reads. One voice for
103
+ every reply the owner sends - and no coding CLI drafting prose from inside a repo. A draft
104
+ that fails to write still leaves the review standing: 'Draft with AI' retries it.
105
+
106
+ A reply triage already drafted from the mail alone was HELD when the session started - it
107
+ promised what the agent had not looked at yet. That same review comes back here and is
108
+ rewritten from the report, so the sender gets one answer, and it is the true one."""
109
+ from . import responder
110
+ held = store.held_review(task_id, mid) or store.held_review(task_id)
111
+ if held:
112
+ rid = held['ReviewId']
113
+ store.unhold_review(rid, 'the agent finished - the reply is rewritten from what it found')
114
+ store.update_review_reason(rid, 'the agent finished - the reply is rewritten from what it found', run_id)
115
+ else:
116
+ rid = store.add_review({'TaskId': task_id, 'MessageId': mid, 'RunId': run_id, 'Kind': 'draft_reply',
117
+ 'Status': 'pending', 'Reason': 'coder finished the work - reply awaiting approval'})
118
+ try: responder.write_draft(store, task_id, rid, resolution_text(rep), 'coder')
119
+ except Exception as e: logger.warning(f'reply draft failed for task {task_id}: {e}')
120
+ # the ping that matters most: work FINISHED and its reply is sitting in Review on you
121
+ if (store.get_settings().get('notify_level') or 'needs_me') != 'off':
122
+ from .outbound import notify
123
+ from .phone import ping_tail
124
+ from .store import task_ref
125
+ t = store.get_task(task_id) or {}
126
+ head = (t.get('Title') or '')[:100]
127
+ # with phone approvals on, the ping carries the draft and the [rvN] tag - replying
128
+ # 'approve' in the chat sends it (phone.py), so 'done' really can mean done
129
+ tail = ping_tail(store, rid, (store.get_review(rid) or {}).get('DraftText'))
130
+ try: notify(store, f'{task_ref(task_id)} is done - the reply is drafted and waiting on '
131
+ f'your approval in Review.\n{head}{tail}')
132
+ except Exception as e: logger.warning(f'notify failed for task {task_id}: {e}')
taskuary/config.py ADDED
@@ -0,0 +1,62 @@
1
+ """Config: ~/.taskuary/config.toml (or TASKUARY_HOME) - zero assumptions, all defaults sane.
2
+
3
+ [server] port/host/token; [agents.<name>] cmd/args/resume_args/timeout/cwd/cwd_map;
4
+ [github] token/default_repo. Everything is optional: `taskuary` runs with no config at all
5
+ (SQLite store, stub agent, localhost server).
6
+ """
7
+ import json, os
8
+ try: import tomllib
9
+ except ImportError: import tomli as tomllib # py3.10
10
+ from pathlib import Path
11
+
12
+ def home() -> Path:
13
+ p = Path(os.getenv('TASKUARY_HOME') or Path.home() / '.taskuary')
14
+ old = Path.home() / '.taskhub'
15
+ # one-time migration from the pre-rename data dir
16
+ if not os.getenv('TASKUARY_HOME') and not p.exists() and old.exists(): old.rename(p)
17
+ p.mkdir(parents=True, exist_ok=True)
18
+ return p
19
+
20
+ def load() -> dict:
21
+ f = home() / 'config.toml'
22
+ cfg = tomllib.loads(f.read_text(encoding='utf-8')) if f.exists() else {}
23
+ cfg.setdefault('server', {})
24
+ cfg['server'].setdefault('host', '127.0.0.1')
25
+ cfg['server'].setdefault('port', 7787)
26
+ # --dangerously-skip-permissions matters: without it a headless claude waits forever
27
+ # for permission approvals nobody can click. stream-json (+ required --verbose) makes
28
+ # claude emit events AS IT WORKS so the Board can stream the run live.
29
+ cfg.setdefault('agents', {'coder': {'cmd': 'claude',
30
+ 'args': ['-p', '--dangerously-skip-permissions',
31
+ '--output-format', 'stream-json', '--verbose'],
32
+ 'resume_args': ['--resume'], 'timeout': 1500}})
33
+ cfg.setdefault('github', {})
34
+ return cfg
35
+
36
+ def db_path() -> str:
37
+ return str(home() / 'taskuary.db')
38
+
39
+
40
+ def _tval(v):
41
+ if isinstance(v, bool): return 'true' if v else 'false'
42
+ if isinstance(v, (int, float)): return str(v)
43
+ if isinstance(v, list): return '[' + ', '.join(_tval(o) for o in v) + ']'
44
+ return json.dumps(str(v)) # json string escaping == toml basic string escaping
45
+
46
+ def dumps_toml(d: dict, prefix='') -> str:
47
+ """Minimal TOML writer for our config shapes (scalars, string lists, nested tables).
48
+ tomllib is stdlib-read-only; this keeps the UI able to persist config with no new deps."""
49
+ lines, tables = [], []
50
+ for k, v in d.items():
51
+ key = k if k.replace('_', '').replace('-', '').isalnum() else json.dumps(k)
52
+ if isinstance(v, dict): tables.append((key, v))
53
+ else: lines.append(f'{key} = {_tval(v)}')
54
+ out = (f'[{prefix}]\n' if prefix and lines else '') + '\n'.join(lines)
55
+ for key, v in tables:
56
+ sub = dumps_toml(v, f'{prefix}.{key}' if prefix else key)
57
+ if sub: out += ('\n\n' if out else '') + sub
58
+ return out
59
+
60
+ def save(cfg: dict):
61
+ """Persist config to ~/.taskuary/config.toml (round-trips through tomllib in tests)."""
62
+ (home() / 'config.toml').write_text(dumps_toml(cfg) + '\n', encoding='utf-8')
taskuary/db.py ADDED
@@ -0,0 +1,63 @@
1
+ """Any database by connection string - one card, every engine. Two roads by shape:
2
+ a URL ('postgresql://user:pw@host/db', 'mysql+pymysql://...', 'snowflake://...') runs
3
+ through SQLAlchemy; anything else ('DRIVER={...};SERVER=...;') is a raw ODBC string via
4
+ pyodbc. A {password} placeholder in the string is filled from the card's write-only
5
+ secret, so the saved config never carries the password itself.
6
+ """
7
+
8
+
9
+ def conn_str(cfg: dict) -> str:
10
+ cs = (cfg.get('conn_str') or '').strip()
11
+ if not cs: raise RuntimeError('no connection string set - Connectors → Any database (connection string)')
12
+ return cs.replace('{password}', cfg.get('password') or '')
13
+
14
+
15
+ def is_url(cs: str) -> bool: return '://' in cs
16
+
17
+
18
+ def _rows_sqlalchemy(cs, query, n):
19
+ try: import sqlalchemy
20
+ except ImportError:
21
+ # name the package, not the extra: `taskuary[db]` silently no-ops when the install's
22
+ # metadata predates the extra (see aws._boto3)
23
+ raise RuntimeError('sqlalchemy is not installed - run: pip install sqlalchemy, plus the engine '
24
+ 'driver (psycopg2-binary for postgres, pymysql for mysql, snowflake-sqlalchemy…)')
25
+ eng = sqlalchemy.create_engine(cs, pool_pre_ping=True)
26
+ try:
27
+ with eng.connect() as cx:
28
+ return [dict(r._mapping) for r in cx.execute(sqlalchemy.text(query)).fetchmany(n)]
29
+ finally:
30
+ eng.dispose()
31
+
32
+
33
+ def _rows_odbc(cs, query, n):
34
+ import pyodbc
35
+ with pyodbc.connect(cs, timeout=10) as cx:
36
+ cur = cx.cursor().execute(query)
37
+ cols = [c[0] for c in cur.description or []]
38
+ return [dict(zip(cols, r)) for r in cur.fetchmany(n)]
39
+
40
+
41
+ def run_query(cfg: dict, limit: int) -> list:
42
+ cs = conn_str(cfg)
43
+ return (_rows_sqlalchemy if is_url(cs) else _rows_odbc)(cs, cfg['query'], limit)
44
+
45
+
46
+ def test(cfg: dict) -> dict:
47
+ """Connect and run a probe; never raises - errors come back as data. Engines with no
48
+ bare SELECT 1 (Oracle wants FROM DUAL) can set 'test_query' on the card."""
49
+ try:
50
+ cs = conn_str(cfg)
51
+ rows = (_rows_sqlalchemy if is_url(cs) else _rows_odbc)(cs, cfg.get('test_query') or 'SELECT 1', 1)
52
+ eng = cs.split('://', 1)[0] if is_url(cs) else 'odbc'
53
+ return {'ok': True, 'engine': eng, 'detail': f'connected ({eng}) · probe returned {len(rows)} row(s)'}
54
+ except Exception as e:
55
+ return {'ok': False, 'error': str(e)[:500]}
56
+
57
+
58
+ def run_report(cfg: dict):
59
+ """Report executor: (headline, body). One row past the limit is fetched so the
60
+ headline can admit when the result was cut (see reports.rows_out)."""
61
+ from .reports import row_limit, rows_out
62
+ lim, mine = row_limit(cfg)
63
+ return rows_out(run_query(cfg, lim + 1), lim, mine=mine)
taskuary/desktop.py ADDED
@@ -0,0 +1,72 @@
1
+ """Taskuary desktop: the same server + UI in a native window, shipped as one executable.
2
+
3
+ The FastAPI app runs on a free localhost port in a background thread; pywebview (Edge
4
+ WebView2 on Windows) hosts the UI. No pywebview -> graceful fallback to the default
5
+ browser, so `taskuary-desktop` is useful even from a bare pip install. Build the single
6
+ exe with `pyinstaller taskuary.spec` (see the spec at the repo root).
7
+ """
8
+ import io, socket, sys, threading, time, webbrowser
9
+
10
+ # Windowed (console=False) exe: std streams are None, but uvicorn's logging setup calls
11
+ # sys.stdout.isatty() and loguru writes to stderr - shim BEFORE importing uvicorn.
12
+ for _s in ('stdout', 'stderr'):
13
+ if getattr(sys, _s) is None: setattr(sys, _s, io.StringIO())
14
+
15
+ import uvicorn
16
+
17
+
18
+ def free_port(host='127.0.0.1') -> int:
19
+ with socket.socket() as s:
20
+ s.bind((host, 0)); return s.getsockname()[1]
21
+
22
+
23
+ def start_server(host='127.0.0.1', port=None):
24
+ """Run the app in a daemon thread; returns (server, url) once it accepts connections."""
25
+ from taskuary.server import app # absolute: PyInstaller runs this file as a script
26
+ port = port or free_port(host)
27
+ server = uvicorn.Server(uvicorn.Config(app, host=host, port=port, log_level='warning'))
28
+ threading.Thread(target=server.run, daemon=True).start()
29
+ for _ in range(200):
30
+ if server.started: break
31
+ time.sleep(0.05)
32
+ return server, f'http://{host}:{port}'
33
+
34
+
35
+ def main():
36
+ from taskuary import __version__, config
37
+ argv = sys.argv[1:]
38
+ from taskuary.logs import setup as setup_logs
39
+ setup_logs('--debug' in argv)
40
+ port = int(argv[argv.index('--port') + 1]) if '--port' in argv else None
41
+ try:
42
+ server, url = start_server(port=port)
43
+ except Exception:
44
+ import traceback
45
+ try: (config.home() / 'desktop-error.log').write_text(traceback.format_exc(), encoding='utf-8')
46
+ except OSError: pass
47
+ raise
48
+ print(f'Taskuary {__version__} desktop - {url} (data: {config.db_path()})')
49
+ if '--server-only' in argv: # headless mode: CI smoke tests, or run as a service
50
+ try:
51
+ while not server.should_exit: time.sleep(1)
52
+ except KeyboardInterrupt: pass
53
+ return
54
+ try:
55
+ import webview
56
+ webview.create_window('Taskuary', url, width=1280, height=840, min_size=(900, 600))
57
+ webview.start()
58
+ except Exception:
59
+ # no native window (pywebview missing or its runtime broke) -> browser fallback,
60
+ # never an error dialog; the traceback lands next to the data for diagnosis
61
+ import traceback
62
+ try: (config.home() / 'desktop-error.log').write_text(traceback.format_exc(), encoding='utf-8')
63
+ except OSError: pass
64
+ webbrowser.open(url)
65
+ try:
66
+ while True: time.sleep(3600)
67
+ except KeyboardInterrupt: pass
68
+ server.should_exit = True
69
+
70
+
71
+ if __name__ == '__main__':
72
+ main()