taskops-cli 0.4.1__py3-none-any.whl → 0.4.2__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.
- taskops/_version.py +1 -1
- taskops/cli/serving.py +9 -3
- taskops/core/types.py +12 -0
- taskops/http/upstream.py +59 -7
- taskops/mcp/schema.py +1 -1
- taskops/ui/app.js +10 -10
- taskops/verbs/_facts.py +4 -4
- taskops/verbs/activity.py +7 -1
- {taskops_cli-0.4.1.dist-info → taskops_cli-0.4.2.dist-info}/METADATA +1 -1
- {taskops_cli-0.4.1.dist-info → taskops_cli-0.4.2.dist-info}/RECORD +12 -12
- {taskops_cli-0.4.1.dist-info → taskops_cli-0.4.2.dist-info}/WHEEL +0 -0
- {taskops_cli-0.4.1.dist-info → taskops_cli-0.4.2.dist-info}/entry_points.txt +0 -0
taskops/_version.py
CHANGED
taskops/cli/serving.py
CHANGED
|
@@ -25,7 +25,7 @@ if TYPE_CHECKING: # a NAME for the annotation, never an import at start-up
|
|
|
25
25
|
from ..http.upstream import Upstream
|
|
26
26
|
|
|
27
27
|
from . import window
|
|
28
|
-
from .. import _clock
|
|
28
|
+
from .. import _clock, session
|
|
29
29
|
from .._json import as_object
|
|
30
30
|
from ..board import DIR, find_root, is_project, read_config
|
|
31
31
|
from .._errors import TaskopsError
|
|
@@ -133,7 +133,13 @@ def _upstream(root: Path) -> Upstream | None:
|
|
|
133
133
|
url = str(config.get("url", ""))
|
|
134
134
|
if not url:
|
|
135
135
|
return None
|
|
136
|
-
|
|
136
|
+
# The SESSION, refreshed here if it has run out and refreshable again from
|
|
137
|
+
# inside the window if it runs out while somebody is reading (2026-08-18,
|
|
138
|
+
# `http/upstream.py`): a host session is twelve hours and this window is
|
|
139
|
+
# meant to be left open. `fresh` falls back to the cached token whenever it
|
|
140
|
+
# cannot sign in, and `refresher` is None for a standing bearer — neither
|
|
141
|
+
# replaces a token nobody may replace, which is `session.py`'s own rule.
|
|
142
|
+
token = str(session.fresh(root, config, _clock.now()))
|
|
137
143
|
if not token and config.get("readonly"):
|
|
138
144
|
# A viewer's window (`commands.py::_watch`): an Upstream with no bearer.
|
|
139
145
|
# `Mounts.public` reads exactly this — no token means this window is
|
|
@@ -145,7 +151,7 @@ def _upstream(root: Path) -> Upstream | None:
|
|
|
145
151
|
f"{root / DIR}/board.json points at {url} but there is no credential in "
|
|
146
152
|
"remote.json — run: taskops join <url with ?token= or ?invite=>"
|
|
147
153
|
)
|
|
148
|
-
return Remote(url, token)
|
|
154
|
+
return Remote(url, token, refresh=session.refresher(root, config))
|
|
149
155
|
|
|
150
156
|
|
|
151
157
|
def _open(url: str) -> int:
|
taskops/core/types.py
CHANGED
|
@@ -161,6 +161,18 @@ MILESTONE_STATUSES = ("open", "done", "dropped")
|
|
|
161
161
|
CLOSED = ("done", "dropped")
|
|
162
162
|
LEASE_TTL = 900.0 # 15 min; every MCP call renews it
|
|
163
163
|
PROJECT = "project" # the `task` of board-level events
|
|
164
|
+
EVERYTHING = "*"
|
|
165
|
+
"""`milestone=*`: the WHOLE board, said out loud — no chapter in scope.
|
|
166
|
+
|
|
167
|
+
An ABSENT milestone means "resolve it yourself" (`verbs/_facts.py::in_scope`),
|
|
168
|
+
which with exactly one open chapter IS that chapter. Right for an agent, and it
|
|
169
|
+
left the dashboard's "all chapters" unreachable: it sent no `milestone=`, the
|
|
170
|
+
board came back narrowed anyway, and the picker drew a ✓ beside a scope that had
|
|
171
|
+
never been in effect and that clicking could not change (2026-08-18). Absent and
|
|
172
|
+
"all" are two questions; they now have two arguments, and nothing that omitted
|
|
173
|
+
it sees any change. `verbs/activity.py` is where the two genuinely part — it
|
|
174
|
+
REQUIRES a chapter, and answered `milestone * does not exist`, a lookup's answer
|
|
175
|
+
to a question that was never a lookup."""
|
|
164
176
|
|
|
165
177
|
|
|
166
178
|
# Fields of a Card that `edited` may target. Anything else is a BadRequest, so
|
taskops/http/upstream.py
CHANGED
|
@@ -28,6 +28,20 @@ it, because then the reader is debugging this host instead of reading the board.
|
|
|
28
28
|
Only a server that could not be reached at all is spoken for here, and it says
|
|
29
29
|
so in the same envelope shape everything else uses.
|
|
30
30
|
|
|
31
|
+
**A SESSION RUNS OUT, and the window must not** (2026-08-18). A host session is
|
|
32
|
+
`login.py::SESSION_TTL` — twelve hours — and this class held the token it was
|
|
33
|
+
built with for the life of the process, so a window left open overnight woke up
|
|
34
|
+
answering "that credential expired" to every read, and the habit it taught was
|
|
35
|
+
`taskops join`: re-pasting a credential the machine can mint by itself, which is
|
|
36
|
+
exactly what the session chapter exists to end. The retry here is
|
|
37
|
+
`board.py::RemoteBoard.call`'s, narrow for the same reason — the SERVER said
|
|
38
|
+
EXPIRED, and this process was handed a way to sign in — and it is a callable
|
|
39
|
+
passed in rather than a key read here, so this module still knows nothing about
|
|
40
|
+
keys, files or logins (`cli/serving.py` builds it out of `session.refresher`).
|
|
41
|
+
A window with no way to refresh (a standing bearer token, a public viewer) is
|
|
42
|
+
unchanged: `refresh` is None and the refusal travels to the page as it always
|
|
43
|
+
did.
|
|
44
|
+
|
|
31
45
|
**The feed is a poll, not a relay** (`feed.py`: a message is a SIGNAL, not a
|
|
32
46
|
payload). `mounts.py` asks `head()` on an interval while somebody is connected
|
|
33
47
|
and pokes the page when the number moves; the page then refetches through the
|
|
@@ -39,10 +53,12 @@ something the board did not say.
|
|
|
39
53
|
from __future__ import annotations
|
|
40
54
|
|
|
41
55
|
import json
|
|
56
|
+
from typing import Callable
|
|
42
57
|
from urllib.error import URLError, HTTPError
|
|
43
58
|
from urllib.request import Request, urlopen
|
|
44
59
|
|
|
45
60
|
from .._json import as_object
|
|
61
|
+
from ..store.creds import EXPIRED
|
|
46
62
|
|
|
47
63
|
TIMEOUT = 20.0
|
|
48
64
|
"""Seconds to wait on the remote for one /rpc. The same budget `board.py` gives
|
|
@@ -53,16 +69,38 @@ waiting on, not a background job."""
|
|
|
53
69
|
class Upstream:
|
|
54
70
|
"""One remote board, addressed by url, spoken to with one credential."""
|
|
55
71
|
|
|
56
|
-
def __init__(
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
url: str,
|
|
75
|
+
token: str,
|
|
76
|
+
timeout: float = TIMEOUT,
|
|
77
|
+
refresh: Callable[[], str] | None = None,
|
|
78
|
+
) -> None:
|
|
57
79
|
self.url = url.rstrip("/")
|
|
58
80
|
self.token = token
|
|
59
81
|
self.timeout = timeout
|
|
82
|
+
# How to mint ANOTHER session when this one runs out, or None when
|
|
83
|
+
# nobody may replace this token behind its owner's back — the docstring
|
|
84
|
+
# above argues both halves.
|
|
85
|
+
self.refresh = refresh
|
|
60
86
|
|
|
61
87
|
def rpc(self, body: bytes) -> tuple[int, bytes]:
|
|
62
|
-
"""(status, bytes) — the remote's own answer, untouched
|
|
88
|
+
"""(status, bytes) — the remote's own answer, untouched, with ONE retry
|
|
89
|
+
for the one case a retry can fix: the session ran out and this window
|
|
90
|
+
knows how to sign in again.
|
|
63
91
|
|
|
64
92
|
Returns rather than raises: this sits under an HTTP handler whose job is
|
|
65
93
|
to hand the page what the board said, and a refusal IS an answer."""
|
|
94
|
+
status, answer = self._post(body)
|
|
95
|
+
if self.refresh is None or not _ran_out(answer):
|
|
96
|
+
return status, answer
|
|
97
|
+
try:
|
|
98
|
+
self.token = self.refresh()
|
|
99
|
+
except Exception: # noqa: BLE001 — a failed refresh is the board's refusal, unchanged
|
|
100
|
+
return status, answer
|
|
101
|
+
return self._post(body)
|
|
102
|
+
|
|
103
|
+
def _post(self, body: bytes) -> tuple[int, bytes]:
|
|
66
104
|
headers = {"Content-Type": "application/json"}
|
|
67
105
|
if self.token:
|
|
68
106
|
# No token is the VIEWER's window onto a public board: sending an
|
|
@@ -94,14 +132,28 @@ class Upstream:
|
|
|
94
132
|
return seq_of(answer) if status == 200 else 0
|
|
95
133
|
|
|
96
134
|
|
|
135
|
+
def _ran_out(answer: bytes) -> bool:
|
|
136
|
+
"""Is this the board saying the SESSION expired — not any other refusal?
|
|
137
|
+
|
|
138
|
+
`store/creds.py::EXPIRED` is the named sentence `board.py` matches on for
|
|
139
|
+
the same reason: the words after it are a human's instruction and drift."""
|
|
140
|
+
body = as_object(_parsed(answer))
|
|
141
|
+
if body.get("ok"):
|
|
142
|
+
return False
|
|
143
|
+
return EXPIRED in str(as_object(body.get("error")).get("message", ""))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _parsed(answer: bytes) -> object:
|
|
147
|
+
try:
|
|
148
|
+
return json.loads(answer)
|
|
149
|
+
except ValueError:
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
97
153
|
def seq_of(answer: bytes) -> int:
|
|
98
154
|
"""The `seq` out of an envelope, or 0. Never raises: a body that is not an
|
|
99
155
|
envelope means the poke is skipped, which costs one tick."""
|
|
100
|
-
|
|
101
|
-
parsed: object = json.loads(answer)
|
|
102
|
-
except ValueError:
|
|
103
|
-
return 0
|
|
104
|
-
seq: object = as_object(parsed).get("seq", 0)
|
|
156
|
+
seq: object = as_object(_parsed(answer)).get("seq", 0)
|
|
105
157
|
return seq if isinstance(seq, int) else 0
|
|
106
158
|
|
|
107
159
|
|
taskops/mcp/schema.py
CHANGED
|
@@ -29,7 +29,7 @@ SCHEMAS: dict[str, dict[str, Any]] = {
|
|
|
29
29
|
**DEV_SCHEMAS,
|
|
30
30
|
"taskops_board": _object(
|
|
31
31
|
{
|
|
32
|
-
"milestone": _text("ms-…
|
|
32
|
+
"milestone": _text("ms-… one chapter, * the whole board; default: the open one"),
|
|
33
33
|
"window": _text(
|
|
34
34
|
'hours over "7d" (the last N calendar days), "month" (this month so '
|
|
35
35
|
'far), "2026-07" (that calendar month) or "total" (the whole log)'
|