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 CHANGED
@@ -1,4 +1,4 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  __title__ = "taskops"
4
- __version__ = "0.4.2" # single source of truth; pyproject reads it dynamically
4
+ __version__ = "0.5.1" # single source of truth; pyproject reads it dynamically
@@ -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
- token = header.removeprefix("Bearer ").strip()
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: `Mounts` carries `repo: Path | None`. `taskops ui` sits
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, so every /git request there is a
17
- 404 whose message SAYS which case it is — the UI reads those words and falls
18
- through its cascade (numstat → /git → the forge link → an honest sentence)
19
- rather than showing a dead pane. ARCHITECTURE.md §16.
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(repo: Path | None, tail: str, query: str) -> dict[str, Any]:
101
- """The whole door. `tail` is the path after `<board>/git/`."""
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(_stale(ref))
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
- raise NotFound(_stale(*(r for r in (left, right) if not diff.resolve(repo, r))))
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(_stale(rev))
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 only
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
- """Mounted from the LOCAL clone whether the board is local or remote —
125
- that is the whole point of serving the window here (§16)."""
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
- return gitdoor.answer(self.mounts.repo, rest, self.path.partition("?")[2])
129
-
130
- def _static(self, rest: str) -> None: # `static.py` owns what it answers, and why
131
- self._send(*static.answer(self.mounts.ui, rest))
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
- # The ONE place that decides whether this process can read a repo, and
49
- # it is decided by the CALLER at construction, never sniffed per
50
- # request (`gitdoor.py` carries the rest).
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
- # ONE switch, not two: the same `repo` that mounts /git mounts the bundle. A
53
- # dashboard needs the viewer's CLONE to draw a diff, so a process with no clone
54
- # has no business serving one — see `static.py` for the whole post-mortem. The
55
- # bundle still ships inside the wheel; what went away is the server-side mount
56
- # and the `--ui` flag that configured it.
57
- self.ui = _PACKAGED_UI if repo is not None else None
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())