taskops-cli 0.4.2__py3-none-any.whl → 0.5.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.
- taskops/_version.py +1 -1
- taskops/gitwork/mirror.py +132 -0
- taskops/http/gitdoor.py +29 -48
- taskops/http/handler.py +28 -7
- taskops/http/mounts.py +11 -12
- taskops/http/repos.py +98 -0
- taskops/http/stale.py +62 -0
- taskops/http/static.py +30 -15
- taskops/ui/app.js +3 -3
- {taskops_cli-0.4.2.dist-info → taskops_cli-0.5.0.dist-info}/METADATA +7 -5
- {taskops_cli-0.4.2.dist-info → taskops_cli-0.5.0.dist-info}/RECORD +13 -10
- {taskops_cli-0.4.2.dist-info → taskops_cli-0.5.0.dist-info}/WHEEL +0 -0
- {taskops_cli-0.4.2.dist-info → taskops_cli-0.5.0.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/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
|
@@ -67,7 +67,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
67
67
|
elif tail.startswith("git/"):
|
|
68
68
|
self._git(board, tail[4:])
|
|
69
69
|
elif tail.startswith("ui"):
|
|
70
|
-
self._static(tail[2:])
|
|
70
|
+
self._static(board, tail[2:])
|
|
71
71
|
elif (page := static.at_root(self.mounts.ui, self.path)) is not None:
|
|
72
72
|
self._send(200, *page) # a WINDOW serves its page at the ROOT
|
|
73
73
|
else:
|
|
@@ -121,14 +121,35 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
121
121
|
self._answer(lambda _: self._diff(board, rest))
|
|
122
122
|
|
|
123
123
|
def _diff(self, board: str, rest: str) -> dict[str, Any]:
|
|
124
|
-
"""
|
|
125
|
-
|
|
124
|
+
"""A window answers from its own LOCAL clone; a serve-mode host from
|
|
125
|
+
the board's forge mirror — `repos.py` decides which, per board (§16)."""
|
|
126
126
|
self.mounts.check(board)
|
|
127
127
|
self._credential(board, "read")
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
128
|
+
repo, mirrored = self.mounts.repos.for_board(board)
|
|
129
|
+
return gitdoor.answer(repo, rest, self.path.partition("?")[2], mirrored=mirrored)
|
|
130
|
+
|
|
131
|
+
def _static(self, board: str, rest: str) -> None:
|
|
132
|
+
"""The /ui door. A WINDOW serves its bundle to whoever reaches the port,
|
|
133
|
+
unchanged. A serve-mode HOST serves the SAME packaged bundle for a board
|
|
134
|
+
whose owner declared a forge (`repos.backed` — the fact, never a clone),
|
|
135
|
+
behind the credential /rpc asks for: public board, anonymous READ;
|
|
136
|
+
private board, the join refusal. The 410 comes BEFORE the credential on
|
|
137
|
+
purpose — it says nothing about the board a login would guard, and the
|
|
138
|
+
no-forge sentence predates keys on this door. A GET here runs no verb,
|
|
139
|
+
so an anonymous page load writes nothing — no presence row (§11)."""
|
|
140
|
+
if self.mounts.ui is not None:
|
|
141
|
+
self._send(*static.answer(self.mounts.ui, rest))
|
|
142
|
+
return
|
|
143
|
+
try:
|
|
144
|
+
self.mounts.check(board)
|
|
145
|
+
if not self.mounts.repos.backed(board):
|
|
146
|
+
self._send(*static.answer(None, rest))
|
|
147
|
+
return
|
|
148
|
+
self._credential(board, "read")
|
|
149
|
+
except TaskopsError as err:
|
|
150
|
+
self._fail(rpc.status_for(rpc.failure(err)), err)
|
|
151
|
+
return
|
|
152
|
+
self._send(*static.answer(static.PACKAGED, rest))
|
|
132
153
|
|
|
133
154
|
# ── plumbing ────────────────────────────────────────────────────────────
|
|
134
155
|
|
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/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())
|
taskops/http/static.py
CHANGED
|
@@ -1,20 +1,27 @@
|
|
|
1
1
|
"""Serving the UI bundle. Small on purpose: the board is an API, not a website.
|
|
2
2
|
|
|
3
|
-
**
|
|
4
|
-
|
|
5
|
-
(`
|
|
3
|
+
**A WINDOW serves it — and, since §16's hosted-window amendment, so does a
|
|
4
|
+
serve-mode host, but only for a board whose owner declared a forge.** The
|
|
5
|
+
handler asks `repos.backed()` for the FACT and serves `PACKAGED` behind the
|
|
6
|
+
same credential /rpc asks for; a board with no forge keeps the one sentence
|
|
7
|
+
(`NO_UI` below, answered `410 Gone`), which now names both ways out.
|
|
6
8
|
|
|
7
9
|
Why, and it is the same rule as /git's (ARCHITECTURE.md §16): a dashboard shows
|
|
8
|
-
DIFFS, and a diff
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
DIFFS, and a diff needs a repo to read. A host with none — the state every
|
|
11
|
+
board's host is in until its owner declares a forge — could only serve a page
|
|
12
|
+
that falls through every step of `links.tsx::cascade` to a forge link or a
|
|
13
|
+
sentence. That was serving a degraded window and calling it *the* window. The
|
|
14
|
+
binary serves the window; the server serves the truth. What ends that state is
|
|
15
|
+
§16's hosted-window amendment: a bare READ-ONLY mirror of the DECLARED forge
|
|
16
|
+
(`<root>/<board>/mirror.git`), and only a board that holds one gets `/ui/`
|
|
17
|
+
back. `NO_UI` therefore means "this board declared no forge", never "this host
|
|
18
|
+
can never have a repo".
|
|
13
19
|
|
|
14
20
|
`410 Gone` rather than 404 because the two say different things to whoever typed
|
|
15
21
|
the URL: 404 is "no idea what that is, maybe later", and this is neither vague
|
|
16
|
-
nor temporary. The page WAS here, it was withdrawn on purpose, and
|
|
17
|
-
|
|
22
|
+
nor temporary. The page WAS here, it was withdrawn on purpose, and only the
|
|
23
|
+
owner's own act (`taskops board forge`) brings it back — never time, never a
|
|
24
|
+
retry. The body is plain text, not the JSON error envelope,
|
|
18
25
|
because the one reader who reaches this door is a human with a browser.
|
|
19
26
|
|
|
20
27
|
The bundle is NOT removed from the package: `src/taskops/ui/` still ships inside
|
|
@@ -28,11 +35,18 @@ from __future__ import annotations
|
|
|
28
35
|
from pathlib import Path
|
|
29
36
|
|
|
30
37
|
NO_UI = (
|
|
31
|
-
"this
|
|
32
|
-
"
|
|
33
|
-
"
|
|
38
|
+
"this board's window is not served here — it declares no forge for this "
|
|
39
|
+
"host to mirror. Two ways in: run `taskops ui` in a checkout joined to "
|
|
40
|
+
"this board (the window reads your own clone), or the owner declares the "
|
|
41
|
+
"repo with `taskops board forge <owner>/<repo>` and this host serves the "
|
|
42
|
+
"page itself.\n"
|
|
34
43
|
)
|
|
35
44
|
|
|
45
|
+
PACKAGED = Path(__file__).resolve().parent.parent / "ui"
|
|
46
|
+
"""The bundle the wheel ships (`src/taskops/ui/`). A WINDOW mounts it via
|
|
47
|
+
`Mounts.ui`; a serve-mode host serves the SAME files for a board whose owner
|
|
48
|
+
declared a forge — one copy, never a second bundle path to drift."""
|
|
49
|
+
|
|
36
50
|
MISSING = "no UI bundle is installed here — this checkout's taskops has none.\n"
|
|
37
51
|
"""A window that HAS a clone but whose install carries no bundle. Distinct from
|
|
38
52
|
`NO_UI`: that one is a design, this one is a broken install."""
|
|
@@ -116,8 +130,9 @@ def at_root(root: Path | None, path: str) -> tuple[bytes, str] | None:
|
|
|
116
130
|
|
|
117
131
|
def answer(root: Path | None, rest: str) -> tuple[int, bytes, str]:
|
|
118
132
|
"""The WHOLE /ui door: (status, body, content-type). `root is None` means
|
|
119
|
-
|
|
120
|
-
|
|
133
|
+
NO window opens for this board here — a board host whose board declared no
|
|
134
|
+
forge — and the module docstring argues why that is a 410 and one sentence
|
|
135
|
+
(now naming BOTH ways out) rather than a page."""
|
|
121
136
|
if root is None:
|
|
122
137
|
return 410, NO_UI.encode(), "text/plain; charset=utf-8"
|
|
123
138
|
found = payload(root, rest)
|