taskops-cli 0.4.2__py3-none-any.whl → 0.5.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- taskops/_version.py +1 -1
- taskops/gitwork/mirror.py +132 -0
- taskops/http/auth.py +19 -1
- taskops/http/gitdoor.py +29 -48
- taskops/http/handler.py +37 -15
- taskops/http/mounts.py +11 -12
- taskops/http/repos.py +98 -0
- taskops/http/routes.py +32 -0
- taskops/http/stale.py +62 -0
- taskops/http/static.py +49 -15
- taskops/ui/app.js +7 -7
- taskops/ui/index.html +4 -2
- {taskops_cli-0.4.2.dist-info → taskops_cli-0.5.1.dist-info}/METADATA +27 -5
- {taskops_cli-0.4.2.dist-info → taskops_cli-0.5.1.dist-info}/RECORD +16 -12
- {taskops_cli-0.4.2.dist-info → taskops_cli-0.5.1.dist-info}/WHEEL +0 -0
- {taskops_cli-0.4.2.dist-info → taskops_cli-0.5.1.dist-info}/entry_points.txt +0 -0
taskops/_version.py
CHANGED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""The board's bare mirror — the forge's history, held so a host can answer.
|
|
2
|
+
|
|
3
|
+
ARCHITECTURE §16 ("The hosted window") decided this on 2026-08-30: a board
|
|
4
|
+
host MAY hold `<root>/<board>/mirror.git`, a bare `git clone --mirror` of the
|
|
5
|
+
forge the owner declared with `board forge` — and only then does it answer
|
|
6
|
+
`/git` and `/ui/`. The mirror is DERIVED and disposable in exactly the sense
|
|
7
|
+
`cache.sqlite` is: delete it and `ensure` re-clones; nothing in it is truth
|
|
8
|
+
the forge does not hold. Its ONLY source is that forge — it takes no client
|
|
9
|
+
pushes, it is not a second trunk, and nothing here ever writes to it beyond
|
|
10
|
+
what `clone` and `fetch` are.
|
|
11
|
+
|
|
12
|
+
**The address is derived, never a credential.** The default remote is
|
|
13
|
+
`https://<host>/<repo>.git`, spelled straight out of the declared fact
|
|
14
|
+
(`core/forge.py` owns that shape) — an anonymous clone, which is all a public
|
|
15
|
+
repo needs. A private repo is the owner's business: they point the mirror at
|
|
16
|
+
an ssh remote backed by a deploy key on the host's filesystem (`url=` at
|
|
17
|
+
clone time, or `git remote set-url` in a mirror that already exists), and
|
|
18
|
+
this module neither knows nor stores anything about it. What §11 bans stays
|
|
19
|
+
banned: no stored token, no write credential, no dev credential travelling.
|
|
20
|
+
|
|
21
|
+
**Failures return None/False, never raise into a request.** These functions
|
|
22
|
+
sit behind HTTP doors serving readers who cannot fix the host's network; a
|
|
23
|
+
mirror that cannot answer is a stale page, not a 500. The same reasoning
|
|
24
|
+
gave `remote.py::push` its 10-second budget, and `fetch` inherits the number:
|
|
25
|
+
a refresh is never a gate, so it may never cost more than a moment. The one
|
|
26
|
+
bounded on-demand fetch lives in `refresh_if_missing` — a requested ref that
|
|
27
|
+
is absent buys exactly one fetch, then the answer is whatever is true, stale
|
|
28
|
+
included. Answering stale beats blocking a page on a forge that is down.
|
|
29
|
+
|
|
30
|
+
All subprocess goes through `run.py`, the one module allowed to import it —
|
|
31
|
+
and `run.git` RAISES on a timeout (right for workers, wrong for a request),
|
|
32
|
+
so the catch here is part of the contract, not belt-and-braces.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
|
|
39
|
+
from . import run
|
|
40
|
+
from ..core import forge as forge_facts
|
|
41
|
+
from .._errors import TaskopsError
|
|
42
|
+
|
|
43
|
+
__all__ = ["ensure", "fetch", "refresh_if_missing"]
|
|
44
|
+
|
|
45
|
+
MIRROR = "mirror.git"
|
|
46
|
+
"""§16's wording, verbatim: `<root>/<board>/mirror.git`, beside events.jsonl."""
|
|
47
|
+
|
|
48
|
+
FETCH_TIMEOUT = 10.0
|
|
49
|
+
"""`remote.py::PUSH_TIMEOUT`'s argument, pointing the other way: a refresh is
|
|
50
|
+
never a gate. Worst case a reader waits ten seconds ONCE and gets the truth
|
|
51
|
+
about this disk — which is what `gitdoor` answers anyway."""
|
|
52
|
+
|
|
53
|
+
CLONE_TIMEOUT = 120.0
|
|
54
|
+
"""A first clone moves the whole history, once, at mount time — not per
|
|
55
|
+
request — so it gets `run.TIMEOUT`'s patience rather than `fetch`'s."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _url(forge: object) -> str:
|
|
59
|
+
"""The declared fact → the anonymous https remote, or "".
|
|
60
|
+
|
|
61
|
+
`forge_facts.understood` already collapses absent, cleared and
|
|
62
|
+
unintelligible to None, so an https URL is only ever built from a fact a
|
|
63
|
+
door would also act on. No token, no user@ — the address IS the whole
|
|
64
|
+
credential story for a public repo.
|
|
65
|
+
"""
|
|
66
|
+
fact = forge_facts.understood(forge)
|
|
67
|
+
if fact is None:
|
|
68
|
+
return ""
|
|
69
|
+
return f"https://{fact['host']}/{fact['repo']}.git"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def ensure(board_dir: Path, forge: object, *, url: str = "") -> Path | None:
|
|
73
|
+
"""The mirror's path — cloning it into existence if this is the first ask.
|
|
74
|
+
|
|
75
|
+
An EXISTING `mirror.git` is returned untouched, whatever remote it holds:
|
|
76
|
+
that is how an owner's ssh remote (a deploy key's address) survives every
|
|
77
|
+
later call. `url=` overrides the derived address at clone time — the same
|
|
78
|
+
owner's move, and how tests clone from a local fixture instead of the
|
|
79
|
+
network. No forge and no url means no mirror, which is the state every
|
|
80
|
+
board is born in; a clone that fails leaves nothing behind, so the next
|
|
81
|
+
ask is a clean retry rather than a corpse mistaken for a mirror.
|
|
82
|
+
"""
|
|
83
|
+
dest = board_dir / MIRROR
|
|
84
|
+
if dest.is_dir():
|
|
85
|
+
return dest
|
|
86
|
+
remote = url or _url(forge)
|
|
87
|
+
if not remote:
|
|
88
|
+
return None
|
|
89
|
+
try:
|
|
90
|
+
result = run.git("clone", "--mirror", remote, str(dest), timeout=CLONE_TIMEOUT)
|
|
91
|
+
except TaskopsError:
|
|
92
|
+
result = None # timeout, or no git at all — a request never hears it
|
|
93
|
+
if result is None or not result.ok:
|
|
94
|
+
return None
|
|
95
|
+
return dest
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def fetch(mirror: Path) -> bool:
|
|
99
|
+
"""One bounded fetch from the mirror's own remote. True means it ran clean.
|
|
100
|
+
|
|
101
|
+
A `--mirror` clone's fetch refspec already maps every ref one-to-one, so
|
|
102
|
+
plain `git fetch` IS the whole refresh — no refspec spelled here that
|
|
103
|
+
could drift from what clone configured. False covers everything a reader
|
|
104
|
+
cannot fix: a dead remote, a timeout, a directory that stopped being a
|
|
105
|
+
repo. The caller answers with what it has.
|
|
106
|
+
"""
|
|
107
|
+
try:
|
|
108
|
+
return run.git("fetch", cwd=mirror, timeout=FETCH_TIMEOUT).ok
|
|
109
|
+
except TaskopsError: # timeout, or no git at all — never the reader's problem
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _has(mirror: Path, ref: str) -> bool:
|
|
114
|
+
"""`^{commit}` is load-bearing: bare `rev-parse --verify <40-hex>` answers
|
|
115
|
+
ok for ANY well-formed sha, present or not — a ref arriving as a full sha
|
|
116
|
+
(which is what a diff door holds) would always read as here."""
|
|
117
|
+
return run.git("rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}", cwd=mirror).ok
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def refresh_if_missing(mirror: Path, ref: str) -> bool:
|
|
121
|
+
"""Is `ref` here — after at most ONE fetch if it was not?
|
|
122
|
+
|
|
123
|
+
§16's exact promise: a missing ref triggers one bounded on-demand fetch
|
|
124
|
+
before answering stale. A ref already present costs nothing — no network
|
|
125
|
+
touched, so a page over known history never waits on a forge. A ref still
|
|
126
|
+
absent after the fetch is answered False, truthfully: "not here" is the
|
|
127
|
+
truth about this disk, and the door already knows how to say so.
|
|
128
|
+
"""
|
|
129
|
+
if _has(mirror, ref):
|
|
130
|
+
return True
|
|
131
|
+
fetch(mirror)
|
|
132
|
+
return _has(mirror, ref)
|
taskops/http/auth.py
CHANGED
|
@@ -52,8 +52,26 @@ def token_in(header: str, path: str) -> str:
|
|
|
52
52
|
agent's client sends a header, a browser follows a link (`?token=`), and a
|
|
53
53
|
newcomer redeems an invite (`?invite=`). One extractor, so a door added
|
|
54
54
|
later — /feed, /git — cannot accidentally invent a second way in.
|
|
55
|
+
|
|
56
|
+
POST-MORTEM (shipped broken in 0.5.0, reported 2026-08-31): this used to be
|
|
57
|
+
`header.removeprefix("Bearer ").strip()`, and that one line made every
|
|
58
|
+
PUBLIC board unreadable from its own hosted page. The page's client sends
|
|
59
|
+
`Authorization: "Bearer " + token()` — with no token, the header reaches
|
|
60
|
+
the server as `Bearer` (the trailing space stripped in transit), the prefix
|
|
61
|
+
`"Bearer "` does not match, and the LITERAL STRING "Bearer" came back as
|
|
62
|
+
the token. `creds.check` then answered "unknown credential — run: taskops
|
|
63
|
+
join" to a reader who presented nothing at all. Anonymous reads worked only
|
|
64
|
+
when NO Authorization header was sent — which no browser client does. So:
|
|
65
|
+
scheme and value are parsed as separate whitespace-delimited fields, the
|
|
66
|
+
scheme case-insensitive per RFC 7235, and a bearer scheme with NO value is
|
|
67
|
+
NO credential presented — the query-string fallback and, behind it, the
|
|
68
|
+
anonymous gate still run. This is deliberately narrow: a header that
|
|
69
|
+
carries any actual value — wrong, expired, or under a scheme this server
|
|
70
|
+
never spoke — is a credential that WAS presented, and it refuses exactly as
|
|
71
|
+
it always has. "Unparseable" is not "anonymous".
|
|
55
72
|
"""
|
|
56
|
-
|
|
73
|
+
scheme, _, value = header.strip().partition(" ")
|
|
74
|
+
token = value.strip() if scheme.lower() == "bearer" else header.strip()
|
|
57
75
|
if token:
|
|
58
76
|
return token
|
|
59
77
|
for part in path.partition("?")[2].split("&"):
|
taskops/http/gitdoor.py
CHANGED
|
@@ -11,12 +11,14 @@ there is no second credential system, and `server.py` checks the credential
|
|
|
11
11
|
before this module is reached.
|
|
12
12
|
|
|
13
13
|
**Whether the door exists at all is decided ONCE, at construction**, never
|
|
14
|
-
re-derived per request
|
|
14
|
+
re-derived per request (`http/repos.py` resolves it per board). `taskops ui` sits
|
|
15
15
|
inside a repo (its root is `<repo>/.taskops`) and passes it; `taskops serve`
|
|
16
|
-
sits in a boards directory and passes nothing
|
|
17
|
-
404 whose message
|
|
18
|
-
through its cascade
|
|
19
|
-
|
|
16
|
+
sits in a boards directory and passes nothing FOR A BOARD WITH NO DECLARED
|
|
17
|
+
FORGE, so /git there is a 404 whose message says which case it is — the UI falls
|
|
18
|
+
through its cascade rather than a dead pane. A declared forge may instead mount
|
|
19
|
+
a bare read-only mirror (`<root>/<board>/mirror.git` — §16, "The hosted
|
|
20
|
+
window"): `NO_REPO` means "no checkout here and no mirror for this board",
|
|
21
|
+
never "this host can never read git".
|
|
20
22
|
|
|
21
23
|
This module only routes and refuses. Everything about git is `gitwork/diff.py`,
|
|
22
24
|
which is where the ref validation lives.
|
|
@@ -39,6 +41,7 @@ from typing import Any
|
|
|
39
41
|
from pathlib import Path
|
|
40
42
|
from urllib.parse import unquote
|
|
41
43
|
|
|
44
|
+
from . import stale
|
|
42
45
|
from ..core import reports
|
|
43
46
|
from .._errors import NotFound, BadRequest
|
|
44
47
|
from ..gitwork import diff, patch
|
|
@@ -46,26 +49,12 @@ from ..gitwork import diff, patch
|
|
|
46
49
|
NO_REPO = (
|
|
47
50
|
"this host serves boards, not a repository — it was started outside a "
|
|
48
51
|
"checkout (taskops serve), so there is no clone here to read a diff from. "
|
|
49
|
-
"A host that sits in a repo (taskops ui) answers /git
|
|
52
|
+
"A host that sits in a repo (taskops ui) answers /git; a board with a "
|
|
53
|
+
"declared forge answers from its mirror when the host can hold one."
|
|
50
54
|
)
|
|
51
55
|
|
|
52
56
|
SEPARATOR = "..."
|
|
53
57
|
|
|
54
|
-
STALE = (
|
|
55
|
-
"{refs} not in your clone yet — `{fetch}` brings {them}. The board is shared and "
|
|
56
|
-
"the code is not: a card's branch reaches origin when it closes, and this "
|
|
57
|
-
"window reads only the checkout it stands in. Nothing is fetched for you."
|
|
58
|
-
)
|
|
59
|
-
"""The MISSING-REF case, in its own words. It is not an error and must not read
|
|
60
|
-
like one: on a shared board most refs belong to somebody else's card, and until
|
|
61
|
-
you fetch, "not here yet" is simply the truth about your disk. Naming the exact
|
|
62
|
-
command is this codebase's habit — every refusal names the call that works —
|
|
63
|
-
and it is also the reason nothing fetches on the reader's behalf: a background
|
|
64
|
-
`git fetch` inside a read-only door would move a branch under a worktree
|
|
65
|
-
somebody is sitting in."""
|
|
66
|
-
|
|
67
|
-
SHA = "0123456789abcdef"
|
|
68
|
-
|
|
69
58
|
NOT_A_REPORT = (
|
|
70
59
|
"{path} is not a report. This door serves committed files under "
|
|
71
60
|
f"{reports.DIR} and nothing else — it is not a file server, and a read "
|
|
@@ -97,24 +86,34 @@ TEXT = "text/plain"
|
|
|
97
86
|
MARKDOWN = "text/markdown"
|
|
98
87
|
|
|
99
88
|
|
|
100
|
-
def answer(
|
|
101
|
-
|
|
89
|
+
def answer(
|
|
90
|
+
repo: Path | None, tail: str, query: str, *, mirrored: bool = False
|
|
91
|
+
) -> dict[str, Any]:
|
|
92
|
+
"""The whole door. `tail` is the path after `<board>/git/`. `mirrored`
|
|
93
|
+
says the repo is the board's mirror (`http/repos.py`), which is the one
|
|
94
|
+
case a missing ref buys a bounded fetch (`http/stale.py`)."""
|
|
102
95
|
if repo is None:
|
|
103
96
|
raise NotFound(NO_REPO)
|
|
104
97
|
kind, _, rest = tail.partition("/")
|
|
105
98
|
path = _param(query, "path") or None
|
|
106
99
|
if kind == "file" and rest:
|
|
107
|
-
return _file(repo, unquote(rest), path or "")
|
|
100
|
+
return _file(repo, unquote(rest), path or "", mirrored)
|
|
108
101
|
if kind == "commit" and rest:
|
|
109
102
|
ref = unquote(rest)
|
|
110
103
|
found = diff.commit_range(repo, ref)
|
|
104
|
+
if found is None and stale.refreshed(repo, mirrored, [ref]):
|
|
105
|
+
found = diff.commit_range(repo, ref)
|
|
111
106
|
if found is None:
|
|
112
|
-
raise NotFound(
|
|
107
|
+
raise NotFound(stale.sentence(ref))
|
|
113
108
|
elif kind == "compare" and SEPARATOR in rest:
|
|
114
109
|
left, _, right = unquote(rest).partition(SEPARATOR)
|
|
115
110
|
found = diff.compare_range(repo, left, right)
|
|
116
111
|
if found is None:
|
|
117
|
-
|
|
112
|
+
missing = [r for r in (left, right) if not diff.resolve(repo, r)]
|
|
113
|
+
if stale.refreshed(repo, mirrored, missing):
|
|
114
|
+
found = diff.compare_range(repo, left, right)
|
|
115
|
+
if found is None:
|
|
116
|
+
raise NotFound(stale.sentence(*missing))
|
|
118
117
|
else:
|
|
119
118
|
raise BadRequest(
|
|
120
119
|
"git/commit/<ref>, git/compare/<a>...<b>, or git/file/<rev>?path=<file>"
|
|
@@ -122,7 +121,7 @@ def answer(repo: Path | None, tail: str, query: str) -> dict[str, Any]:
|
|
|
122
121
|
return patch.between(repo, found[0], found[1], path)
|
|
123
122
|
|
|
124
123
|
|
|
125
|
-
def _file(repo: Path, rev: str, wanted: str) -> dict[str, Any]:
|
|
124
|
+
def _file(repo: Path, rev: str, wanted: str, mirrored: bool) -> dict[str, Any]:
|
|
126
125
|
"""One committed file at a rev — read-only, shape-guarded, capped.
|
|
127
126
|
|
|
128
127
|
Three walls, in this order: the path is a REPORT path (`under()`, which
|
|
@@ -144,8 +143,10 @@ def _file(repo: Path, rev: str, wanted: str) -> dict[str, Any]:
|
|
|
144
143
|
if not diff.usable(path):
|
|
145
144
|
raise BadRequest(ODD_SHAPE.format(path=path, dir=reports.DIR))
|
|
146
145
|
sha = diff.resolve(repo, rev)
|
|
146
|
+
if sha is None and stale.refreshed(repo, mirrored, [rev]):
|
|
147
|
+
sha = diff.resolve(repo, rev)
|
|
147
148
|
if sha is None:
|
|
148
|
-
raise NotFound(
|
|
149
|
+
raise NotFound(stale.sentence(rev))
|
|
149
150
|
got = patch.show(repo, sha, path)
|
|
150
151
|
if got is None:
|
|
151
152
|
raise NotFound(ABSENT.format(path=path, sha=sha[:12]))
|
|
@@ -160,26 +161,6 @@ def _file(repo: Path, rev: str, wanted: str) -> dict[str, Any]:
|
|
|
160
161
|
}
|
|
161
162
|
|
|
162
163
|
|
|
163
|
-
def _stale(*refs: str) -> str:
|
|
164
|
-
"""Which refs are missing, and the one command that brings them.
|
|
165
|
-
|
|
166
|
-
A sha is asked for WITHOUT a refspec — `git fetch origin <40 hex>` is
|
|
167
|
-
refused by most servers unless they allow it — while a branch is named, so
|
|
168
|
-
the reader can paste the line and get exactly what the pane wanted."""
|
|
169
|
-
names = [ref for ref in refs if ref] or ["that ref"]
|
|
170
|
-
branches = [ref for ref in names if not _looks_like_a_sha(ref)]
|
|
171
|
-
many = len(names) > 1
|
|
172
|
-
return STALE.format(
|
|
173
|
-
refs=f"{' and '.join(names)} {'are' if many else 'is'}",
|
|
174
|
-
fetch=" ".join(["git fetch origin", *branches]),
|
|
175
|
-
them="them" if many else "it",
|
|
176
|
-
)
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
def _looks_like_a_sha(ref: str) -> bool:
|
|
180
|
-
return len(ref) >= 7 and all(char in SHA for char in ref.lower())
|
|
181
|
-
|
|
182
|
-
|
|
183
164
|
def _param(query: str, key: str) -> str:
|
|
184
165
|
for part in query.split("&"):
|
|
185
166
|
name, _, value = part.partition("=")
|
taskops/http/handler.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""The request handler — one method per door, split out of `server.py` so the
|
|
2
2
|
router (the class) and the server's lifecycle (`BoardServer`, `serve`) each
|
|
3
|
-
have room. What each route MEANS is `server.py`'s docstring; this file is
|
|
4
|
-
how one request travels through it.
|
|
3
|
+
have room. What each route MEANS is `server.py`'s docstring; this file is
|
|
4
|
+
only how one request travels through it (`routes.py` reads the path).
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
@@ -13,6 +13,8 @@ from http.server import BaseHTTPRequestHandler
|
|
|
13
13
|
from . import rpc, feed, admin, login, static, gitdoor
|
|
14
14
|
from .. import _clock
|
|
15
15
|
from .auth import Credential, token_in, anonymous
|
|
16
|
+
from .mounts import NAME
|
|
17
|
+
from .routes import split
|
|
16
18
|
from .._errors import BadRequest, TaskopsError
|
|
17
19
|
from .._version import __version__
|
|
18
20
|
|
|
@@ -67,7 +69,10 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
67
69
|
elif tail.startswith("git/"):
|
|
68
70
|
self._git(board, tail[4:])
|
|
69
71
|
elif tail.startswith("ui"):
|
|
70
|
-
self._static(tail[2:])
|
|
72
|
+
self._static(board, tail[2:]) # 0.5.0's address, kept — links were pasted
|
|
73
|
+
elif self.mounts.ui is None and NAME.match(board) and (not tail or static.asset(tail)):
|
|
74
|
+
# the page at the board's OWN address; assets are a CLOSED set
|
|
75
|
+
self._static(board, tail)
|
|
71
76
|
elif (page := static.at_root(self.mounts.ui, self.path)) is not None:
|
|
72
77
|
self._send(200, *page) # a WINDOW serves its page at the ROOT
|
|
73
78
|
else:
|
|
@@ -121,14 +126,37 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
121
126
|
self._answer(lambda _: self._diff(board, rest))
|
|
122
127
|
|
|
123
128
|
def _diff(self, board: str, rest: str) -> dict[str, Any]:
|
|
124
|
-
"""
|
|
125
|
-
|
|
129
|
+
"""A window answers from its own LOCAL clone; a serve-mode host from
|
|
130
|
+
the board's forge mirror — `repos.py` decides which, per board (§16)."""
|
|
126
131
|
self.mounts.check(board)
|
|
127
132
|
self._credential(board, "read")
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
133
|
+
repo, mirrored = self.mounts.repos.for_board(board)
|
|
134
|
+
return gitdoor.answer(repo, rest, self.path.partition("?")[2], mirrored=mirrored)
|
|
135
|
+
|
|
136
|
+
def _static(self, board: str, rest: str) -> None:
|
|
137
|
+
"""The page — at the board's own root since tk-32d2ba, and still at
|
|
138
|
+
/ui/, the 0.5.0 address (kept: links were pasted). A WINDOW serves
|
|
139
|
+
its bundle to whoever reaches the port, unchanged. A serve-mode HOST
|
|
140
|
+
serves the SAME packaged bundle for a board
|
|
141
|
+
whose owner declared a forge (`repos.backed` — the fact, never a clone),
|
|
142
|
+
behind the credential /rpc asks for: public board, anonymous READ;
|
|
143
|
+
private board, the join refusal. The 410 comes BEFORE the credential on
|
|
144
|
+
purpose — it says nothing about the board a login would guard, and the
|
|
145
|
+
no-forge sentence predates keys on this door. A GET here runs no verb,
|
|
146
|
+
so an anonymous page load writes nothing — no presence row (§11)."""
|
|
147
|
+
if self.mounts.ui is not None:
|
|
148
|
+
self._send(*static.answer(self.mounts.ui, rest))
|
|
149
|
+
return
|
|
150
|
+
try:
|
|
151
|
+
self.mounts.check(board)
|
|
152
|
+
if not self.mounts.repos.backed(board):
|
|
153
|
+
self._send(*static.answer(None, rest))
|
|
154
|
+
return
|
|
155
|
+
self._credential(board, "read")
|
|
156
|
+
except TaskopsError as err:
|
|
157
|
+
self._fail(rpc.status_for(rpc.failure(err)), err)
|
|
158
|
+
return
|
|
159
|
+
self._send(*static.answer(static.PACKAGED, rest))
|
|
132
160
|
|
|
133
161
|
# ── plumbing ────────────────────────────────────────────────────────────
|
|
134
162
|
|
|
@@ -170,9 +198,3 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
170
198
|
|
|
171
199
|
def _fail(self, status: int, err: TaskopsError) -> None:
|
|
172
200
|
self._json(status, rpc.failure(err))
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
def split(path: str) -> tuple[str, str]:
|
|
176
|
-
clean = path.partition("?")[0].strip("/")
|
|
177
|
-
board, _, tail = clean.partition("/")
|
|
178
|
-
return board, tail
|
taskops/http/mounts.py
CHANGED
|
@@ -11,9 +11,10 @@ import re
|
|
|
11
11
|
from pathlib import Path
|
|
12
12
|
from threading import Lock
|
|
13
13
|
|
|
14
|
-
from . import feed, watcher
|
|
14
|
+
from . import feed, static, watcher
|
|
15
15
|
from .. import verbs, _clock
|
|
16
16
|
from .login import Host
|
|
17
|
+
from .repos import Repos
|
|
17
18
|
from ..verbs import project
|
|
18
19
|
from .._errors import NotFound, BadRequest
|
|
19
20
|
from .upstream import Upstream, seq_of
|
|
@@ -22,8 +23,6 @@ from ..store.stores import Stores
|
|
|
22
23
|
|
|
23
24
|
NAME = re.compile(r"^[a-z0-9][a-z0-9-]{0,39}$")
|
|
24
25
|
|
|
25
|
-
_PACKAGED_UI = Path(__file__).resolve().parent.parent / "ui"
|
|
26
|
-
|
|
27
26
|
class Mounts:
|
|
28
27
|
"""The boards this process serves, opened once and kept.
|
|
29
28
|
|
|
@@ -45,16 +44,16 @@ class Mounts:
|
|
|
45
44
|
) -> None:
|
|
46
45
|
self.root = root
|
|
47
46
|
self.upstream = upstream
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
#
|
|
47
|
+
# Whether this process can read a repo is decided by the CALLER at
|
|
48
|
+
# construction — a window's own checkout — or, on a serve-mode host,
|
|
49
|
+
# per BOARD from its declared forge's mirror (`repos.py` carries it).
|
|
51
50
|
self.repo = repo
|
|
52
|
-
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
-
#
|
|
57
|
-
self.ui =
|
|
51
|
+
self.repos = Repos(root, repo, self.stores)
|
|
52
|
+
# ONE switch, not two: the same `repo` that mounts /git mounts the bundle at
|
|
53
|
+
# the WINDOW's root. On a serve-mode host `ui` stays None and the /ui door asks
|
|
54
|
+
# `repos.backed()` per board instead — the forge fact, the same one that opens
|
|
55
|
+
# /git — see `static.py` and `repos.py` for the whole argument.
|
|
56
|
+
self.ui = static.PACKAGED if repo is not None else None
|
|
58
57
|
self.credentials = Credentials(root / "live.sqlite")
|
|
59
58
|
# The HOST's own identity, and it opens NOTHING until a login asks
|
|
60
59
|
# (`login.py::Host` says why lazily is a rule here, not a taste).
|
taskops/http/repos.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Which repo answers /git for a board — the window's checkout, or a mirror.
|
|
2
|
+
|
|
3
|
+
`Mounts.repo` used to be the WHOLE answer: one `Path | None` decided at
|
|
4
|
+
construction, `taskops ui` passing its checkout and `taskops serve` passing
|
|
5
|
+
nothing. §16's hosted window keeps the spirit — the switch is still a decided
|
|
6
|
+
fact, never a per-request sniff — but on a serve-mode host the fact is per
|
|
7
|
+
BOARD: the forge its owner declared (`core/forge.py`, read through the one
|
|
8
|
+
reader `verbs/project.py::forge`). A board with one gets
|
|
9
|
+
`gitwork/mirror.ensure()` lazily, resolved once and cached the way `Mounts`
|
|
10
|
+
caches stores; a board without one is refused with the door named, because a
|
|
11
|
+
refusal that does not say `taskops board forge` strands the reader in a 404.
|
|
12
|
+
|
|
13
|
+
The WINDOW case is unchanged and deliberately first: a host constructed with a
|
|
14
|
+
checkout answers from it for every board, and no mirror is ever consulted —
|
|
15
|
+
the local clone is the reader's own truth, mirrors are the host's.
|
|
16
|
+
|
|
17
|
+
The second half of the answer is `mirrored`: `gitdoor` may buy a missing ref
|
|
18
|
+
ONE bounded fetch on a mirror (`http/stale.py`), and must never do that to a
|
|
19
|
+
window's clone — a background fetch inside a read-only door would move a
|
|
20
|
+
branch under a worktree somebody is sitting in.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from typing import Callable
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from threading import Lock
|
|
28
|
+
|
|
29
|
+
from ..verbs import project
|
|
30
|
+
from .._errors import NotFound
|
|
31
|
+
from ..gitwork import mirror
|
|
32
|
+
from ..store.stores import Stores
|
|
33
|
+
|
|
34
|
+
NO_FORGE = (
|
|
35
|
+
"this host serves boards, not a repository — it was started outside a "
|
|
36
|
+
"checkout (taskops serve), and board {board!r} declares no forge to hold a "
|
|
37
|
+
"mirror of. `taskops board forge <owner>/<repo>` is the door: the declared "
|
|
38
|
+
"forge is the one source this host may mirror, read-only."
|
|
39
|
+
)
|
|
40
|
+
"""The serve-mode refusal, naming the move that opens it (§16: the refusal
|
|
41
|
+
names the door). It replaces the old blanket NO_REPO here because on a board
|
|
42
|
+
host "no repo" is a board-level fact with a board-level remedy."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Repos:
|
|
46
|
+
"""Per-board repo resolution for one server process.
|
|
47
|
+
|
|
48
|
+
`checkout` is the construction-time window repo, exactly as `Mounts.repo`
|
|
49
|
+
was; `stores` is `Mounts.stores`, so the forge fact is read from the same
|
|
50
|
+
open board every other door reads. The cache holds only RESOLVED mirrors:
|
|
51
|
+
a refusal is re-derived per ask (the fact may be declared any moment), and
|
|
52
|
+
a failed clone leaves nothing behind, so the next ask is a clean retry.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self, root: Path, checkout: Path | None, stores: Callable[[str], Stores]
|
|
57
|
+
) -> None:
|
|
58
|
+
self.root = root
|
|
59
|
+
self.checkout = checkout
|
|
60
|
+
self._stores = stores
|
|
61
|
+
self._lock = Lock()
|
|
62
|
+
self._mirrors: dict[str, Path] = {}
|
|
63
|
+
|
|
64
|
+
def for_board(self, name: str) -> tuple[Path | None, bool]:
|
|
65
|
+
"""`(repo, mirrored)` — the repo /git reads for this board, and whether
|
|
66
|
+
it is a mirror (which is what licenses the one on-demand fetch)."""
|
|
67
|
+
if self.checkout is not None:
|
|
68
|
+
return self.checkout, False
|
|
69
|
+
with self._lock:
|
|
70
|
+
found = self._mirrors.get(name)
|
|
71
|
+
if found is not None:
|
|
72
|
+
return found, True
|
|
73
|
+
fact = project.forge(self._stores(name))
|
|
74
|
+
if fact is None:
|
|
75
|
+
raise NotFound(NO_FORGE.format(board=name))
|
|
76
|
+
made = mirror.ensure(self.root / name, fact)
|
|
77
|
+
if made is None: # clone failed or impossible — gitdoor says NO_REPO
|
|
78
|
+
return None, False
|
|
79
|
+
with self._lock:
|
|
80
|
+
self._mirrors[name] = made
|
|
81
|
+
return made, True
|
|
82
|
+
|
|
83
|
+
def backed(self, name: str) -> bool:
|
|
84
|
+
"""Does a window open for this board here — the FACT, never the mirror.
|
|
85
|
+
|
|
86
|
+
/ui asks this instead of `for_board` on purpose: the page it serves is
|
|
87
|
+
the package's own bundle (`static.PACKAGED`), so its bytes need no repo
|
|
88
|
+
at all — resolving one would put a network clone in front of
|
|
89
|
+
`index.html` for nothing, and a clone that FAILS (forge down, key
|
|
90
|
+
missing) would take the page down with it. The declared forge alone
|
|
91
|
+
opens the door; the page's own /git calls go through `for_board` and
|
|
92
|
+
resolve the mirror lazily the moment a diff is actually read — which is
|
|
93
|
+
also where a resolution failure belongs: on the diff pane, in
|
|
94
|
+
gitdoor's words, not on the page load. A window's checkout answers for
|
|
95
|
+
every board, exactly as `for_board`'s first clause does."""
|
|
96
|
+
if self.checkout is not None:
|
|
97
|
+
return True
|
|
98
|
+
return project.forge(self._stores(name)) is not None
|
taskops/http/routes.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Where a request path splits into `(board, tail)` — ONE function, shared by
|
|
2
|
+
GET and POST so the two methods cannot disagree about what a path names. Split
|
|
3
|
+
out of `handler.py` at the seam that owns no HTTP at all (this module sees
|
|
4
|
+
strings, never sockets), because the handler sits on the ≤200-line budget
|
|
5
|
+
`tests/test_architecture.py` enforces.
|
|
6
|
+
|
|
7
|
+
`api/` is stripped HERE, once. Since the board's own address became the page
|
|
8
|
+
(tk-32d2ba), the machine doors — rpc, git, feed, invite/redeem — also answer
|
|
9
|
+
under `/<board>/api/…`, which is the spelling the page uses from now on.
|
|
10
|
+
Stripping in the split rather than per door means every door gets both
|
|
11
|
+
spellings and none can drift: `/<board>/api/rpc` IS `/<board>/rpc` — same
|
|
12
|
+
handler, same credential, same words. The 0.5.0 spellings stay, unprefixed,
|
|
13
|
+
because agents, the MCP client, `taskops ui`'s upstream forward and four
|
|
14
|
+
legacy production boards speak them today.
|
|
15
|
+
|
|
16
|
+
The prefix cannot shadow a page asset: the packaged bundle's filenames are a
|
|
17
|
+
CLOSED SET and none of them is named `api` (`static.asset` argues the same
|
|
18
|
+
fact from the router's other side). And the ROOT is untouched — `/login`,
|
|
19
|
+
`/rpc` and `/healthz` are one segment, so the strip, which lives in the TAIL,
|
|
20
|
+
never sees them; a board named `api` still answers at `/api/…` because the
|
|
21
|
+
strip runs after the board segment is taken.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def split(path: str) -> tuple[str, str]:
|
|
28
|
+
clean = path.partition("?")[0].strip("/")
|
|
29
|
+
board, _, tail = clean.partition("/")
|
|
30
|
+
if tail.startswith("api/"):
|
|
31
|
+
tail = tail[4:]
|
|
32
|
+
return board, tail
|
taskops/http/stale.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""The MISSING-REF case, in its own words — and the one fetch a mirror buys.
|
|
2
|
+
|
|
3
|
+
Split out of `gitdoor.py` when the mirror chapter pushed it past the module
|
|
4
|
+
budget: this is the cohesive seam, because everything here is about one
|
|
5
|
+
question — a ref the repo lacks — and nothing here routes or reads git ranges.
|
|
6
|
+
|
|
7
|
+
It is not an error and must not read like one: on a shared board most refs
|
|
8
|
+
belong to somebody else's card, and until you fetch, "not here yet" is simply
|
|
9
|
+
the truth about your disk. Naming the exact command is this codebase's habit —
|
|
10
|
+
every refusal names the call that works — and it is also the reason nothing
|
|
11
|
+
fetches on a WINDOW's behalf: a background `git fetch` inside a read-only door
|
|
12
|
+
would move a branch under a worktree somebody is sitting in. A MIRROR is the
|
|
13
|
+
host's own derived copy with nobody sitting in it, so there — and only there —
|
|
14
|
+
a missing ref buys exactly one bounded fetch before the stale sentence
|
|
15
|
+
(`gitwork/mirror.py::refresh_if_missing`, §16's promise).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from ..gitwork import mirror
|
|
23
|
+
|
|
24
|
+
STALE = (
|
|
25
|
+
"{refs} not in your clone yet — `{fetch}` brings {them}. The board is shared and "
|
|
26
|
+
"the code is not: a card's branch reaches origin when it closes, and this "
|
|
27
|
+
"window reads only the checkout it stands in. Nothing is fetched for you."
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
SHA = "0123456789abcdef"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def refreshed(repo: Path, mirrored: bool, refs: list[str]) -> bool:
|
|
34
|
+
"""True when a fetch may have brought the missing refs — retry the read.
|
|
35
|
+
|
|
36
|
+
Only the FIRST missing ref pays: `refresh_if_missing` runs at most one
|
|
37
|
+
fetch, and a mirror's fetch brings every ref at once, so the caller's
|
|
38
|
+
retry covers the rest. `mirrored` False is the window's clone and answers
|
|
39
|
+
False without touching the network, which is the §16 sentence above."""
|
|
40
|
+
if not mirrored or not refs:
|
|
41
|
+
return False
|
|
42
|
+
return mirror.refresh_if_missing(repo, refs[0])
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def sentence(*refs: str) -> str:
|
|
46
|
+
"""Which refs are missing, and the one command that brings them.
|
|
47
|
+
|
|
48
|
+
A sha is asked for WITHOUT a refspec — `git fetch origin <40 hex>` is
|
|
49
|
+
refused by most servers unless they allow it — while a branch is named, so
|
|
50
|
+
the reader can paste the line and get exactly what the pane wanted."""
|
|
51
|
+
names = [ref for ref in refs if ref] or ["that ref"]
|
|
52
|
+
branches = [ref for ref in names if not _looks_like_a_sha(ref)]
|
|
53
|
+
many = len(names) > 1
|
|
54
|
+
return STALE.format(
|
|
55
|
+
refs=f"{' and '.join(names)} {'are' if many else 'is'}",
|
|
56
|
+
fetch=" ".join(["git fetch origin", *branches]),
|
|
57
|
+
them="them" if many else "it",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _looks_like_a_sha(ref: str) -> bool:
|
|
62
|
+
return len(ref) >= 7 and all(char in SHA for char in ref.lower())
|