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.
- taskuary/__init__.py +2 -0
- taskuary/agents.py +218 -0
- taskuary/artifacts.py +209 -0
- taskuary/aws.py +226 -0
- taskuary/azure.py +334 -0
- taskuary/blackboard.py +150 -0
- taskuary/channels.py +768 -0
- taskuary/ci.py +237 -0
- taskuary/cli.py +47 -0
- taskuary/coder.py +132 -0
- taskuary/config.py +62 -0
- taskuary/db.py +63 -0
- taskuary/desktop.py +72 -0
- taskuary/devtools.py +352 -0
- taskuary/digest.py +104 -0
- taskuary/docsync.py +124 -0
- taskuary/github.py +136 -0
- taskuary/histgen.py +220 -0
- taskuary/imapmail.py +150 -0
- taskuary/ingest.py +344 -0
- taskuary/learn.py +181 -0
- taskuary/llm.py +175 -0
- taskuary/logs.py +17 -0
- taskuary/mcp.py +78 -0
- taskuary/messengers.py +205 -0
- taskuary/mssql.py +64 -0
- taskuary/outbound.py +221 -0
- taskuary/phone.py +88 -0
- taskuary/pm.py +310 -0
- taskuary/policy.py +59 -0
- taskuary/proof.py +194 -0
- taskuary/proposals.py +132 -0
- taskuary/reports.py +508 -0
- taskuary/reshape.py +201 -0
- taskuary/responder.py +199 -0
- taskuary/routing.py +115 -0
- taskuary/scopes.py +88 -0
- taskuary/server.py +1506 -0
- taskuary/store.py +711 -0
- taskuary/templates/coder.md +35 -0
- taskuary/templates/digest.md +5 -0
- taskuary/templates/learned.md +31 -0
- taskuary/templates/soul.md +44 -0
- taskuary/templates/style.md +14 -0
- taskuary/templates/triage.md +22 -0
- taskuary/terminal.py +971 -0
- taskuary/toil.py +57 -0
- taskuary/triage.py +123 -0
- taskuary/verdicts.py +73 -0
- taskuary/web/assets/index-6GBZ9nXN.css +32 -0
- taskuary/web/assets/index-Cjj87C2X.js +401 -0
- taskuary/web/favicon.ico +0 -0
- taskuary/web/favicon.png +0 -0
- taskuary/web/index.html +25 -0
- taskuary/whatsapp/bridge.mjs +101 -0
- taskuary/whatsapp/package.json +11 -0
- taskuary-0.2.0.dist-info/METADATA +424 -0
- taskuary-0.2.0.dist-info/RECORD +62 -0
- taskuary-0.2.0.dist-info/WHEEL +5 -0
- taskuary-0.2.0.dist-info/entry_points.txt +3 -0
- taskuary-0.2.0.dist-info/licenses/LICENSE +21 -0
- 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()
|