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 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.0" # 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/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
@@ -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
- """Mounted from the LOCAL clone whether the board is local or remote —
125
- that is the whole point of serving the window here (§16)."""
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
- 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))
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
- # 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/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
- **And only a WINDOW serves it.** `taskops serve` — a board host — answers /rpc,
4
- /feed, /healthz and nothing else; its /ui/ is one sentence naming `taskops ui`
5
- (`NO_UI` below, answered `410 Gone`).
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 is read from the reader's own clone. The server deliberately
9
- has no clone — it never gains a repo, a checkout or a git credential — so a page
10
- served from it could only ever fall through every step of `links.tsx::cascade`
11
- to a forge link or a sentence. That was serving a degraded window and calling it
12
- *the* window. The binary serves the window; the server serves the truth.
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 it is never
17
- coming back to this host. The body is plain text, not the JSON error envelope,
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 host serves the board, not the dashboard — run `taskops ui` in a "
32
- "checkout joined to this board and it serves the window itself, reading "
33
- "diffs from your own clone.\n"
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
- this process is a board host — see the module docstring for why that is a
120
- 410 and one sentence rather than a page."""
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)