taskuary 0.2.1__tar.gz → 0.3.0__tar.gz
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-0.2.1/taskuary.egg-info → taskuary-0.3.0}/PKG-INFO +34 -7
- {taskuary-0.2.1 → taskuary-0.3.0}/README.md +33 -6
- {taskuary-0.2.1 → taskuary-0.3.0}/pyproject.toml +1 -1
- taskuary-0.3.0/taskuary/__init__.py +27 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/agents.py +14 -6
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/aws.py +76 -3
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/blackboard.py +2 -2
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/channels.py +32 -5
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/ci.py +63 -3
- taskuary-0.3.0/taskuary/cli.py +73 -0
- taskuary-0.3.0/taskuary/clis.py +49 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/config.py +33 -6
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/github.py +8 -4
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/histgen.py +27 -2
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/imapmail.py +7 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/ingest.py +195 -18
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/outbound.py +40 -0
- taskuary-0.3.0/taskuary/promptmap.py +134 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/proof.py +25 -6
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/reports.py +165 -8
- taskuary-0.3.0/taskuary/research.py +113 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/responder.py +14 -4
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/routing.py +14 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/scopes.py +3 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/server.py +153 -21
- taskuary-0.3.0/taskuary/setup.py +106 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/store.py +58 -16
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/templates/triage.md +4 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/terminal.py +7 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/triage.py +46 -4
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/verdicts.py +23 -0
- taskuary-0.3.0/taskuary/web/assets/index-QtBkB3cc.js +405 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/web/index.html +5 -5
- taskuary-0.3.0/taskuary/webguard.py +79 -0
- {taskuary-0.2.1 → taskuary-0.3.0/taskuary.egg-info}/PKG-INFO +34 -7
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary.egg-info/SOURCES.txt +21 -2
- {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_api.py +40 -2
- taskuary-0.3.0/tests/test_audit_chain.py +83 -0
- taskuary-0.3.0/tests/test_aws_region.py +101 -0
- taskuary-0.3.0/tests/test_cc_triage.py +149 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_cloud.py +2 -1
- taskuary-0.3.0/tests/test_config.py +91 -0
- taskuary-0.3.0/tests/test_cwlogs_paging.py +103 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_diff_review.py +68 -2
- taskuary-0.3.0/tests/test_docs_flow.py +121 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_histgen.py +47 -0
- taskuary-0.3.0/tests/test_local_file.py +123 -0
- taskuary-0.3.0/tests/test_note_retrieval.py +137 -0
- taskuary-0.3.0/tests/test_outbound_reports.py +143 -0
- taskuary-0.3.0/tests/test_pr_comments.py +115 -0
- taskuary-0.3.0/tests/test_promptmap_and_catalog.py +138 -0
- taskuary-0.3.0/tests/test_research.py +151 -0
- taskuary-0.3.0/tests/test_setup.py +283 -0
- taskuary-0.3.0/tests/test_verdict_sticks.py +269 -0
- taskuary-0.3.0/tests/test_webguard.py +119 -0
- taskuary-0.2.1/taskuary/__init__.py +0 -2
- taskuary-0.2.1/taskuary/cli.py +0 -47
- taskuary-0.2.1/taskuary/web/assets/index-Cjj87C2X.js +0 -401
- taskuary-0.2.1/tests/test_config.py +0 -32
- {taskuary-0.2.1 → taskuary-0.3.0}/LICENSE +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/setup.cfg +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/artifacts.py +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/azure.py +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/coder.py +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/db.py +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/desktop.py +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/devtools.py +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/digest.py +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/docsync.py +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/learn.py +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/llm.py +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/logs.py +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/mcp.py +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/messengers.py +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/mssql.py +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/phone.py +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/pm.py +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/policy.py +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/proposals.py +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/reshape.py +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/templates/coder.md +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/templates/digest.md +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/templates/learned.md +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/templates/soul.md +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/templates/style.md +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/toil.py +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/web/assets/index-6GBZ9nXN.css +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/web/favicon.ico +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/web/favicon.png +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/whatsapp/bridge.mjs +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary/whatsapp/package.json +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary.egg-info/dependency_links.txt +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary.egg-info/entry_points.txt +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary.egg-info/requires.txt +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/taskuary.egg-info/top_level.txt +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_answers.py +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_blackboard.py +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_cloud_sources.py +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_core.py +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_desktop.py +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_devtools.py +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_docs_and_llm.py +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_harvest.py +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_imapmail.py +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_mark_read.py +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_mcp.py +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_messengers.py +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_mssql.py +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_not_coding.py +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_pm.py +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_poll_overlap.py +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_poll_sources.py +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_proof_ci_proposals.py +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_reply_channels.py +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_reply_voice.py +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_reviews.py +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_scopes.py +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_sync_clock.py +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_task_identity.py +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_terminal.py +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_toil.py +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_vision.py +0 -0
- {taskuary-0.2.1 → taskuary-0.3.0}/tests/test_watermark.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: taskuary
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Automate your job - the local-first operating system for your work: AI triage in, coding agents out.
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/ldbumble/taskuary
|
|
@@ -65,7 +65,7 @@ you already use does it; you approve the result. Runs entirely on your machine.
|
|
|
65
65
|
|
|
66
66
|
<sub>Real app, real data — a mail triaged and its reply drafted for your approval, then a scheduled SQL report landing on the same rail with its chart and spreadsheet. Nothing sends without you.</sub>
|
|
67
67
|
|
|
68
|
-
**Where it is:** early — **v0.
|
|
68
|
+
**Where it is:** early — **v0.3.0**, and moving fast. The funnel, the review queue, the agent
|
|
69
69
|
sessions and the reports pipeline are all real and in daily use on my own inbox; the edges are
|
|
70
70
|
still being knocked off, and breaking changes are possible before 1.0. Issues get answered.
|
|
71
71
|
[Full status and roadmap ↓](#status--roadmap)
|
|
@@ -166,6 +166,18 @@ Prefer a desktop app? `pip install "taskuary[desktop]"`
|
|
|
166
166
|
then `taskuary-desktop` — the same UI in a native window. A prebuilt single-file
|
|
167
167
|
`Taskuary.exe` is attached to every CI run.
|
|
168
168
|
|
|
169
|
+
Prefer Docker? No Python install on the machine:
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
git clone https://github.com/ldbumble/taskuary && cd taskuary
|
|
173
|
+
docker compose up
|
|
174
|
+
# http://127.0.0.1:7787 — data lives in the taskuary-data volume
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
The container is the web app (Timeline, Review, Reports, Connectors). Coding CLIs
|
|
178
|
+
(Claude Code, Codex, …) and the WhatsApp bridge stay on the host — they are programs on
|
|
179
|
+
*your* machine. Publish the port past localhost only with `TASKUARY_TOKEN` set.
|
|
180
|
+
|
|
169
181
|
## The workspace
|
|
170
182
|
|
|
171
183
|
One tab per question, two lines each; the details live in the app's own help text.
|
|
@@ -179,7 +191,8 @@ One tab per question, two lines each; the details live in the app's own help tex
|
|
|
179
191
|
right now — a live session counts as working, and a session gone quiet moves its card to
|
|
180
192
|
*waiting on you* with the question showing. Cards working now show a live peephole and
|
|
181
193
|
the files their agent has modified so far; a queued card says whom it waits behind (see
|
|
182
|
-
[Many agents, one repo](#many-agents-one-repo--no-stepping-on-each-other)).
|
|
194
|
+
[Many agents, one repo](#many-agents-one-repo--no-stepping-on-each-other)). Flip it to
|
|
195
|
+
**Studio** for the same board as a floor you can walk around — below.
|
|
183
196
|
- **Tasks** — **the page is a terminal**: your CLI in the task's repo, prompt typed in and
|
|
184
197
|
sent, and you keep talking. Taskuary picks the checkout from the SOUL.md repo map (one
|
|
185
198
|
click to override); the prompt carries the ask, the mail, the files and the rules, so the
|
|
@@ -214,6 +227,17 @@ Two principles hold everywhere: **nothing sends or ships without your approval**
|
|
|
214
227
|
it works the mail (auto-dispatch + auto-draft, both switchable); triage is AI-gated, so
|
|
215
228
|
with no AI connected messages file visibly instead of heuristics spraying tasks.
|
|
216
229
|
|
|
230
|
+
## The floor — capacity you can see
|
|
231
|
+
|
|
232
|
+

|
|
233
|
+
|
|
234
|
+
"How much can run at once" is a number in Settings — **Agents at once**, four by default —
|
|
235
|
+
and on the Board's **Studio** toggle it stops being a number. One desk per slot: a desk with
|
|
236
|
+
somebody at it is a task being worked, an empty desk is capacity you are not using, and the
|
|
237
|
+
posture says which kind of work it is rather than making you read a chip. Drag to turn the
|
|
238
|
+
room, scroll to zoom, click a desk to fly to it; raise the limit in Settings and the floor
|
|
239
|
+
widens rather than crowding.
|
|
240
|
+
|
|
217
241
|
## Many agents, one repo — no stepping on each other
|
|
218
242
|
|
|
219
243
|

|
|
@@ -355,14 +379,17 @@ pyinstaller taskuary.spec # dist/Taskuary.exe - single-file desktop build
|
|
|
355
379
|
```
|
|
356
380
|
|
|
357
381
|
Data lives in `~/.taskuary/` (override with `TASKUARY_HOME`): `taskuary.db` (SQLite),
|
|
358
|
-
`config.toml`, `taskuary.log`.
|
|
359
|
-
|
|
360
|
-
|
|
382
|
+
`config.toml`, `taskuary.log`. Docker uses `/data` inside the container for the same
|
|
383
|
+
files (`TASKUARY_HOST` / `TASKUARY_PORT` / `TASKUARY_TOKEN` overlay `[server]` at
|
|
384
|
+
runtime only — they are never written back). For LAN use set
|
|
385
|
+
`[server].token` in config (or `TASKUARY_TOKEN`) and send it as the `X-Taskuary-Token`
|
|
386
|
+
header. CI runs the test matrix on Windows / Linux / macOS × py3.10 / 3.12 on every
|
|
387
|
+
push and pull request, plus the web build and a Docker image smoke. The single-file
|
|
361
388
|
exe is built on push to master.
|
|
362
389
|
|
|
363
390
|
## Status / roadmap
|
|
364
391
|
|
|
365
|
-
Early (v0.
|
|
392
|
+
Early (v0.3.0) and moving fast — said up top too, because it should not be something you find
|
|
366
393
|
out at the bottom.
|
|
367
394
|
|
|
368
395
|
- [x] AI-gated triage, review queue, resumable agent sessions, hash-chained audit
|
|
@@ -17,7 +17,7 @@ you already use does it; you approve the result. Runs entirely on your machine.
|
|
|
17
17
|
|
|
18
18
|
<sub>Real app, real data — a mail triaged and its reply drafted for your approval, then a scheduled SQL report landing on the same rail with its chart and spreadsheet. Nothing sends without you.</sub>
|
|
19
19
|
|
|
20
|
-
**Where it is:** early — **v0.
|
|
20
|
+
**Where it is:** early — **v0.3.0**, and moving fast. The funnel, the review queue, the agent
|
|
21
21
|
sessions and the reports pipeline are all real and in daily use on my own inbox; the edges are
|
|
22
22
|
still being knocked off, and breaking changes are possible before 1.0. Issues get answered.
|
|
23
23
|
[Full status and roadmap ↓](#status--roadmap)
|
|
@@ -118,6 +118,18 @@ Prefer a desktop app? `pip install "taskuary[desktop]"`
|
|
|
118
118
|
then `taskuary-desktop` — the same UI in a native window. A prebuilt single-file
|
|
119
119
|
`Taskuary.exe` is attached to every CI run.
|
|
120
120
|
|
|
121
|
+
Prefer Docker? No Python install on the machine:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
git clone https://github.com/ldbumble/taskuary && cd taskuary
|
|
125
|
+
docker compose up
|
|
126
|
+
# http://127.0.0.1:7787 — data lives in the taskuary-data volume
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
The container is the web app (Timeline, Review, Reports, Connectors). Coding CLIs
|
|
130
|
+
(Claude Code, Codex, …) and the WhatsApp bridge stay on the host — they are programs on
|
|
131
|
+
*your* machine. Publish the port past localhost only with `TASKUARY_TOKEN` set.
|
|
132
|
+
|
|
121
133
|
## The workspace
|
|
122
134
|
|
|
123
135
|
One tab per question, two lines each; the details live in the app's own help text.
|
|
@@ -131,7 +143,8 @@ One tab per question, two lines each; the details live in the app's own help tex
|
|
|
131
143
|
right now — a live session counts as working, and a session gone quiet moves its card to
|
|
132
144
|
*waiting on you* with the question showing. Cards working now show a live peephole and
|
|
133
145
|
the files their agent has modified so far; a queued card says whom it waits behind (see
|
|
134
|
-
[Many agents, one repo](#many-agents-one-repo--no-stepping-on-each-other)).
|
|
146
|
+
[Many agents, one repo](#many-agents-one-repo--no-stepping-on-each-other)). Flip it to
|
|
147
|
+
**Studio** for the same board as a floor you can walk around — below.
|
|
135
148
|
- **Tasks** — **the page is a terminal**: your CLI in the task's repo, prompt typed in and
|
|
136
149
|
sent, and you keep talking. Taskuary picks the checkout from the SOUL.md repo map (one
|
|
137
150
|
click to override); the prompt carries the ask, the mail, the files and the rules, so the
|
|
@@ -166,6 +179,17 @@ Two principles hold everywhere: **nothing sends or ships without your approval**
|
|
|
166
179
|
it works the mail (auto-dispatch + auto-draft, both switchable); triage is AI-gated, so
|
|
167
180
|
with no AI connected messages file visibly instead of heuristics spraying tasks.
|
|
168
181
|
|
|
182
|
+
## The floor — capacity you can see
|
|
183
|
+
|
|
184
|
+

|
|
185
|
+
|
|
186
|
+
"How much can run at once" is a number in Settings — **Agents at once**, four by default —
|
|
187
|
+
and on the Board's **Studio** toggle it stops being a number. One desk per slot: a desk with
|
|
188
|
+
somebody at it is a task being worked, an empty desk is capacity you are not using, and the
|
|
189
|
+
posture says which kind of work it is rather than making you read a chip. Drag to turn the
|
|
190
|
+
room, scroll to zoom, click a desk to fly to it; raise the limit in Settings and the floor
|
|
191
|
+
widens rather than crowding.
|
|
192
|
+
|
|
169
193
|
## Many agents, one repo — no stepping on each other
|
|
170
194
|
|
|
171
195
|

|
|
@@ -307,14 +331,17 @@ pyinstaller taskuary.spec # dist/Taskuary.exe - single-file desktop build
|
|
|
307
331
|
```
|
|
308
332
|
|
|
309
333
|
Data lives in `~/.taskuary/` (override with `TASKUARY_HOME`): `taskuary.db` (SQLite),
|
|
310
|
-
`config.toml`, `taskuary.log`.
|
|
311
|
-
|
|
312
|
-
|
|
334
|
+
`config.toml`, `taskuary.log`. Docker uses `/data` inside the container for the same
|
|
335
|
+
files (`TASKUARY_HOST` / `TASKUARY_PORT` / `TASKUARY_TOKEN` overlay `[server]` at
|
|
336
|
+
runtime only — they are never written back). For LAN use set
|
|
337
|
+
`[server].token` in config (or `TASKUARY_TOKEN`) and send it as the `X-Taskuary-Token`
|
|
338
|
+
header. CI runs the test matrix on Windows / Linux / macOS × py3.10 / 3.12 on every
|
|
339
|
+
push and pull request, plus the web build and a Docker image smoke. The single-file
|
|
313
340
|
exe is built on push to master.
|
|
314
341
|
|
|
315
342
|
## Status / roadmap
|
|
316
343
|
|
|
317
|
-
Early (v0.
|
|
344
|
+
Early (v0.3.0) and moving fast — said up top too, because it should not be something you find
|
|
318
345
|
out at the bottom.
|
|
319
346
|
|
|
320
347
|
- [x] AI-gated triage, review queue, resumable agent sessions, hash-chained audit
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "taskuary"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Automate your job - the local-first operating system for your work: AI triage in, coding agents out."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Taskuary - Automate your job: AI triage in, coding agents out, you in charge."""
|
|
2
|
+
# ONE place holds the version and it is pyproject.toml, because that is the one the git tag has
|
|
3
|
+
# to match (RELEASING.md). A second copy here said 0.2.0 through the whole of 0.2.1: the CLI
|
|
4
|
+
# banner, the API and the header pill all reported it, and the header's own tooltip told the
|
|
5
|
+
# owner to restart - which could never have helped, because restarting reloads the same
|
|
6
|
+
# hardcoded string.
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _version() -> str:
|
|
11
|
+
# pyproject FIRST, and only a source checkout has one sitting next to the package. It is the
|
|
12
|
+
# file the number lives in, so it is right the moment you pull - whereas installed metadata
|
|
13
|
+
# is frozen at `pip install` time: an editable install made at 0.2.0 keeps reporting 0.2.0
|
|
14
|
+
# through every later pull, which is the exact confusion this whole function exists to end.
|
|
15
|
+
try:
|
|
16
|
+
for line in (Path(__file__).parent.parent / 'pyproject.toml').read_text(encoding='utf-8').splitlines():
|
|
17
|
+
if line.startswith('version'): return line.split('=', 1)[1].strip().strip('"\'')
|
|
18
|
+
except OSError: pass
|
|
19
|
+
try: # a real wheel: no pyproject beside it, metadata is the truth
|
|
20
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
21
|
+
try: return version('taskuary')
|
|
22
|
+
except PackageNotFoundError: pass
|
|
23
|
+
except ImportError: pass
|
|
24
|
+
return '0.0.0+unknown' # never a stale number pretending to be a real one
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
__version__ = _version()
|
|
@@ -197,16 +197,24 @@ def task_context(store, task_id: int) -> str:
|
|
|
197
197
|
lines += [f"- [{m.get('SentAt')}] {m.get('FromName') or m.get('FromEmail')}: {m.get('Subject') or ''}",
|
|
198
198
|
f" {str(m.get('BodyText') or '')[:1500]}"]
|
|
199
199
|
lines += ['', 'Thread:'] + [f"- {c.get('Actor')}: {str(c.get('Body'))[:300]}" for c in d['comments']]
|
|
200
|
+
mem = memory_block(store, d['messages'])
|
|
201
|
+
if mem: lines += ['', mem]
|
|
200
202
|
return '\n'.join(lines)
|
|
201
203
|
|
|
202
204
|
|
|
203
205
|
def memory_block(store, messages: list) -> str:
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
206
|
+
"""The standing notes an agent working this thread has to follow - the ones that bear on
|
|
207
|
+
THIS thread, not every note on file. It used to be every match with no cap at all, which
|
|
208
|
+
grows without limit as the owner keeps giving verdicts; ranking by what the thread actually
|
|
209
|
+
says puts the ones that matter at the top and says how many were left out."""
|
|
210
|
+
from .ingest import relevant_notes
|
|
211
|
+
text = ' '.join(f"{m.get('Subject') or ''} {m.get('BodyText') or ''}" for m in messages)[:4000]
|
|
212
|
+
notes, left = relevant_notes(store, [(m.get('FromEmail') or '') for m in messages], text)
|
|
213
|
+
if not notes: return ''
|
|
214
|
+
return ('Standing notes (learned from the owner - FOLLOW these):\n'
|
|
215
|
+
+ '\n'.join(f'- {n}' for n in notes)
|
|
216
|
+
+ (f'\n({left} more apply to this thread but did not fit - ask before assuming '
|
|
217
|
+
'nothing else was said.)' if left else ''))
|
|
210
218
|
|
|
211
219
|
|
|
212
220
|
# dispatch() lived here: one open->close HEADLESS run on a task, the CLI working and closing
|
|
@@ -129,6 +129,44 @@ def discover(store, cfg: dict, connector_id: int, actor: str = 'owner') -> dict:
|
|
|
129
129
|
return out
|
|
130
130
|
|
|
131
131
|
|
|
132
|
+
def catalog(store, service: str = None) -> dict:
|
|
133
|
+
"""What is callable here, WITHOUT calling AWS: botocore ships the service and operation
|
|
134
|
+
models on disk, so both lists are local metadata - no credentials, no region, no request.
|
|
135
|
+
|
|
136
|
+
Typing `service` and `operation` by hand meant knowing boto3's naming from memory and
|
|
137
|
+
finding out you had it wrong only when a scheduled report failed. `seen` is what discovery
|
|
138
|
+
actually found in this account, first in the list, because 400-odd alphabetical service
|
|
139
|
+
names is a haystack rather than a choice. `read` marks the operations that only look:
|
|
140
|
+
a report is a thing you read, and describe_/list_/get_ is nearly always what you want.
|
|
141
|
+
"""
|
|
142
|
+
import botocore.session
|
|
143
|
+
from botocore import xform_name
|
|
144
|
+
sess = botocore.session.get_session()
|
|
145
|
+
# the objects discovery actually found, each WITH THE REGION it was found in. Handing back a
|
|
146
|
+
# bare name was a real bug: a log group is (name, region) - /aws/lambda/x in us-east-2 and the
|
|
147
|
+
# one in us-east-1 are unrelated groups - so a name picked from a list and run against the
|
|
148
|
+
# card's FIRST region answers "The specified log group does not exist", which is true and
|
|
149
|
+
# completely misleading. The region travels with the pick now.
|
|
150
|
+
objs = [(s['Address'] or '', json.loads(s.get('ConfigJson') or '{}').get('region') or '')
|
|
151
|
+
for s in store.list_sources(active_only=False) if s['Channel'] == 'aws']
|
|
152
|
+
groups = sorted(({'name': a[len('logs://'):], 'region': r} for a, r in objs if a.startswith('logs://')),
|
|
153
|
+
key=lambda g: (g['region'], g['name']))
|
|
154
|
+
buckets = sorted(({'name': a[len('s3://'):], 'region': r} for a, r in objs if a.startswith('s3://')),
|
|
155
|
+
key=lambda b: b['name'])
|
|
156
|
+
seen = sorted({'logs' for _ in groups[:1]} | {'s3' for _ in buckets[:1]})
|
|
157
|
+
out = {'seen': seen, 'services': sorted(sess.get_available_services()),
|
|
158
|
+
'log_groups': groups, 'buckets': buckets}
|
|
159
|
+
if service:
|
|
160
|
+
try:
|
|
161
|
+
names = [xform_name(o) for o in sess.get_service_model(service).operation_names]
|
|
162
|
+
except Exception as e:
|
|
163
|
+
return {**out, 'service': service, 'operations': [], 'error': str(e)[:200]}
|
|
164
|
+
read = [n for n in names if n.startswith(('list_', 'describe_', 'get_', 'head_', 'query', 'scan', 'select_'))]
|
|
165
|
+
out.update({'service': service, 'operations': sorted(read) + sorted(set(names) - set(read)),
|
|
166
|
+
'read': sorted(read)})
|
|
167
|
+
return out
|
|
168
|
+
|
|
169
|
+
|
|
132
170
|
def poll_source(store, cfg: dict, src: dict, since, llm=None, file_only=False) -> int:
|
|
133
171
|
"""One discovered object in tasks/feed mode. s3://bucket -> a Timeline item per NEW
|
|
134
172
|
object; logs://group -> ONE batched item of the new matching events (default pattern:
|
|
@@ -212,15 +250,50 @@ def run_s3_object(cfg: dict):
|
|
|
212
250
|
return rows_out(rows, lim, unit='objects', mine=mine)
|
|
213
251
|
|
|
214
252
|
|
|
253
|
+
LOG_PAGE_CAP = 12 # one report is worth this many FilterLogEvents calls, not unbounded
|
|
254
|
+
|
|
255
|
+
|
|
215
256
|
def run_cloudwatch_logs(cfg: dict):
|
|
216
257
|
"""{"log_group", "pattern", "hours": 24} - recent events from a CloudWatch log group.
|
|
217
258
|
Pattern uses CloudWatch filter syntax: '?ERROR ?Exception' means any of these words."""
|
|
218
259
|
from .reports import row_limit, rows_out
|
|
219
260
|
lim, mine = row_limit(cfg)
|
|
220
261
|
start = int((datetime.now() - timedelta(hours=float(cfg.get('hours') or 24))).timestamp() * 1000)
|
|
221
|
-
|
|
262
|
+
hours = float(cfg.get('hours') or 24)
|
|
263
|
+
kw = {'logGroupName': cfg['log_group'], 'startTime': start, 'limit': min(max(lim + 1, 1000), 10000)}
|
|
222
264
|
if cfg.get('pattern'): kw['filterPattern'] = cfg['pattern']
|
|
223
|
-
|
|
265
|
+
where = regions(cfg)[0]
|
|
266
|
+
try:
|
|
267
|
+
cl = client(cfg, 'logs', where)
|
|
268
|
+
# FilterLogEvents PAGES, and its pages are not full ones. It scans log streams and hands
|
|
269
|
+
# back whatever that pass found, with a nextToken - so a lambda (one stream per
|
|
270
|
+
# invocation) answered a single call with nine events while the rest of the day sat behind
|
|
271
|
+
# the token. "9 events" then read as "a quiet day", which for a report asked to find
|
|
272
|
+
# ERRORS is the worst way to be wrong. Page until the cap is covered or the token runs
|
|
273
|
+
# out; PAGE_CAP stops a busy group from turning one report into a thousand calls.
|
|
274
|
+
ev, tok, pages = [], None, 0
|
|
275
|
+
while pages < LOG_PAGE_CAP:
|
|
276
|
+
r = cl.filter_log_events(**kw, **({'nextToken': tok} if tok else {}))
|
|
277
|
+
ev += r.get('events') or []
|
|
278
|
+
tok, pages = r.get('nextToken'), pages + 1
|
|
279
|
+
if not tok or len(ev) > lim: break
|
|
280
|
+
except Exception as e:
|
|
281
|
+
# "The specified log group does not exist" is true and useless: it does exist, in another
|
|
282
|
+
# region. AWS cannot say that because it only knows the region it was asked about - but we
|
|
283
|
+
# know which one we asked, so we say it.
|
|
284
|
+
if 'ResourceNotFoundException' not in type(e).__name__ and 'does not exist' not in str(e): raise
|
|
285
|
+
raise RuntimeError(f"no log group {cfg['log_group']} in {where or 'the default region'} - a log "
|
|
286
|
+
'group belongs to ONE region, so the same name in another region is a '
|
|
287
|
+
'different group. Pick it from the list again (each entry says its region), '
|
|
288
|
+
"or set this source's region to the one it lives in.") from None
|
|
289
|
+
# newest first: when the cap bites, the recent end of the window is the half worth keeping,
|
|
290
|
+
# and AWS hands these back oldest-first
|
|
291
|
+
ev.sort(key=lambda e: e.get('timestamp') or 0, reverse=True)
|
|
224
292
|
rows = [{'at': datetime.fromtimestamp(e['timestamp'] / 1000).strftime('%Y-%m-%d %H:%M:%S'),
|
|
225
293
|
'stream': e.get('logStreamName'), 'message': (e.get('message') or '').strip()[:500]} for e in ev]
|
|
226
|
-
|
|
294
|
+
hrs = f'{hours:g}h'
|
|
295
|
+
head, body = rows_out(rows, lim, unit=f'events in the last {hrs}', mine=mine)
|
|
296
|
+
# a scan that stopped at the page bound is not "all of it", and only this code knows
|
|
297
|
+
if tok and len(ev) <= lim:
|
|
298
|
+
head += f' — stopped after {LOG_PAGE_CAP} pages, so there may be more; narrow it with a filter pattern'
|
|
299
|
+
return head, body
|
|
@@ -116,11 +116,11 @@ def drain(store):
|
|
|
116
116
|
"""A session ended (or a slot freed up): start what was queued, in arrival order. Anything
|
|
117
117
|
whose blocker is still working stays put; anything whose task moved on is just cleared."""
|
|
118
118
|
from . import terminal as term
|
|
119
|
-
from .ingest import
|
|
119
|
+
from .ingest import auto_sessions
|
|
120
120
|
if not _DRAINING.acquire(blocking=False): return
|
|
121
121
|
try:
|
|
122
122
|
for q in store.queued_dispatches():
|
|
123
|
-
if len([t for t in term.SESSIONS.values() if t.alive]) >=
|
|
123
|
+
if len([t for t in term.SESSIONS.values() if t.alive]) >= auto_sessions(store): return
|
|
124
124
|
b = q.get('BehindTaskId')
|
|
125
125
|
if b and (term.for_task(b) or any(r['TaskId'] == b for r in store.running_runs())): continue
|
|
126
126
|
t = store.get_task(q['TaskId']) or {}
|
|
@@ -14,12 +14,18 @@ from .github import _h as gh_headers, list_accessible_repos
|
|
|
14
14
|
from .ingest import ingest_message
|
|
15
15
|
|
|
16
16
|
GRAPH = 'https://graph.microsoft.com/v1.0'
|
|
17
|
-
MAIL_SELECT = 'id,subject,from,receivedDateTime,sentDateTime,bodyPreview,body,conversationId,webLink,hasAttachments,isRead'
|
|
17
|
+
MAIL_SELECT = 'id,subject,from,toRecipients,ccRecipients,receivedDateTime,sentDateTime,bodyPreview,body,conversationId,webLink,hasAttachments,isRead'
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
def _cfg(c): return json.loads(c.get('ConfigJson') or '{}')
|
|
21
21
|
|
|
22
22
|
|
|
23
|
+
def _addrs(rows) -> list:
|
|
24
|
+
"""The addresses off a Graph recipient list. Triage needs to know whether the mailbox is
|
|
25
|
+
on the To line or merely in Cc - being copied on other people's work is not an assignment."""
|
|
26
|
+
return [a for a in ((r.get('emailAddress') or {}).get('address') or '' for r in rows or []) if a]
|
|
27
|
+
|
|
28
|
+
|
|
23
29
|
def graph_creds(store, c):
|
|
24
30
|
"""Effective Graph credentials for a connector: its own, else the Outlook connector's
|
|
25
31
|
saved app (Teams shares it by design), else the AZURE_* env vars (in graph_token).
|
|
@@ -132,6 +138,14 @@ def test_connector(store, cid: int) -> dict:
|
|
|
132
138
|
elif c['Type'] == 'whatsapp':
|
|
133
139
|
from .messengers import wa_test
|
|
134
140
|
detail = wa_test(store, store.get_connector(c['ConnectorId'], with_secret=True))
|
|
141
|
+
elif c['Type'] in ('exa', 'tavily', 'firecrawl', 'reader'):
|
|
142
|
+
# a real call, not a key-shape check: these all fail the same way (401) and the
|
|
143
|
+
# owner should find that out here rather than from an empty report on Monday
|
|
144
|
+
from .reports import REGISTRY, resolve_cfg
|
|
145
|
+
probe = ({'url': 'https://example.com'} if c['Type'] in ('firecrawl', 'reader')
|
|
146
|
+
else {'query': 'taskuary local-first ai task hub', 'num': 1})
|
|
147
|
+
head, _body = REGISTRY[c['Type']](resolve_cfg(store, {**probe, 'type': c['Type'], 'max_rows': 1}))
|
|
148
|
+
detail = f'{c["Type"]} answered: {head}'
|
|
135
149
|
elif c['Type'] in ('jira', 'asana', 'monday', 'clickup', 'todoist'):
|
|
136
150
|
from . import pm
|
|
137
151
|
detail = pm.test(store, store.get_connector(c['ConnectorId'], with_secret=True))
|
|
@@ -411,7 +425,7 @@ def _graph_user(tok, oid, cache):
|
|
|
411
425
|
_HOSTED = re.compile(r'<img[^>]+src="([^"]*?/hostedContents/[^"]*?)"', re.I)
|
|
412
426
|
|
|
413
427
|
|
|
414
|
-
def hosted_images(tok: str, html: str, cap: int = 4) -> list:
|
|
428
|
+
def hosted_images(tok: str, html: str, cap: int = 4, where: str = '') -> list:
|
|
415
429
|
out = []
|
|
416
430
|
for i, url in enumerate(dict.fromkeys(_HOSTED.findall(html or '')).keys()):
|
|
417
431
|
if i >= cap: break
|
|
@@ -419,7 +433,14 @@ def hosted_images(tok: str, html: str, cap: int = 4) -> list:
|
|
|
419
433
|
r = requests.get(unescape(url), headers={'Authorization': f'Bearer {tok}'}, timeout=30)
|
|
420
434
|
r.raise_for_status()
|
|
421
435
|
except Exception as e:
|
|
422
|
-
|
|
436
|
+
# one readable line, not the signed hostedContents URL twice over: it is 900
|
|
437
|
+
# characters of query string, it says nothing the status code does not, and two
|
|
438
|
+
# of them per message buried the rest of the sync log. 403 here is the membership
|
|
439
|
+
# check described below - expected, not a fault to hunt.
|
|
440
|
+
code = getattr(getattr(e, 'response', None), 'status_code', None)
|
|
441
|
+
why = ('Microsoft refused it for this chat (403 - the app is not a member)' if code == 403
|
|
442
|
+
else f'HTTP {code}' if code else str(e)[:120])
|
|
443
|
+
logger.warning(f'teams image {i + 1} not readable{f" in {where}" if where else ""}: {why}')
|
|
423
444
|
continue
|
|
424
445
|
ct = (r.headers.get('Content-Type') or 'image/png').split(';')[0].strip()
|
|
425
446
|
out.append({'id': hashlib.sha1(url.encode()).hexdigest()[:16],
|
|
@@ -453,7 +474,7 @@ def ingest_teams_chats(store, upn: str, tok: str, since, llm=None, file_only=Fal
|
|
|
453
474
|
# fetched BEFORE triage, like the mail path: the classifier has to see the screenshot
|
|
454
475
|
# to judge it, and afterwards is too late
|
|
455
476
|
raw_html = (m.get('body') or {}).get('content') or ''
|
|
456
|
-
atts = hosted_images(tok, raw_html)
|
|
477
|
+
atts = hosted_images(tok, raw_html, where=topic or f'chat with {name}')
|
|
457
478
|
# a picture we could not FETCH must not vanish silently the way it used to. Graph
|
|
458
479
|
# refuses hostedContents unless the app registration carries ChatMessage.Read.All, and
|
|
459
480
|
# "the screenshot was the whole ask" is exactly the message you cannot afford to read
|
|
@@ -629,7 +650,7 @@ def _since(s, backfill_days: int = 0):
|
|
|
629
650
|
return min(last, datetime.now() - timedelta(days=backfill_days)) if backfill_days else last
|
|
630
651
|
|
|
631
652
|
|
|
632
|
-
def poll_channels(store, backfill_days: int = 0) -> int:
|
|
653
|
+
def poll_channels(store, backfill_days: int = 0, progress=None) -> int:
|
|
633
654
|
"""Ingest new items for every connection the owner marked as a TRIGGER, through the
|
|
634
655
|
same triage funnel (incl. the configured AI, if any). A connection without the trigger
|
|
635
656
|
role is still usable by agents and reports - it just never creates work on its own.
|
|
@@ -651,6 +672,11 @@ def poll_channels(store, backfill_days: int = 0) -> int:
|
|
|
651
672
|
if (not roles & {'trigger', 'feed'} and not (c['Type'] == 'github' and _gh_explicit(store))
|
|
652
673
|
and not (c['Type'] in CLOUD and _cloud_explicit(store, CH2SRC[c['Type']]))): continue
|
|
653
674
|
file_only = 'trigger' not in roles
|
|
675
|
+
# said as it happens, not at the end: the timeline is refreshing while this runs, so
|
|
676
|
+
# "reading Outlook" beside rows that are already arriving beats a spinner and a wait
|
|
677
|
+
if progress:
|
|
678
|
+
try: progress(c['Type'], n)
|
|
679
|
+
except Exception: pass
|
|
654
680
|
full = store.get_connector(c['ConnectorId'], with_secret=True)
|
|
655
681
|
try:
|
|
656
682
|
if c['Type'] in ('outlook', 'teams'):
|
|
@@ -708,6 +734,7 @@ def poll_channels(store, backfill_days: int = 0) -> int:
|
|
|
708
734
|
'external_id': f"graph:{m['id']}", 'channel': 'email',
|
|
709
735
|
'subject': m.get('subject'), 'body': _body(m),
|
|
710
736
|
'from_name': frm.get('name'), 'from_email': frm.get('address'),
|
|
737
|
+
'to': _addrs(m.get('toRecipients')), 'cc': _addrs(m.get('ccRecipients')),
|
|
711
738
|
'conversation_id': m.get('conversationId'), 'sent_at': _local(m.get('receivedDateTime') or ''),
|
|
712
739
|
'source_link': m.get('webLink'), 'source_name': s['Address'],
|
|
713
740
|
'images': images_for_triage(store, atts)}, llm=llm)
|
|
@@ -15,6 +15,7 @@ from datetime import datetime
|
|
|
15
15
|
from loguru import logger
|
|
16
16
|
|
|
17
17
|
PR_MARK = 'PULL REQUEST' # comment marker: 'PULL REQUEST {json}'
|
|
18
|
+
PRC_MARK = 'PR COMMENT' # a human's comment on that PR, kept on the task's own thread
|
|
18
19
|
PUSH_MARK = 'PUSHED' # the same, for work landed straight on the default branch
|
|
19
20
|
CI_MARK = 'CI FEEDBACK' # what we have already handed back, so it goes back ONCE
|
|
20
21
|
BRANCH_SAFE = re.compile(r'[^a-zA-Z0-9._/-]+')
|
|
@@ -221,10 +222,64 @@ def check_task(store, task_id: int, llm=None) -> dict:
|
|
|
221
222
|
return {'state': 'failure', 'fed': True, 'handed': handed, 'at': ref, 'kind': at['kind']}
|
|
222
223
|
|
|
223
224
|
|
|
225
|
+
def pull_comments(store, task_id: int, at: dict) -> int:
|
|
226
|
+
"""Somebody commented on our pull request - put it on the TIMELINE, on this task.
|
|
227
|
+
|
|
228
|
+
github.pr_review_comments has existed since the CI watcher was written, its docstring
|
|
229
|
+
calling review comments "the other thing that should reach the agent", and nothing has ever
|
|
230
|
+
called it. So a reviewer could ask for a change and the only place it existed was GitHub:
|
|
231
|
+
the task sat there looking finished, and the owner found out by remembering to go and look.
|
|
232
|
+
|
|
233
|
+
Each comment becomes a message ON THE SAME TASK - it is the same piece of work, not a new
|
|
234
|
+
one - which is what puts the task back in front of the owner as needs-you. A live session
|
|
235
|
+
is told as well, because a reviewer's note is exactly what the agent should act on next."""
|
|
236
|
+
from . import github, terminal as hub_term
|
|
237
|
+
from .store import task_ref
|
|
238
|
+
if at.get('kind') != 'pr': return 0
|
|
239
|
+
try:
|
|
240
|
+
c = _conn(store)
|
|
241
|
+
comments = github.pr_review_comments(c['Secret'], at['repo'], at['number'])
|
|
242
|
+
except Exception as e:
|
|
243
|
+
logger.warning(f'reading PR comments for task {task_id} failed: {e}')
|
|
244
|
+
return 0
|
|
245
|
+
n, newest = 0, None
|
|
246
|
+
for cm in comments:
|
|
247
|
+
ext = f"ghc:{at['repo']}#{at['number']}:{cm.get('id')}"
|
|
248
|
+
if not cm.get('id') or store.message_exists(ext): continue
|
|
249
|
+
where = f" on {cm['path']}" if cm.get('path') else ''
|
|
250
|
+
body = f"{cm['who']} commented{where} on pull request #{at['number']}:\n\n{cm['body']}"
|
|
251
|
+
mid = store.add_message({
|
|
252
|
+
'TaskId': task_id, 'ExternalId': ext, 'Channel': 'github',
|
|
253
|
+
'ConversationId': f"pr:{at['repo']}#{at['number']}", 'SourceName': at['repo'],
|
|
254
|
+
'Subject': f"PR #{at['number']} - {cm['kind']} comment from {cm['who']}",
|
|
255
|
+
'FromName': cm['who'], 'FromEmail': f"{cm['who']}@users.noreply.github.com",
|
|
256
|
+
'SentAt': str(cm.get('at') or '').replace('T', ' ').rstrip('Z'),
|
|
257
|
+
'BodyText': body, 'SourceLink': cm.get('url') or at.get('url'), 'Status': 'routed'})
|
|
258
|
+
store.add_route(mid, task_id, 'attach', None,
|
|
259
|
+
f"a human commented on the pull request for {task_ref(task_id)} - "
|
|
260
|
+
'time to look at it again', [], 'github')
|
|
261
|
+
newest, n = body, n + 1
|
|
262
|
+
if newest:
|
|
263
|
+
store.add_comment(task_id, 'github', 'agent', f'{PRC_MARK} {newest[:400]}')
|
|
264
|
+
# a live session gets it typed in; with nobody at the keyboard the task goes back to open,
|
|
265
|
+
# so the timeline row it just grew is not the only thing carrying the news
|
|
266
|
+
if not hub_term.say_to_task(store, task_id, {'FromName': 'GitHub', 'Channel': 'github',
|
|
267
|
+
'BodyText': newest}, 'github'):
|
|
268
|
+
store.update_task(task_id, {'Status': 'open'}, 'github')
|
|
269
|
+
store.audit('task', task_id, 'pr_comments', 'github', detail={'new': n, 'pr': at.get('number')})
|
|
270
|
+
return n
|
|
271
|
+
|
|
272
|
+
|
|
224
273
|
def poll(store, llm=None) -> int:
|
|
225
|
-
"""Every task whose work has landed - PR or direct push -
|
|
226
|
-
same sync as everything else
|
|
227
|
-
|
|
274
|
+
"""Every task whose work has landed - PR or direct push - looked at once. Called from the
|
|
275
|
+
same sync as everything else.
|
|
276
|
+
|
|
277
|
+
Two different things, and only one of them is opt-in. A human commenting on our pull request
|
|
278
|
+
is INBOUND WORK arriving, which is the whole point of the app, so it lands on the timeline
|
|
279
|
+
whatever the settings say. Handing a RED BUILD to a running agent is the automation, and that
|
|
280
|
+
is what ci_watch gates - it ships off, and gating the comments behind it too would have meant
|
|
281
|
+
the default install never told anybody their PR had been reviewed."""
|
|
282
|
+
watch = store.get_settings().get('ci_watch', 'off') != 'off'
|
|
228
283
|
n = 0
|
|
229
284
|
for t in store.list_tasks():
|
|
230
285
|
if t['Status'] in ('done', 'dropped'): continue
|
|
@@ -232,6 +287,11 @@ def poll(store, llm=None) -> int:
|
|
|
232
287
|
# a merged/closed PR is finished business; a direct push is only ever checked while
|
|
233
288
|
# its checks could still be running, which the state below settles
|
|
234
289
|
if not at or at.get('state') == 'closed': continue
|
|
290
|
+
try:
|
|
291
|
+
n += pull_comments(store, t['TaskId'], at)
|
|
292
|
+
except Exception as e:
|
|
293
|
+
logger.warning(f'PR comments for task {t["TaskId"]} failed: {e}')
|
|
294
|
+
if not watch: continue
|
|
235
295
|
out = check_task(store, t['TaskId'], llm)
|
|
236
296
|
n += 1 if out.get('fed') else 0
|
|
237
297
|
return n
|
|
@@ -0,0 +1,73 @@
|
|
|
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 public_url(host, port) -> str:
|
|
8
|
+
"""0.0.0.0 / :: are bind addresses, not a place a browser can go."""
|
|
9
|
+
shown = '127.0.0.1' if host in ('0.0.0.0', '::') else host
|
|
10
|
+
return f'http://{shown}:{port}'
|
|
11
|
+
|
|
12
|
+
def _busy(host, port):
|
|
13
|
+
probe = '127.0.0.1' if host in ('0.0.0.0', '::') else host
|
|
14
|
+
with socket.socket() as s: return s.connect_ex((probe, port)) == 0
|
|
15
|
+
|
|
16
|
+
def _is_taskuary(url):
|
|
17
|
+
try:
|
|
18
|
+
import requests
|
|
19
|
+
r = requests.get(f'{url}/api/health', timeout=2)
|
|
20
|
+
if r.status_code == 200 and (r.json() or {}).get('ok') is True: return True
|
|
21
|
+
return requests.get(f'{url}/api/settings', timeout=2).status_code in (200, 401)
|
|
22
|
+
except Exception:
|
|
23
|
+
return False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def main():
|
|
27
|
+
ap = argparse.ArgumentParser(prog='taskuary', description='Automate your job - the local-first agent work hub.')
|
|
28
|
+
ap.add_argument('--host', help='override [server].host (0.0.0.0 to listen on all interfaces)')
|
|
29
|
+
ap.add_argument('--port', type=int, help='override [server].port')
|
|
30
|
+
ap.add_argument('--no-browser', action='store_true')
|
|
31
|
+
ap.add_argument('--debug', action='store_true', help='verbose console logging (requests, report runs, errors)')
|
|
32
|
+
ap.add_argument('--version', action='version', version=f'taskuary {__version__}')
|
|
33
|
+
# "what is actually in the prompt?" had no answer short of reading the code that builds it,
|
|
34
|
+
# which is not a reasonable thing to ask of the person whose judgement is being automated
|
|
35
|
+
ap.add_argument('--prompts', nargs='?', const='', metavar='MESSAGE_ID',
|
|
36
|
+
help='print the triage, reply and coding-agent prompts for a real item on '
|
|
37
|
+
'this machine - every block labelled with the document or table it '
|
|
38
|
+
'came from - then exit. Optionally for one message id.')
|
|
39
|
+
args = ap.parse_args()
|
|
40
|
+
if args.prompts is not None:
|
|
41
|
+
import sys
|
|
42
|
+
from .promptmap import render
|
|
43
|
+
from .store import SQLiteStore
|
|
44
|
+
# the Windows console is cp1252 and the operator documents are full of em dashes, so this
|
|
45
|
+
# would die on the owner's OWN text before it printed a line of it
|
|
46
|
+
try: sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
|
47
|
+
except (AttributeError, OSError): pass
|
|
48
|
+
mid = int(args.prompts) if str(args.prompts).strip().isdigit() else None
|
|
49
|
+
print(render(SQLiteStore(config.db_path()), message_id=mid))
|
|
50
|
+
return
|
|
51
|
+
from .logs import setup as setup_logs
|
|
52
|
+
setup_logs(args.debug)
|
|
53
|
+
cfg = config.load()
|
|
54
|
+
host, port = args.host or cfg['server']['host'], args.port or cfg['server']['port']
|
|
55
|
+
url = public_url(host, port)
|
|
56
|
+
if _busy(host, port):
|
|
57
|
+
if _is_taskuary(url):
|
|
58
|
+
# don't crash into 'address already in use' - reuse the running instance
|
|
59
|
+
print(f'Taskuary is already running at {url} - opening it.')
|
|
60
|
+
if not args.no_browser: webbrowser.open(url)
|
|
61
|
+
return
|
|
62
|
+
from .desktop import free_port
|
|
63
|
+
old, port = port, free_port(host)
|
|
64
|
+
url = public_url(host, port)
|
|
65
|
+
print(f'port {old} is in use by something else - using {port} instead')
|
|
66
|
+
print(f'Taskuary {__version__} - {url} (data: {config.db_path()})')
|
|
67
|
+
if not args.no_browser:
|
|
68
|
+
threading.Thread(target=lambda: (time.sleep(1.2), webbrowser.open(url)), daemon=True).start()
|
|
69
|
+
uvicorn.run('taskuary.server:app', host=host, port=port, log_level='warning')
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
if __name__ == '__main__':
|
|
73
|
+
main()
|