diff --git a/docs/status.md b/docs/status.md
index 7a45bf1..b64abf3 100644
--- a/docs/status.md
+++ b/docs/status.md
@@ -58,6 +58,22 @@ read is a policy nobody has agreed to.
its suite runs against a blank one per phase. That path has been exercised by **one** project, and
`alembic upgrade head` in its test command is what made it necessary.
+**A verified upgrade can be opened from the page, and only by a person.** As of item 245: an upgrade
+this instance verified green — applied in a clone, your suite run before and after — carries the files
+it passed with and the commit it ran at, so pressing the control on its row opens a draft pull request
+holding exactly that, rooted at exactly that commit. Nothing is opened on a clock: this instance
+verifies on its own schedule and **never** opens by itself (DR-0026).
+
+Two things that follow, and both are refusals you will meet:
+
+- **The half that renders the page cannot push.** It writes down that you asked; the other process,
+ the one with no socket and the code credential, opens it on its next turn. So the pull request
+ appears seconds later and the page says which state the row is in rather than pretending the click
+ was the act.
+- **Your manifest outranks the button.** `autofix.open_upgrades` is `false` by default, and while it
+ is, the page tells you how many passed and that none can be opened. Having the credential is not the
+ same as having agreed, and the report is what there is to act on either way.
+
## What does not exist yet
- **Sentry's webhook route is enabled since `0.1.0a8`, and its signature is not verified.** It is
diff --git a/hullwork.yml b/hullwork.yml
index f555613..4990469 100644
--- a/hullwork.yml
+++ b/hullwork.yml
@@ -27,6 +27,14 @@ autofix:
# does not exist yet, and it refuses without a code credential the live instance does not hold.
agent: claude-code
sandbox: docker
+ # Permission, not capability (DR-0019). Turned on 2026-08-13 by the operator, once item 245 made
+ # the request a person's act: a verified-green upgrade carries the files its suite passed with and
+ # the commit it ran at, and pressing the control on the page opens a draft pull request holding
+ # exactly that. Nothing here opens on a clock — the instance verifies on its own schedule and
+ # never opens by itself. What this line says to a contributor reading it is: on this repository,
+ # a human may ask for that, and every one of them still arrives as a draft nobody but a human
+ # merges.
+ open_upgrades: true
lanes:
green:
- typeerror
diff --git a/hullwork/advisories.py b/hullwork/advisories.py
new file mode 100644
index 0000000..df5b0c5
--- /dev/null
+++ b/hullwork/advisories.py
@@ -0,0 +1,126 @@
+"""What OSV has published against what a project pins. DR-0024, item 230.
+
+The half of the product that needs **no model, no write credential and no Docker** — the half an
+evaluator can use on their first day — left no trace in a running instance until this: `hullwork
+deps` opened no session, stored nothing, and could not even run inside the container.
+
+**What this module is and is not.** It reads, asks and returns; it writes no rows and knows nothing
+about pages or clocks. The caller stores the answer, because *when it was asked* is half of it and
+that belongs with the row rather than in here.
+
+**The verification half stays where it is.** Applying an upgrade and running a suite needs the
+Docker socket, and DR-0005 gives the receiver none. This can say what is published; only the
+dispatcher can say whether the fix survives your tests.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Callable, Sequence
+from dataclasses import dataclass, field
+from typing import Any, Protocol
+
+from hullwork import dependencies
+from hullwork.osv import Finding, Osv
+
+
+class Tree(Protocol):
+ """A listing. **Read-only properties, not attributes**: a `Protocol` declaring a mutable
+ attribute is invariant, so the forge's own `Tree` — whose `paths` is a `tuple` — does not
+ satisfy `paths: Sequence[str]` however obviously it does in practice."""
+
+ @property
+ def paths(self) -> Sequence[str]: ...
+
+ @property
+ def truncated(self) -> bool: ...
+
+
+class Reads(Protocol):
+ def tree(self, repo: str) -> Tree: ...
+ def read_file(self, repo: str, path: str) -> str | None: ...
+
+
+@dataclass(frozen=True)
+class Report:
+ """What was found, and whether the question was asked at all.
+
+ **`asked=False` with a `note` is the answer, not the absence of one.** An advisory list that
+ silently reads empty when OSV was unreachable says *you are fine* on no evidence, which is the
+ worst failure this feature can have — and it is the operator's own condition on DR-0024.
+ """
+
+ asked: bool
+ pinned: int = 0
+ findings: list[dict[str, Any]] = field(default_factory=list)
+ note: str | None = None
+
+
+def as_rows(found: Sequence[Finding]) -> list[dict[str, Any]]:
+ """The findings as the row stores them. One shape, so the page never sees an `Advisory`."""
+ return [
+ {
+ "package": one.dependency.name,
+ "version": one.dependency.version,
+ "source": one.dependency.source,
+ "advisories": [
+ {"id": a.id, "summary": a.summary, "fixed": list(a.fixed)} for a in one.advisories
+ ],
+ }
+ for one in found
+ if one.advisories
+ ]
+
+
+def about(repo: str, forge: Reads, ask: Callable[[Sequence[Any]], list[Finding]]) -> Report:
+ """Read what this repository pins, and ask what is published against it.
+
+ Every failure is a `Report` rather than an exception, and each says which half failed: a forge
+ that will not list a tree and a database that will not answer are different problems with
+ different fixes, and *something went wrong* is neither.
+ """
+ try:
+ listing = forge.tree(repo)
+ except Exception as exc:
+ return Report(asked=False, note=f"could not list {repo}: {exc}")
+
+ pinned = dependencies.read_lockfiles(
+ list(listing.paths), lambda path: _read(forge, repo, path)
+ )
+ if not pinned:
+ return Report(
+ asked=True,
+ note=(
+ "nothing here pins a version: no lock file and no `==` in a requirements file, so "
+ "there is nothing to ask about. A declaration is a range, and a range is not a "
+ "fact about what your build resolved to"
+ ),
+ )
+ try:
+ found = ask(pinned)
+ except Exception as exc:
+ return Report(
+ asked=False,
+ pinned=len(pinned),
+ note=f"read {len(pinned)} pinned version(s) and could not reach OSV: {exc}",
+ )
+ return Report(asked=True, pinned=len(pinned), findings=as_rows(found))
+
+
+def _read(forge: Reads, repo: str, path: str) -> str | None:
+ """One file, or `None`. A file that will not read costs its own contribution and no more —
+ `read_lockfiles` already treats that as *this file said nothing*, which is the honest reading
+ of a `package-lock.json` the forge refused while `uv.lock` came back fine."""
+ try:
+ return forge.read_file(repo, path)
+ except Exception:
+ return None
+
+
+def asking(timeout: float = 20.0) -> Callable[[Sequence[Any]], list[Finding]]:
+ """A callable that asks the real OSV and closes after itself."""
+
+ def _ask(deps: Sequence[Any]) -> list[Finding]:
+ with Osv(timeout=timeout) as osv:
+ return osv.affected(deps)
+
+ return _ask
diff --git a/hullwork/cli.py b/hullwork/cli.py
index c01b64d..85122be 100644
--- a/hullwork/cli.py
+++ b/hullwork/cli.py
@@ -14,13 +14,12 @@
import json
import logging
import os
-import shutil
import signal
import subprocess
import sys
import threading
from collections.abc import Callable, Sequence
-from contextlib import ExitStack, suppress
+from contextlib import suppress
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from pathlib import Path
@@ -47,7 +46,6 @@
readiness,
recurrence,
refit,
- resolve,
spend,
territory,
triage,
@@ -55,7 +53,6 @@
work,
)
from hullwork import decisions as decide
-from hullwork import dispatch as dispatch_module
from hullwork import features as features_module
from hullwork import upstream as upstream_module
from hullwork.config import ConfigError, Settings, get_settings
@@ -545,7 +542,7 @@ def _verify_upgrades(
)
)
continue
- report = _verify_one(checkout, paths, read, manifest, dep, versions, out)
+ report = upgrades.verify_one(checkout, paths, read, manifest, dep, versions, out)
if report is not None:
reports.append(report)
@@ -894,146 +891,6 @@ def _print_the_queue(reports: Sequence[bump.Report], out: TextIO) -> None:
print("", file=out)
-def _verify_one(
- checkout: Path,
- paths: Sequence[str],
- read: Callable[[str], str | None],
- manifest: Manifest,
- dep: dependencies.Dependency,
- versions: list[str],
- out: TextIO,
-) -> bump.Report | None:
- """One package, every candidate, each in its own sandbox."""
- from hullwork import trial
- from hullwork.sandbox import image as image_module
- from hullwork.sandbox.run import Sandbox
-
- runtime = manifest.runtime
- assert runtime is not None # noqa: S101 - refused above, and mypy cannot see that
- tests = manifest.tests or ""
- source = dep.source
-
- # Which candidate `verify` is on, so a resolver-backed mover knows what to ask for.
- _pending: dict[str, str] = {"version": ""}
-
- with ExitStack() as stack:
- worktree = dispatch_module.prepare_worktree(checkout)
- stack.callback(shutil.rmtree, worktree, ignore_errors=True)
-
- def files_now() -> dict[str, bytes]:
- """The declared dependency files as they are in the worktree right now.
-
- Read per build rather than once: the rewrite happens between the two, and the second
- build has to see it — `image.dependency_digest` then makes the tag differ by itself,
- which is what turns the second build into a real rebuild.
- """
- found: dict[str, bytes] = {}
- for path in runtime.dependencies or [source]:
- whole = worktree / path
- if whole.exists():
- found[path] = whole.read_bytes()
- return found
-
- built: dict[str, str] = {}
- # **The commit the source is at, when the source goes into the build at all** (item 182).
- # Read once: it is what `image_tag` hashes to decide whether an image can be reused, and the
- # source does not move between candidates — only the dependency files do, and those are
- # hashed separately by `dependency_digest`.
- source_ref = trial.head_sha(checkout) if runtime.install_needs_source else None
-
- def build_now() -> str | None:
- try:
- image = image_module.build(
- runtime, files_now(), None,
- # **Item 113's fix, which this path never inherited** (found by item 182, on
- # the first third-party tree it was pointed at). The build context holds the
- # declared dependency files and never the source, and three ordinary installers
- # read the source anyway: a `requirements.txt` beginning `-e .`, a `Gemfile`
- # that says `gemspec`, and `mvn test`. Measured on `encode/httpx`, whose first
- # requirement is `-e .[brotli,cli,http2,socks,zstd]`:
- #
- # ERROR: file:///work does not appear to be a Python project:
- # neither 'setup.py' nor 'pyproject.toml' found.
- #
- # Reported as *your own environment does not build*, which was true of what we
- # built and false of the project. Ruby, Java and PHP are on the roadmap as
- # stacks whose attempts work; every one of them reaches this the same way.
- source=worktree if runtime.install_needs_source else None,
- source_ref=source_ref,
- )
- except image_module.ImageBuildError as failed:
- return str(failed)
- built["tag"] = image.tag
- return None
-
- # The baseline image, before anything is rewritten. A failure here is the project's
- # environment, not the upgrade's, so it is said as that.
- problem = build_now()
- if problem is not None:
- print(f" {dep.name}: your own environment does not build — {problem}\n", file=out)
- return None
-
- made = {"n": 0}
-
- def make_box(_version: str) -> bump.Box:
- """A box on **whatever image `built` holds right now**.
-
- Called once per run rather than once per candidate, because the second run has to
- happen on the rebuilt image — reusing the first box measures the upgraded project's
- suite against the environment it replaced, and reports `clean` for a version that was
- never installed. Found by a real Docker run; see item 174.
- """
- made["n"] += 1
- # Built from the worktree **as it is now**, which is what makes each run happen in the
- # environment its own tree describes. Cheap when nothing changed: the digest is the
- # content, so `build` reuses the existing image rather than making another.
- build_now()
- box = Sandbox(image=built["tag"], worktree=worktree)
- stack.callback(box.cleanup)
- box.ensure_volume(
- f"hullwork-deps-{os.getpid()}-{made['n']}",
- # **Item 114's fix, which this path never inherited either** (item 182). Anything
- # the build installed under `/work` is erased by the worktree volume unless the
- # image goes down first — which is what `vendor/` is for PHP, and the reason that
- # item exists. Off unless the project asks, so every other project takes the path
- # it took yesterday.
- seed_from_image=runtime.install_needs_source,
- )
- return box # type: ignore[return-value]
-
- # How this file is moved, and everything moving it can touch (items 175 and 176). For a
- # list the line is the pin; for a resolved graph only the ecosystem's own tool may move it,
- # and `touches` is what stops one candidate leaving a widened range behind for the next.
- resolver = resolve.resolver_for(source)
- mover = None
- guarded: tuple[str, ...] = (source,)
- if resolver is not None:
- guarded = resolve.touches(resolver)
- here = [p for p in paths if p.rsplit("/", 1)[-1] in set(resolver.needs)]
-
- def mover(worktree: Path, _r: resolve.Resolver = resolver) -> str | None:
- outcome = resolve.upgrade(
- resolver=_r, worktree=worktree, package=dep.name, version=_pending["version"],
- present=here, run=resolve.in_a_container,
- )
- return None if outcome.ok else f"{outcome.outcome.value}: {outcome.detail}"
-
- report = bump.verify(
- tests=tests, source=source, package=dep.name,
- was=dep.version, versions=versions,
- make_box=make_box, rebuild=lambda _text: build_now(),
- mover=mover, touches=guarded, pending=_pending,
- )
-
- for answer in report.answers:
- print(f" {answer.says}", file=out)
- if answer.detail:
- for line in answer.detail.splitlines()[:8]:
- print(f" {line}", file=out)
- print("", file=out)
- return report
-
-
def _cmd_features(args: argparse.Namespace, settings: Settings, out: TextIO) -> int:
"""What this can do for your project, and what it cannot. Item 186.
@@ -1448,7 +1305,7 @@ def refresh_manifest(
return manifest
-def prune(session: Session, older_than_days: int) -> int:
+def prune(session: Session, older_than_days: int, *, dry_run: bool = False) -> int:
"""Forget the raw bodies of deliveries older than N days. Returns how many were cleared.
The payload is kept so a delivery accepted before a restart can still be processed after one —
@@ -1471,6 +1328,16 @@ def prune(session: Session, older_than_days: int) -> int:
looking untried when it is spent.
"""
cutoff = datetime.now(UTC) - timedelta(days=older_than_days)
+ # **Counting is the same query as clearing, minus the write** (item 219). The page shows what it
+ # would drop before it drops it, and a preview computed by a second query is a preview that can
+ # disagree with the thing it previews — which on the only destructive control here would be
+ # worse than having no preview at all.
+ if dry_run:
+ return int(
+ session.query(Delivery)
+ .filter(Delivery.received_at < cutoff, Delivery.payload_json != "")
+ .count()
+ )
cleared = (
session.query(Delivery)
.filter(Delivery.received_at < cutoff, Delivery.payload_json != "")
@@ -1521,6 +1388,79 @@ def disable_project(session: Session, slug: str) -> Project:
return project
+def enable_project(session: Session, slug: str) -> Project:
+ """Watch it again. The counterpart `disable` did not have. Item 226.
+
+ **Reversible in principle and irreversible in practice is the worst of both.** `disable` deletes
+ nothing — that is its whole design — and until this existed the only way to undo it was an
+ `UPDATE` against a SQLite file inside a Docker volume. Found by doing it by accident to a real
+ instance, from a button sitting beside `refresh`.
+
+ Nothing is re-validated here: the manifest, the secret and every item are exactly where they
+ were, which is what made stopping safe in the first place.
+ """
+ project = _require(session, slug)
+ project.active = True
+ session.commit()
+ return project
+
+
+def ask_to_open(session: Session, slug: str, verdict_id: int) -> str:
+ """Record that a person wants this upgrade opened. Item 245, DR-0026.
+
+ **This is the button, and pressing it opens nothing.** The receiver renders the page and holds
+ no credential that can push — that refusal is the property the whole two-process design rests
+ on (DR-0009) — so the act of a person is written down here and the dispatcher carries it out on
+ its next turn. What comes back is the sentence the page shows, and it says the pull request does
+ not exist yet, because a control that implies otherwise is one somebody presses twice.
+
+ Every refusal below is a `ValueError` the page renders as-is: the caller is a form, and a form
+ posting an id nobody offered is the case this exists for.
+ """
+ from hullwork.models import UpgradeVerdict
+
+ project = _require(session, slug)
+ verdict = session.get(UpgradeVerdict, verdict_id)
+ if verdict is None or verdict.project_id != project.id:
+ msg = f"there is no verdict {verdict_id} for {slug!r}. Nothing was asked for."
+ raise ValueError(msg)
+ if verdict.opened_where:
+ msg = (
+ f"{verdict.package} {verdict.was} → {verdict.to} is already open at "
+ f"{verdict.opened_where}."
+ )
+ raise ValueError(msg)
+ if verdict.outcome != "clean":
+ # **Only a verdict that passed may be asked for**, and this is the guard rather than the
+ # template: a page that offers the control correctly today is not a reason for the write
+ # path to accept anything posted at it.
+ msg = (
+ f"{verdict.package} {verdict.was} → {verdict.to} is {verdict.outcome}, so there is "
+ f"nothing to open. Only an upgrade this project's own suite passed can be."
+ )
+ raise ValueError(msg)
+ if not verdict.artefact:
+ msg = (
+ f"the files {verdict.package} {verdict.to} passed with are not kept any more, so what "
+ f"would be opened is not what was verified. It is measured again on the next report."
+ )
+ raise ValueError(msg)
+ if verdict.asked_to_open_at is not None:
+ return (
+ f"{verdict.package} {verdict.was} → {verdict.to} was already asked for. The dispatcher "
+ f"opens it on its next turn; nothing is lost by waiting."
+ )
+ verdict.asked_to_open_at = datetime.now(UTC)
+ verdict.open_note = None
+ session.commit()
+ return (
+ f"Asked for {verdict.package} {verdict.was} → {verdict.to}. **No pull request exists "
+ f"yet**: this half of the instance cannot push, so the dispatcher opens it on its next "
+ f"turn — rooted at the commit the suite ran against, with the files it passed with. "
+ f"Reload to see where it went."
+ )
+
+
def _require(session: Session, slug: str) -> Project:
project = session.query(Project).filter(Project.slug == slug).one_or_none()
if project is None:
@@ -1662,7 +1602,23 @@ def _cmd_disable(
args: argparse.Namespace, session: Session, settings: Settings, out: TextIO
) -> int:
project = disable_project(session, args.slug)
- print(f"Disabled '{project.slug}'. Its events and items are kept.", file=out)
+ print(
+ f"Disabled '{project.slug}'. Its events and items are kept, and "
+ f"`hullwork projects enable {project.slug}` watches it again.",
+ file=out,
+ )
+ return 0
+
+
+def _cmd_enable(
+ args: argparse.Namespace, session: Session, settings: Settings, out: TextIO
+) -> int:
+ project = enable_project(session, args.slug)
+ print(
+ f"Watching '{project.slug}' again. Nothing was re-validated: its manifest, its secret and "
+ f"every item are where they were.",
+ file=out,
+ )
return 0
@@ -3178,6 +3134,87 @@ def _cmd_work(args: argparse.Namespace, session: Session, settings: Settings, ou
LOOP_CEILING_SECONDS = 300
+def _verify_one_upgrade(
+ session: Session, settings: Settings, *, say: Callable[[str | None], None] = lambda _: None
+) -> str | None:
+ """One published fix, applied and measured, writing nothing anywhere. DR-0026.
+
+ **The read credential.** A verification writes to no repository, so it clones with the token
+ that cannot push and the property holds by construction — the same reasoning that keeps `work`
+ from ever holding one it does not need.
+ """
+ from hullwork import upgrades, work
+
+ def clone(where: Settings, project: Project, into: Path) -> Path:
+ token = where.forge_token.get_secret_value() if where.forge_token else None
+ if token is None:
+ msg = "HULLWORK_FORGE_TOKEN is not set, so no repository can be read"
+ raise work.WiringError(msg)
+ return work.checkout(work.clone_url(where, project), token, into=into).path
+
+ try:
+ return upgrades.verify_next(session, settings, clone=clone, say=say)
+ except (work.WiringError, SandboxError, ImageBuildError) as exc:
+ log.warning("an upgrade could not be verified", extra={"error": str(exc)})
+ return None
+
+
+def _open_one_requested(session: Session, settings: Settings) -> str | None:
+ """One upgrade somebody asked for from the page, opened. Item 245.
+
+ **The code credential, and it is built here rather than passed in** for the same reason
+ `_verify_one_upgrade` builds the read one: the credential belongs to the process, and `upgrades`
+ stays testable against a double. `make_code_forge` answers `None` until the code token is set,
+ which is the ordinary state of the receiver and a misconfiguration in the dispatcher —
+ `open_requested` tells those apart and leaves the request pending either way.
+
+ **Closed even when nothing was opened.** A forge client left open per turn is a socket per
+ minute, and this runs in a resident process.
+ """
+ from hullwork import upgrades
+
+ code_forge = make_code_forge(settings)
+ try:
+ return upgrades.open_requested(
+ session, code_forge, secrets=_redactions(settings)
+ )
+ except ForgeError as exc:
+ # The verdict and its artefact are untouched, so the request stays pending and the next turn
+ # tries again: a forge that is down for a minute must not spend somebody's button press.
+ log.warning("an upgrade could not be opened", extra={"error": str(exc)})
+ return None
+ finally:
+ close = getattr(code_forge, "close", None) if code_forge is not None else None
+ if close is not None:
+ close()
+
+
+def _watch_one_opened(session: Session, settings: Settings) -> str | None:
+ """What became of one pull request this instance opened for an upgrade. Item 253.
+
+ **The read credential, because this is a read.** Asking a forge about a pull request writes to
+ no repository, so it uses the token that cannot push — the same reasoning `_verify_one_upgrade`
+ gives, and it means this half of the watch would work in a process that may not push at all.
+
+ Closed per turn for the reason `_open_one_requested` is: a client left open per turn is a socket
+ a minute in a resident process.
+ """
+ from hullwork import upgrades
+
+ forge = make_forge(settings)
+ try:
+ return upgrades.watch_opened(session, forge)
+ except ForgeError as exc:
+ # Nothing is written, so the row keeps saying what it last knew and the next turn asks
+ # again: a verdict recorded for one bad afternoon is worse than a stale one.
+ log.warning("an opened upgrade could not be asked about", extra={"error": str(exc)})
+ return None
+ finally:
+ close = getattr(forge, "close", None) if forge is not None else None
+ if close is not None:
+ close()
+
+
def _work_loop(
args: argparse.Namespace, session: Session, settings: Settings, out: TextIO
) -> int:
@@ -3309,6 +3346,10 @@ def _stop(signum: int, _frame: object) -> None:
file=out,
)
+ def say(what: str | None) -> None:
+ """What this dispatcher is doing, for the page. Item 242."""
+ lease.doing(session, holder, what)
+
print(f"Dispatching continuously as {holder}. Nothing listens on any port.", file=out)
wait = LOOP_FLOOR_SECONDS
try:
@@ -3350,6 +3391,7 @@ def _stop(signum: int, _frame: object) -> None:
print("The model credential works again. Claiming resumes.", file=out)
try:
+ lease.doing(session, holder, "looking for something to do")
outcomes = work.run(
session, settings, limit=args.limit, slug=args.project, rehearse_into=None
)
@@ -3366,6 +3408,33 @@ def _stop(signum: int, _frame: object) -> None:
for outcome in outcomes:
where = f" → {outcome.pull_request}" if outcome.pull_request else ""
print(f"item {outcome.item_id}: {outcome.outcome.value}{where}", file=out)
+ # **Only when no bug was waiting** (DR-0026, item 233). A production error outranks a
+ # dependency upgrade, and one verification is a clone, an image build and a suite run —
+ # so it happens in the gap, one at a time, and never instead of the work.
+ if not outcomes:
+ # **What a person asked for goes before what the clock asked for** (item 245). This
+ # is one forge round trip against an artefact already on disk; a verification is a
+ # clone, an image build and two suite runs. Doing them the other way round would
+ # mean a button whose answer arrives five minutes later because the instance chose
+ # to start something nobody was waiting for.
+ opened = _open_one_requested(session, settings)
+ if opened:
+ print(opened, file=out)
+ else:
+ # **One round trip, and before the five-minute one** (item 253). Asking what
+ # became of a pull request already opened is cheaper than verifying a new
+ # upgrade, and until this existed the page said *a draft pull request is
+ # waiting for a person* about two that had been merged for a day.
+ became = _watch_one_opened(session, settings)
+ if became:
+ print(became, file=out)
+ tried = _verify_one_upgrade(session, settings, say=say)
+ if tried:
+ print(tried, file=out)
+
+ # **Idle is a thing to say, not a thing to leave stale** (item 242). A dispatcher that
+ # stopped mid-sentence would leave the page claiming it is still building an image.
+ lease.doing(session, holder, None)
# Work found → look again at once, because a queue drains fastest when nothing sleeps on
# it. Nothing found → back off, so an idle instance is idle.
@@ -3641,6 +3710,10 @@ def build_parser() -> argparse.ArgumentParser:
disable.add_argument("slug")
disable.set_defaults(func=_cmd_disable)
+ enable = actions.add_parser("enable", help="watch a project again after `disable`")
+ enable.add_argument("slug")
+ enable.set_defaults(func=_cmd_enable)
+
rotate = actions.add_parser("rotate-secret", help="issue a new webhook token")
rotate.add_argument("slug")
rotate.add_argument(
diff --git a/hullwork/dependencies.py b/hullwork/dependencies.py
index b3e25f2..fbb417f 100644
--- a/hullwork/dependencies.py
+++ b/hullwork/dependencies.py
@@ -56,6 +56,33 @@
_A_REQUIREMENT = re.compile(r"^\s*[A-Za-z0-9._]")
+#: The leading numeric core of a version — `5.0.6` out of `5.0.6-rc1+build.7`. Both ecosystems this
+#: product reads spell that part the same way, which is why one function serves npm and PyPI.
+_CORE = re.compile(r"^\s*v?(\d+(?:\.\d+)*)")
+
+
+def newer(candidate: str, than: str) -> bool | None:
+ """Whether `candidate` is a later version than `than`, or `None` when it cannot be told.
+
+ **`None` is the point of this signature.** A version neither side can parse is not a version to
+ drop silently: OSV carries `1.2.3.RELEASE`, `2024-11-01` and `0.9.0.beta` among the ordinary
+ ones, and a comparison that guessed would either try nonsense or hide a real fix. Unknown means
+ *try it*, and the caller says so.
+
+ Numbers as numbers, because `5.0.10` sorts before `5.0.9` as a string, and a rule built on that
+ would skip the one upgrade that mattered.
+ """
+ here, there = _CORE.match(candidate), _CORE.match(than)
+ if here is None or there is None:
+ return None
+ left = [int(part) for part in here.group(1).split(".")]
+ right = [int(part) for part in there.group(1).split(".")]
+ width = max(len(left), len(right))
+ left += [0] * (width - len(left))
+ right += [0] * (width - len(right))
+ return left > right
+
+
@dataclass(frozen=True)
class Dependency:
"""One pinned package, and which file said so.
diff --git a/hullwork/features.py b/hullwork/features.py
index e9030c4..82e52a1 100644
--- a/hullwork/features.py
+++ b/hullwork/features.py
@@ -53,6 +53,19 @@ class Need:
#: What to do about it, in the words a person would type. Never "configure it correctly".
fix: str
met: Callable[[Checkout], bool]
+ #: **Which of the three things answers this**, named rather than inferred. Item 220: a page
+ #: serving one instance can supply the manifest and its own variable names, and cannot supply a
+ #: checkout — item 142 forbids a forge request per render. So *unmet* means three different
+ #: things there, and only one of them is a defect:
+ #:
+ #: * `manifest` — the instance holds it (DR-0012), so unmet is a fact about the project;
+ #: * `checkout` — nothing on the instance can answer it, so unmet reads *not asked yet*;
+ #: * `instance` — a credential, which on the receiver may be the dispatcher's by design
+ #: (DR-0005) and is downgraded exactly as `doctor.not_from_here` downgrades it.
+ #:
+ #: `hullwork features` ignores it: run against a real checkout all three are answerable, which
+ #: is why the distinction never had to exist until a page needed it.
+ reads: str = "manifest"
@dataclass(frozen=True)
@@ -159,6 +172,7 @@ def _pins_anything(checkout: Checkout) -> bool:
fix="commit one, or pin with `==` — a declaration is a range and a range is not a "
"fact about what your build resolved to",
met=_pins_anything,
+ reads="checkout",
),
),
limits=(
@@ -176,6 +190,7 @@ def _pins_anything(checkout: Checkout) -> bool:
what="a lock file or a pinned requirements file, committed",
fix="commit one, or pin with `==`",
met=_pins_anything,
+ reads="checkout",
),
Need(
what="a hullwork.yml naming an image (`runtime.base`) and your test command",
@@ -218,6 +233,7 @@ def _pins_anything(checkout: Checkout) -> bool:
what="a model credential on the instance that runs it",
fix=f"set {MODEL_KEY} to an API key from any provider (DR-0004)",
met=lambda c: MODEL_KEY in c.configured,
+ reads="instance",
),
Need(
what="`autofix.agent` naming an engine this instance holds",
@@ -247,12 +263,14 @@ def _pins_anything(checkout: Checkout) -> bool:
what="a credential able to write to your repository",
fix=f"set {CODE_TOKEN}. It is the one thing here that writes anything anywhere",
met=lambda c: CODE_TOKEN in c.configured,
+ reads="instance",
),
Need(
what="an `origin` remote, so the repository can be named",
fix="add one — a coordinate cannot be guessed from a directory name, and a wrong "
"guess opens a pull request somewhere else",
met=lambda c: "origin" in c.configured,
+ reads="checkout",
),
),
permits=(
@@ -292,6 +310,7 @@ def _pins_anything(checkout: Checkout) -> bool:
what="a model credential on the instance that runs it",
fix=f"set {MODEL_KEY} to an API key from any provider (DR-0004)",
met=lambda c: MODEL_KEY in c.configured,
+ reads="instance",
),
),
limits=(
diff --git a/hullwork/lease.py b/hullwork/lease.py
index 32b5375..6bfbfda 100644
--- a/hullwork/lease.py
+++ b/hullwork/lease.py
@@ -149,6 +149,27 @@ def renew(session: Session, holder: str) -> bool:
return True
+def doing(session: Session, holder: str, what: str | None) -> None:
+ """Record what this dispatcher is doing, or `None` for idle. Item 242. Never raises.
+
+ **Best effort, and deliberately so.** This is a page's trace, not the work: a database that
+ refuses this write must not take down a verification that is already running. It is also why
+ the timestamp moves only when the sentence changes — a step that has been going for nine
+ minutes should read as nine minutes, not reset every turn of the loop.
+ """
+ try:
+ lease = session.get(DispatcherLease, 1)
+ if lease is None or lease.holder != holder:
+ return
+ if lease.doing != what:
+ lease.doing = what
+ lease.doing_since = _now() if what else None
+ session.commit()
+ except Exception: # a trace for a page is never worth a rollback of the work
+ log.warning("could not record what this dispatcher is doing", extra={"holder": holder})
+ session.rollback()
+
+
def release(session: Session, holder: str) -> None:
"""Give the lease up on the way out, so the next start does not wait for it to expire.
@@ -158,6 +179,9 @@ def release(session: Session, holder: str) -> None:
lease = session.get(DispatcherLease, 1)
if lease is not None and lease.holder == holder:
lease.renewed_at = RELEASED
+ # A stopped dispatcher is not still doing the last thing it was doing (item 242).
+ lease.doing = None
+ lease.doing_since = None
session.commit()
log.info("dispatcher lease released", extra={"holder": holder})
diff --git a/hullwork/main.py b/hullwork/main.py
index 2880d9a..17dd83d 100644
--- a/hullwork/main.py
+++ b/hullwork/main.py
@@ -10,6 +10,7 @@
from fastapi import Depends, FastAPI, HTTPException, Query, Request, Response, status
from fastapi.responses import HTMLResponse, RedirectResponse
from pydantic import BaseModel
+from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from hullwork import __version__, operator, page, readiness
@@ -17,7 +18,7 @@
from hullwork.config import ConfigError, Settings, get_settings
from hullwork.db import get_engine, make_session_factory
from hullwork.forge.factory import make_forge
-from hullwork.ingest import sweep
+from hullwork.ingest import sweep, sweep_inventory
from hullwork.logging import configure_logging
from hullwork.models import Item
from hullwork.readiness import record_sweep_ok
@@ -47,6 +48,144 @@ def _readiness_session(
session.close()
+def _measure_what_the_ingest_credential_may_do(
+ factory: sessionmaker[Session], settings: Settings
+) -> None:
+ """Ask the forge whether the ingest credential can push, on this instance's own clock. Item 228.
+
+ **The most important thing a project's page can say was waiting for a person.** DR-0009 forbids
+ the receiver holding a credential that can push, `credentials.audit` measures it, and its
+ docstring says *only ever run on request* — so an instance running for weeks answered *not asked
+ yet*. Worse: the key the page read was written by nothing at all, so asking would not have
+ helped either.
+
+ **A signal that depends on somebody remembering is not a signal.** That is item 073's rule
+ arriving from the other side: it deleted a check that was permanently on; this one was
+ permanently unknown.
+
+ Throttled by `forge_recheck_seconds`, the number this instance already uses for *how often may I
+ ask the forge about a thing again*. The cost is two calls per active project per interval — 12
+ an hour for two projects at the default — and it is spent by the clock, never by a page render.
+ """
+ from datetime import UTC, datetime, timedelta
+
+ from hullwork import credentials
+ from hullwork.cli import _scope_probe
+ from hullwork.forge.factory import make_permission_reader
+ from hullwork.models import Project as ProjectRow
+
+ if not settings.forge_url or not settings.forge_token:
+ return
+ due = datetime.now(UTC) - timedelta(seconds=max(settings.forge_recheck_seconds, 60))
+ with factory() as session:
+ waiting = [
+ one
+ for one in session.scalars(
+ select(ProjectRow).where(ProjectRow.active.is_(True))
+ ).all()
+ if one.ingest_checked_at is None or one.ingest_checked_at < due
+ ]
+ if not waiting:
+ return
+ try:
+ found = credentials.audit(
+ session, make_permission_reader(settings), probe=_scope_probe(settings)
+ )
+ except Exception: # a forge having a bad minute is not this loop's problem
+ log.debug("could not measure the ingest credential", exc_info=True)
+ return
+ answered = {one.slug: one for one in found}
+ now = datetime.now(UTC)
+ for project in waiting:
+ verdict = answered.get(project.slug)
+ if verdict is None:
+ continue
+ # **Both fields together, or neither.** A verdict with no timestamp is the
+ # permanently-on signal again, and a timestamp with no verdict is worse: it says the
+ # question was answered when it was not.
+ # **The conclusion, not one field.** `credentials.audit` only probes the token where
+ # the account can push, because a project whose account cannot has nothing for the
+ # probe to disprove — so `token_can_push` is `None` there and it is not *unknown*, it
+ # is *not in question*. `None` survives only when the forge would not say at all.
+ project.ingest_token_can_push = (
+ verdict.token_can_push
+ if verdict.token_can_push is not None
+ else (False if verdict.can_push is False else None)
+ )
+ project.ingest_checked_at = now
+ session.commit()
+
+
+#: How often a project's dependencies are asked about. **Not `forge_recheck_seconds`**: advisories
+#: are published on a human's schedule, and asking OSV every ten minutes would be spending somebody
+#: else's public API to learn nothing. Six hours is four answers a day, which is more than the
+#: publication rate of the thing being asked about.
+ADVISORIES_EVERY_SECONDS = 6 * 60 * 60
+
+
+def _ask_what_is_published_against_what_they_pin(
+ factory: sessionmaker[Session], settings: Settings
+) -> None:
+ """Read what each project pins and ask OSV what is published against it. DR-0024, item 230.
+
+ **The receiver may do this and the dispatcher must do the rest.** Reading a lock file is the
+ same forge call `projects refresh` already makes, and OSV takes no credential — so this needs
+ nothing DR-0005 withholds. Applying an upgrade and running a suite needs the Docker socket, and
+ that half stays where it is.
+
+ **Stored with when it was asked, and with whether it was asked at all.** Those are the two
+ conditions the operator put on accepting DR-0024, and they are the same sentence: a report
+ rendered without its timestamp is a claim about a moment presented as a standing fact, and an
+ empty advisory list from a failed request says *you are fine* on no evidence.
+ """
+ from datetime import UTC, datetime, timedelta
+
+ from hullwork import advisories, upgrades
+ from hullwork.models import DependencyReport
+ from hullwork.models import Project as ProjectRow
+
+ if not settings.forge_url or not settings.forge_token:
+ return
+ due = datetime.now(UTC) - timedelta(seconds=ADVISORIES_EVERY_SECONDS)
+ with factory() as session:
+ waiting = [
+ one
+ for one in session.scalars(
+ select(ProjectRow).where(ProjectRow.active.is_(True))
+ ).all()
+ if (report := session.get(DependencyReport, one.id)) is None
+ or report.taken_at < due
+ ]
+ if not waiting:
+ return
+ forge = make_forge(settings)
+ if forge is None:
+ return
+ ask = advisories.asking()
+ try:
+ for project in waiting:
+ found = advisories.about(project.repo, forge, ask)
+ session.merge(
+ DependencyReport(
+ project_id=project.id,
+ taken_at=datetime.now(UTC),
+ asked=found.asked,
+ note=found.note,
+ pinned=found.pinned,
+ findings=found.findings,
+ )
+ )
+ if found.asked:
+ # **A new report is the only event that can make a kept artefact stale** (item
+ # 245), so this is the only place the forgetting can go. Guarded on `asked`
+ # because a request that never reached OSV answers nothing: dropping artefacts
+ # on an empty finding list would forget everything whenever the network blinked.
+ upgrades.forget_stale(session, project.id, found.findings)
+ finally:
+ forge.close()
+ session.commit()
+
+
def _sweep_once(factory: sessionmaker[Session], settings: Settings) -> None:
"""One pass over everything outstanding. Sync, so it runs in a worker thread."""
with factory() as session:
@@ -59,6 +198,8 @@ def _sweep_once(factory: sessionmaker[Session], settings: Settings) -> None:
)
if not result.skipped:
record_sweep_ok()
+ _measure_what_the_ingest_credential_may_do(factory, settings)
+ _ask_what_is_published_against_what_they_pin(factory, settings)
if result.deliveries or result.filed or result.resolved or result.fetched or result.swept:
log.info(
"sweep finished outstanding work",
@@ -318,14 +459,12 @@ def page_instance_index(
if shut is not None:
return shut
acting = _may_read(session, request, token)
+ # **The door answers a question and lists the projects** (item 237). It was every item on the
+ # instance in one table, which with two projects is two projects' bugs interleaved and *whose*
+ # as the column to scan for. The list of every item is still `/items`.
return HTMLResponse(
- page.items(
- session,
- acting=acting,
- here="./",
- settings=settings,
- front=True,
- error_reporting=_reporting_enabled,
+ page.front_door(
+ session, settings, acting=acting, error_reporting=_reporting_enabled
),
headers=page.HEADERS,
)
@@ -375,6 +514,130 @@ def page_items(
)
+#: The five views a project has, by the last segment of the URL each is served from. **A `POST`
+#: answers with the document for the URL it posted to** (item 250), so the handler that acts needs
+#: the same map the handlers that read use.
+_THE_VIEWS: dict[str, Any] = {
+ "errors": page.errors,
+ "fixes": page.fixes,
+ "dependencies": page.dependencies,
+ "deliveries": page.deliveries,
+ "settings": page.settings_for,
+}
+
+
+def _a_project_view(
+ view: object, session: Session, settings: Settings, slug: str, acting: page.Acting,
+ **answered: str | tuple[str, str | None] | None,
+) -> HTMLResponse:
+ """Render one of a project's feature pages, or `404` for a slug that is not one.
+
+ The `404` is the same one an unknown path gets, on purpose: a distinct body would let somebody
+ with a valid read token enumerate the slugs this instance serves.
+
+ `answered` is what the last press said — a sentence, a refusal, a rotated secret — and it is
+ passed through rather than composed here, for the reason `_outcome` gives.
+ """
+ shown = view(session, settings, slug, acting=acting, **answered) # type: ignore[operator]
+ if shown is None:
+ raise HTTPException(status_code=404)
+ return HTMLResponse(shown, headers=page.HEADERS)
+
+
+@app.get(
+ f"{page.PREFIX}/{{token}}/projects/{{slug}}/errors",
+ tags=["page"],
+ response_class=HTMLResponse,
+ include_in_schema=False,
+)
+def page_project_errors(
+ token: str,
+ slug: str,
+ request: Request,
+ session: Annotated[Session, Depends(_readiness_session)],
+ settings: Annotated[Settings, Depends(get_settings)],
+) -> HTMLResponse:
+ """This project's bugs, newest first. Item 237."""
+ _may_read(session, request, token)
+ return _a_project_view(page.errors, session, settings, slug, _acting(session, request))
+
+
+@app.get(
+ f"{page.PREFIX}/{{token}}/projects/{{slug}}/fixes",
+ tags=["page"],
+ response_class=HTMLResponse,
+ include_in_schema=False,
+)
+def page_project_fixes(
+ token: str,
+ slug: str,
+ request: Request,
+ session: Annotated[Session, Depends(_readiness_session)],
+ settings: Annotated[Settings, Depends(get_settings)],
+) -> HTMLResponse:
+ """What this instance attempted on this project, and what it cost. Item 237."""
+ _may_read(session, request, token)
+ return _a_project_view(page.fixes, session, settings, slug, _acting(session, request))
+
+
+@app.get(
+ f"{page.PREFIX}/{{token}}/projects/{{slug}}/dependencies",
+ tags=["page"],
+ response_class=HTMLResponse,
+ include_in_schema=False,
+)
+def page_project_dependencies(
+ token: str,
+ slug: str,
+ request: Request,
+ session: Annotated[Session, Depends(_readiness_session)],
+ settings: Annotated[Settings, Depends(get_settings)],
+) -> HTMLResponse:
+ """What is published against what this project pins. Item 237, DR-0024.
+
+ **Inside the project** (item 237), because a page holding every project's advisories one after
+ another is a wall at two projects and unusable at ten: nobody works by feature across clients.
+ """
+ _may_read(session, request, token)
+ return _a_project_view(page.dependencies, session, settings, slug, _acting(session, request))
+
+
+@app.get(
+ f"{page.PREFIX}/{{token}}/projects/{{slug}}/deliveries",
+ tags=["page"],
+ response_class=HTMLResponse,
+ include_in_schema=False,
+)
+def page_project_deliveries(
+ token: str,
+ slug: str,
+ request: Request,
+ session: Annotated[Session, Depends(_readiness_session)],
+ settings: Annotated[Settings, Depends(get_settings)],
+) -> HTMLResponse:
+ """What this project's tracker sent, and whether it was understood. Item 237."""
+ _may_read(session, request, token)
+ return _a_project_view(page.deliveries, session, settings, slug, _acting(session, request))
+
+
+@app.get(
+ f"{page.PREFIX}/{{token}}/projects/{{slug}}/settings",
+ tags=["page"],
+ response_class=HTMLResponse,
+ include_in_schema=False,
+)
+def page_project_settings(
+ token: str,
+ slug: str,
+ request: Request,
+ session: Annotated[Session, Depends(_readiness_session)],
+ settings: Annotated[Settings, Depends(get_settings)],
+) -> HTMLResponse:
+ """Everything this instance will do to this project on command. Item 237."""
+ _may_read(session, request, token)
+ return _a_project_view(page.settings_for, session, settings, slug, _acting(session, request))
+
+
@app.get(
f"{page.PREFIX}/{{token}}/projects",
tags=["page"],
@@ -413,7 +676,7 @@ def page_project(
about a consultancy's customers as much as about this deployment.
"""
_may_read(session, request, token)
- rendered = page.project(session, settings, slug)
+ rendered = page.project(session, settings, slug, acting=_acting(session, request))
if rendered is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Not Found")
return HTMLResponse(rendered, headers=page.HEADERS)
@@ -489,10 +752,45 @@ def _may_read(session: Session, request: Request, token: str) -> page.Acting:
"""
acting = _acting(session, request)
if not page.opens(session, token, acting=acting):
+ # **A `404` on the operator's own path locks them out of every URL but one** (item 224).
+ # The reason the refusals here are indistinguishable is DR-0021's: a distinct answer would
+ # tell somebody holding a *read link* which doors exist. `me` is not a read link — it is a
+ # literal anybody can type, and the front door already answers it with a login. So on that
+ # path, and only there, the answer is the login rather than a wall.
+ if page.offers_a_login(token, acting):
+ raise _NeedsTheLoginError(request.url.path)
raise HTTPException(status.HTTP_404_NOT_FOUND, "Not Found")
return acting
+class _NeedsTheLoginError(Exception):
+ """Not a failure: a request that may sign in and has not. Item 224.
+
+ An exception rather than a return, because `_may_read` is called by nine routes as a gate and
+ turning it into something every one of them has to inspect is the *one place, not nine* problem
+ its own docstring is about.
+ """
+
+ def __init__(self, going_to: str) -> None:
+ super().__init__(going_to)
+ self.going_to = going_to
+
+
+@app.exception_handler(_NeedsTheLoginError)
+def _answer_with_the_login(request: Request, exc: Exception) -> HTMLResponse:
+ """The login, for a request that may sign in and has not. Item 224.
+
+ **Where they were going travels with it**, so signing in lands on the view they opened rather
+ than on the front door — `page.where_it_may_land` decides what that may be, from a list.
+ """
+ return HTMLResponse(
+ page.just_the_login(
+ page.Acting(csrf=None, offered=True), going_to=getattr(exc, "going_to", "")
+ ),
+ headers=page.HEADERS,
+ )
+
+
def _the_login_if_offered(
session: Session, request: Request, token: str
) -> HTMLResponse | None:
@@ -633,7 +931,7 @@ async def page_login(
supplied = await _field(request, "password")
issued = operator.sign_in(session, supplied) if supplied else None
- redirect = _to_page(token)
+ redirect = _to_page(token, page.where_it_may_land(await _field(request, "going_to")))
if issued is not None:
cookie, _csrf = issued
redirect.set_cookie(
@@ -701,11 +999,12 @@ async def page_connect_project(
@app.post(
- f"{page.PREFIX}/{{token}}/projects/{{slug}}", tags=["page"], include_in_schema=False
+ f"{page.PREFIX}/{{token}}/projects/{{slug}}/{{feature}}", tags=["page"], include_in_schema=False
)
async def page_project_action(
token: str,
slug: str,
+ feature: str,
request: Request,
session: Annotated[Session, Depends(_readiness_session)],
settings: Annotated[Settings, Depends(get_settings)],
@@ -718,9 +1017,22 @@ async def page_project_action(
**No default branch.** An action nobody recognises does nothing and says so — a form field that
fell through to whichever branch was last is how a typo becomes a disabled project.
+
+ **`feature` names the document that comes back, and nothing else** (item 250). A `POST` answers
+ at the URL its form posted to, and every relative link in that answer resolves against that URL
+ — so the document has to be the one that URL serves. This route used to be `projects/ `
+ # collapses newlines, and forty-one lines of YAML arrived as one run-on blob.
+ swept = page.as_a_block(_propose_one(session, settings, slug))
+ elif what == "lanes":
+ swept = _lanes_of(session, settings, slug)
else:
raise ValueError(
f"{what!r} is not something this page does. Nothing was changed."
)
except Exception as exc: # every refusal already carries its own sentence
session.rollback()
- shown = page.projects(
- session, settings, acting=_acting(session, request), refused=str(exc)
+ # **The view they were on, carrying the reason** (item 250). This answered with the list of
+ # projects, rendered at this project's URL — so a forge that had just gone down was
+ # reported on a page whose every link 404'd. The refusal is the common path here, not the
+ # exotic one.
+ return _a_project_view(
+ _THE_VIEWS[feature], session, settings, slug, _acting(session, request),
+ refused=str(exc),
)
- return HTMLResponse(shown, headers=page.HEADERS)
- shown = page.projects(
- session, settings, acting=_acting(session, request), rotated=(slug, rotated)
+ # **The document for the URL this posted to**, which is the view the button is on. Answering
+ # with any other one leaves the URL saying one view and the body showing another, and — because
+ # every link here is relative on purpose — resolves all of them from the wrong depth.
+ return _a_project_view(
+ _THE_VIEWS[feature], session, settings, slug, _acting(session, request),
+ said=swept,
+ # **The whole token, not `rotated[1]`** (item 250). This passed `(slug, rotated[1])` into a
+ # parameter typed `tuple[str, str | None]`, and `rotated` is the token itself — so the one
+ # answer in this product that can never be repeated printed **a single character of it**
+ # and type-checked. Only the hash is kept, so the secret was gone: the button stopped the
+ # tracker's working URL and gave nothing back to replace it with.
+ **({"rotated": (slug, rotated)} if rotated is not None else {}),
)
- return HTMLResponse(shown, headers=page.HEADERS)
+def _propose_one(session: Session, settings: Settings, slug: str) -> str:
+ """A manifest read from the repository's own CI configuration. Item 107, on the page (item 222).
+
+ **It prints and does not write**, here as in the terminal: a manifest belongs in the project's
+ repository, committed by somebody who read it, and DR-0006's rule that what was inferred stays
+ commented only means anything if a person is the one who uncomments it.
+
+ One forge read, when a person asks for it. Item 142 forbids a request per *render* — a reader
+ refreshing would spend one each time — and says nothing about an action somebody pressed, which
+ is the same shape `projects refresh` has had since item 206.
+ """
+ from hullwork.cli import _forge_for, propose_from_ci
+ from hullwork.models import Project as ProjectRow
+
+ project = session.scalars(select(ProjectRow).where(ProjectRow.slug == slug)).one_or_none()
+ if project is None:
+ raise ValueError(f"no project called {slug!r}.")
+ forge = _forge_for(settings, project.forge)
+ try:
+ proposed = propose_from_ci(forge, project.repo)
+ finally:
+ forge.close()
+ if proposed is None:
+ raise ValueError(
+ f"nothing in {project.repo} proposes a manifest: no CI configuration was found, or "
+ f"the one there says nothing this reader recognises. That is not a refusal to connect "
+ f"the project — it means the manifest has to be written by hand, and the field that "
+ f"decides whether anything can be built is `runtime.base`: an image your tests already "
+ f"run in."
+ )
+ return proposed
+
+
+def _lanes_of(session: Session, settings: Settings, slug: str) -> str:
+ """The lane policy, applied to this repository's own directories. M8, item 104, on the page.
+
+ **An operator who cannot see the policy applied to their code is being asked to trust a
+ paragraph**, and this product's first principle is that trust is the product.
+
+ Read-only and stores nothing, deliberately: a derived policy kept on disk would be a snapshot of
+ *which code is dangerous*, and `territory.py` explains why that fails in the direction that
+ matters. So this is an action and never a cache.
+ """
+ from hullwork import territory
+ from hullwork.cli import _forge_for
+ from hullwork.models import Project as ProjectRow
+
+ project = session.scalars(select(ProjectRow).where(ProjectRow.slug == slug)).one_or_none()
+ if project is None:
+ raise ValueError(f"no project called {slug!r}.")
+ forge = _forge_for(settings, project.forge)
+ try:
+ listing = forge.tree(project.repo)
+ except Exception as exc:
+ raise ValueError(f"could not read the tree of {project.repo}: {exc}") from exc
+ finally:
+ forge.close()
+
+ claimed = territory.sensitive_tree(list(listing.paths))
+ said = [
+ f"{project.repo} at {listing.ref[:12]} — {len(listing.paths)} file(s), "
+ f"{len(claimed)} that this instance keeps a human on."
+ ]
+ if listing.truncated:
+ said.append(
+ "The forge did not serve the whole tree, so this list is incomplete — what is missing "
+ "is unclassified here, not classified as ordinary."
+ )
+ by_rule: dict[str, list[str]] = {}
+ for path, rule in claimed:
+ by_rule.setdefault(rule.pattern, []).append(path)
+ for pattern, paths in by_rule.items():
+ said.append(f"`{pattern}` — {len(paths)} file(s): {', '.join(sorted(paths)[:4])}")
+ return " · ".join(said)
+
+
+def _sweep_one(session: Session, settings: Settings, slug: str, *, confirm: bool) -> str:
+ """Read the tracker's unresolved list for one project. DR-0011, item 219.
+
+ **The count comes before the writing and the number you confirm is the number you were shown.**
+ A project with three hundred open issues becomes three hundred forge issues in one pass, and a
+ tool that does that on a first afternoon is uninstalled that evening — which is the whole reason
+ `sweep` has a `--confirm` in the terminal. On a page that is two submissions, and computing the
+ preview from a different query than the write would let them disagree.
+ """
+ inventory = make_inventory(settings)
+ if inventory is None:
+ raise ValueError(
+ "no tracker inventory is configured. It needs HULLWORK_TRACKER_URL, "
+ "HULLWORK_TRACKER_TOKEN and HULLWORK_TRACKER_ORG — the organisation cannot be "
+ "discovered, because the least-privilege token is refused the route that would list it."
+ )
+ results = sweep_inventory(
+ session, inventory, slug=slug, first_pass=True, dry_run=not confirm
+ )
+ if not results:
+ return f"'{slug}' has no tracker project set, so there is nothing to sweep."
+
+ said = []
+ for result in results:
+ if result.error:
+ said.append(f"{result.project}: could not read the tracker — {result.error}")
+ elif confirm:
+ said.append(
+ f"{result.project}: filed {result.created} issue(s); "
+ f"{result.deduplicated} were already known."
+ )
+ else:
+ said.append(
+ f"{result.project}: {result.created} issue(s) would be filed and "
+ f"{result.deduplicated} are already known. Nothing was written."
+ )
+ return " · ".join(said)
+
+
+@app.post(
+ f"{page.PREFIX}/{{token}}/instance",
+ tags=["page"],
+ response_class=HTMLResponse,
+ include_in_schema=False,
+)
+async def page_instance_action(
+ token: str,
+ request: Request,
+ session: Annotated[Session, Depends(_readiness_session)],
+ settings: Annotated[Settings, Depends(get_settings)],
+) -> HTMLResponse:
+ """The instance's own housekeeping, from its own view. Item 219, item 218 §1.
+
+ **One route with an action rather than three names**, which is item 207's rule applied to the
+ second noun that needed one. Each action calls what the terminal calls; none is implemented
+ here.
+
+ **`prune` is the only destructive control on this page**, so it has two submissions and the
+ first one writes nothing: `prune-preview` says how many bodies it would clear, and the number a
+ person confirms is the number they were just shown.
+ """
+ from hullwork import cli
+
+ acting = _the_operators(session, request, token)
+ if not operator.csrf_ok(
+ operator.acting(session, request.cookies.get(operator.COOKIE)),
+ await _field(request, "csrf"),
+ ):
+ raise HTTPException(status.HTTP_403_FORBIDDEN, "Forbidden")
+
+ what = await _field(request, "action")
+ said: str | None = None
+ try:
+ if what == "lease-release":
+ said = cli.release_lease(session)
+ elif what == "prune-preview":
+ days = _days(await _field(request, "older_than_days"))
+ said = (
+ f"{cli.prune(session, days, dry_run=True)} delivery body(s) and fetched event(s) "
+ f"older than {days} days would be cleared. Every row, fingerprint and issue "
+ f"reference stays. Nothing has been cleared yet."
+ )
+ session.rollback()
+ elif what == "prune":
+ days = _days(await _field(request, "older_than_days"))
+ said = (
+ f"Cleared {cli.prune(session, days)} delivery body(s) older than {days} days. "
+ f"Every row, fingerprint and issue reference is intact."
+ )
+ elif what == "republish":
+ said = _republish_all(session, settings)
+ elif what == "page-token":
+ # **The read link, re-keyed from the page** (DR-0025, item 229). Strictly less than what
+ # this session already does: it revokes a URL rather than granting anything. The
+ # password is the other half of that decision and is deliberately not here.
+ from hullwork.security import generate_token, hash_token
+
+ minted = generate_token()
+ page.issue(session, hash_token(minted))
+ # **Including the page this answer is on, when it is one of them** (item 250). Read at
+ # a minted URL, this button revokes the URL the answer is served from: every link on
+ # the page that comes back is correctly written and every one of them answers `404`.
+ # Read through a session it is not the token that opens the door, so nothing here
+ # breaks — and saying so unconditionally would be false half the time.
+ here_too = (
+ " That includes the URL you are reading this on, so every link on this page "
+ "answers 404 now: open the one above."
+ if page.the_url_is_the_credential(token)
+ else ""
+ )
+ said = page.as_a_block(
+ f"{settings.base_url.rstrip('/')}{page.PREFIX}/{minted}/\n\n"
+ f"This URL is the credential and it is shown once — only its hash is kept, so no "
+ f"later view can print it again. Every URL handed out before this moment has "
+ f"stopped working.{here_too}"
+ )
+ else:
+ raise ValueError(f"{what!r} is not something this page does. Nothing was changed.")
+ except Exception as exc: # every refusal already carries its own sentence
+ session.rollback()
+ said = str(exc)
+ else:
+ session.commit()
+
+ return HTMLResponse(
+ page.instance(
+ session,
+ settings,
+ error_reporting=_reporting_enabled,
+ acting=acting,
+ said=said,
+ ),
+ headers=page.HEADERS,
+ )
+
+
+def _days(raw: str | None) -> int:
+ """The retention window, refused rather than defaulted when it is not a number.
+
+ A blank field becoming `0` would clear everything, which is the one mistake this control must
+ not make quietly.
+ """
+ try:
+ days = int(raw or "")
+ except (TypeError, ValueError):
+ raise ValueError(f"{raw!r} is not a number of days. Nothing was changed.") from None
+ if days < 1:
+ raise ValueError("the window has to be at least one day. Nothing was changed.")
+ return days
+
+
+def _republish_all(session: Session, settings: Settings) -> str:
+ """Finish every verdict the dispatcher reached and could not send. Item 077, on the page.
+
+ The receiver has the forge credential this needs and provably not the one that can push, so a
+ `pr-open` verdict is refused here exactly as it is refused in the terminal: it needs the files
+ the agent wrote and nothing stores them (item 079).
+ """
+ from hullwork import work as work_module
+ from hullwork.cli import _redactions
+ from hullwork.models import Item as ItemRow
+ from hullwork.models import Project as ProjectRow
+
+ stranded = work_module.unpublished_verdicts(session)
+ if not stranded:
+ return "No verdict is waiting to be published."
+
+ forge = make_forge(settings)
+ done: list[str] = []
+ try:
+ for attempt in stranded:
+ item = session.get(ItemRow, attempt.item_id)
+ project = session.get(ProjectRow, item.project_id) if item else None
+ if project is None: # pragma: no cover - a foreign key makes this unreachable
+ continue
+ try:
+ where = work_module.republish(
+ session,
+ attempt,
+ forge=forge,
+ repo=project.repo,
+ secrets=_redactions(settings),
+ )
+ except work_module.PublicationError as exc:
+ done.append(f"attempt {attempt.id}: {exc}")
+ continue
+ done.append(f"attempt {attempt.id}: published to {project.repo}{where}")
+ finally:
+ if forge is not None:
+ forge.close()
+ return " · ".join(done)
+
+
+@app.post(
+ f"{page.PREFIX}/{{token}}/items/{{item_id}}",
+ tags=["page"],
+ response_class=HTMLResponse,
+ include_in_schema=False,
+)
+async def page_item_action(
+ token: str,
+ item_id: int,
+ request: Request,
+ session: Annotated[Session, Depends(_readiness_session)],
+ settings: Annotated[Settings, Depends(get_settings)],
+) -> HTMLResponse:
+ """What an operator does to one item that is not a decision about it. Item 219.
+
+ The two decisions — approve, human-only — keep their own routes: they are the product's gate and
+ they are pressed by somebody who may be reading nothing else. This is the housekeeping beside
+ them, and it takes an action field for item 207's reason.
+ """
+ from hullwork import cli
+ from hullwork.models import Item as ItemRow
+ from hullwork.models import Project as ProjectRow
+
+ acting = _the_operators(session, request, token)
+ if not operator.csrf_ok(
+ operator.acting(session, request.cookies.get(operator.COOKIE)),
+ await _field(request, "csrf"),
+ ):
+ raise HTTPException(status.HTTP_403_FORBIDDEN, "Forbidden")
+
+ found = session.get(ItemRow, item_id)
+ if found is None:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "Not Found")
+
+ what = await _field(request, "action")
+ said: str | None = None
+ try:
+ if what == "requeue":
+ project = session.get(ProjectRow, found.project_id)
+ back = cli.requeue(session, project.slug if project else "", item_id)
+ said = (
+ f"Item {back.id} ({back.lane.value}) is now '{back.state.value}'. Its attempt was "
+ f"never spent, so it still has one."
+ )
+ else:
+ raise ValueError(f"{what!r} is not something this page does. Nothing was changed.")
+ except Exception as exc: # every refusal already carries its own sentence
+ session.rollback()
+ said = str(exc)
+ else:
+ session.commit()
+
+ shown = page.item(session, settings, item_id, acting=acting, said=said)
+ if shown is None: # pragma: no cover - it existed a line ago
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "Not Found")
+ return HTMLResponse(shown, headers=page.HEADERS)
+
@app.post(f"{page.PREFIX}/{{token}}/logout", tags=["page"], include_in_schema=False)
async def page_logout(
token: str,
diff --git a/hullwork/models.py b/hullwork/models.py
index 431dd57..98a6495 100644
--- a/hullwork/models.py
+++ b/hullwork/models.py
@@ -227,6 +227,19 @@ class Project(Base):
)
#: Deactivated rather than deleted: unregistering a project must not destroy its history.
+ #: **Whether the ingest _token_ can write code**, measured by asking (item 228), and when.
+ #:
+ #: The **token**, not the account. `PushCapability.can_push` is what the account may do, and a
+ #: token scoped to reads and issues is refused regardless — measured on this project's own
+ #: instance, where that flag was `True` for both projects while `POST /branches` answered
+ #: `403 … scope(s): [write:repository]`. Recording the account's answer here and painting it
+ #: red would rebuild the permanently-on signal item 073 deleted a whole check for.
+ #:
+ #: `None` is *not measured* and never a `False`. Columns rather than keys inside `manifest`,
+ #: which is the project's own document adopted verbatim (DR-0012) and no place for a fact the
+ #: instance measured — the page read exactly such a key for two items and nothing wrote it.
+ ingest_token_can_push: Mapped[bool | None] = mapped_column(Boolean, default=None)
+ ingest_checked_at: Mapped[datetime | None] = mapped_column(UtcDateTime(), default=None)
active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(UtcDateTime(), default=_now)
@@ -774,6 +787,22 @@ class DispatcherLease(Base):
#: column — and it must not read as `off`, which is the defect item 105 was closed for.
error_reporting: Mapped[bool | None] = mapped_column(Boolean, default=None)
+ #: What this dispatcher is doing at this moment, in a person's words, or `None` when it is idle.
+ #: Item 242.
+ #:
+ #: **Written by the process rather than deduced by the page.** Everything a reader could infer
+ #: — this heartbeat, an attempt without a `finished_at`, a verdict appearing — has the same hole
+ #: in the middle: between two writes there is nothing to read, and that gap is the four minutes
+ #: somebody is trying to watch. The instance report called it *nothing in progress* while a
+ #: verification built an image and ran a suite twice.
+ #:
+ #: On the lease and not in a table of its own, because it is the same fact: *who is dispatching
+ #: now*. Two rows could disagree about whether one exists, and then neither is worth reading.
+ doing: Mapped[str | None] = mapped_column(String(200), default=None)
+ #: When that started, so the page can say how long it has been going on rather than only what
+ #: it is. A step that has taken nine minutes is the interesting one.
+ doing_since: Mapped[datetime | None] = mapped_column(UtcDateTime(), default=None)
+
class Installation(Base):
"""A name this deployment can be counted by. One row, id 1. Item 151.
@@ -801,3 +830,113 @@ class Installation(Base):
identifier: Mapped[str] = mapped_column(String(32))
created_at: Mapped[datetime] = mapped_column(UtcDateTime(), default=_now)
+
+
+class DependencyReport(Base):
+ """What OSV had published against what a project pins, and **when that was asked**. DR-0024.
+
+ One row per project, overwritten: this is a claim about a moment, not a history. A history would
+ be a different feature with a different cost, and nobody has asked for one.
+
+ **`asked` is why the row exists.** An advisory list that silently reads empty when the network
+ was down is the worst failure this feature can have — it says *you are fine* on no evidence. So
+ the row records whether the question reached OSV at all, and `note` says what stopped it. That
+ is the operator's own condition on accepting DR-0024, and it is the same *I could not verify
+ this* that has been a first-class answer here since item 199.
+ """
+
+ __tablename__ = "dependency_reports"
+
+ project_id: Mapped[int] = mapped_column(ForeignKey("projects.id"), primary_key=True)
+ taken_at: Mapped[datetime] = mapped_column(UtcDateTime(), default=_now)
+ #: Whether OSV answered. `False` with a `note` is a report; `False` with none is a bug.
+ asked: Mapped[bool] = mapped_column(Boolean, default=False)
+ note: Mapped[str | None] = mapped_column(Text, default=None)
+ #: How many pinned versions were read, so *nothing published* can be told apart from *nothing
+ #: read*. A project with no lock file has 0 here and no findings, and those are not the same
+ #: sentence.
+ pinned: Mapped[int] = mapped_column(Integer, default=0)
+ #: `[{package, version, source, advisories: [{id, summary, fixed: []}]}]`. JSON rather than two
+ #: more tables, because nothing queries inside it: the page renders it and the next report
+ #: replaces it whole.
+ findings: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list)
+
+
+class UpgradeVerdict(Base):
+ """What happened when this instance tried one published fix. DR-0026, item 233.
+
+ **One row per (project, package, from, to)**, overwritten. The question is *does this upgrade
+ hold today*, and yesterday's answer about the same pair is not a second fact — it is the same
+ fact, stale.
+
+ `outcome` is `bump.Verdict`'s own vocabulary and the four states do not collapse: `clean` is
+ *your suite passed before and after* and **not** *this is safe*; `breaks` is the finding;
+ `will-not-install` is the build failing, which is a different fact from the suite failing; and
+ `already-red` is a suite that was failing before anything was touched, so no claim can be made
+ either way.
+
+ **Nothing here was written anywhere else.** DR-0026 stops the queue at `verify`: no branch, no
+ pull request, no comment. This table is the whole of what the attempt produced.
+ """
+
+ __tablename__ = "upgrade_verdicts"
+ __table_args__ = (
+ UniqueConstraint("project_id", "package", "was", "to", name="uq_upgrade_verdict"),
+ )
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
+ project_id: Mapped[int] = mapped_column(ForeignKey("projects.id"), index=True)
+ package: Mapped[str] = mapped_column(String(200))
+ #: The version that was pinned when this was tried. A verdict about a version no longer pinned
+ #: is worse than no verdict, because it reads as current — so the page checks this against the
+ #: report before showing anything.
+ was: Mapped[str] = mapped_column(String(100))
+ to: Mapped[str] = mapped_column(String(100))
+ outcome: Mapped[str] = mapped_column(String(30))
+ #: What the suite or the build said, trimmed. The evidence, not a summary of it.
+ detail: Mapped[str | None] = mapped_column(Text, default=None)
+ tried_at: Mapped[datetime] = mapped_column(UtcDateTime(), default=_now)
+ #: What the passing run produced: `{"files": {path: text}, "runs": {...} | None}`. Item 245.
+ #:
+ #: Only a `clean` verdict has one, and it is the whole of what a pull request is opened with.
+ #: Neither half is a convenience. **The files**: `bump.attempt` restores every file it moved, so
+ #: reconstructing the diff later means running the resolver again, and a lock regenerated twice
+ #: can differ — a version published in between, a different ordering, a registry that answered
+ #: differently. Publishing files the suite did not pass against is the defect item 045 is named
+ #: after. **The runs**: they are the evidence the artefact is *for* — the two exit codes and the
+ #: runner's own summary lines — and by the time anybody renders one the containers are gone. A
+ #: pull request opened from this row without them would carry strictly less than the one the
+ #: terminal opens, which is a degradation nobody would see.
+ #:
+ #: **`none_as_null` because emptying it has to be visible from SQL.** Without it, assigning
+ #: `None` writes the JSON text `null` — four bytes, so the storage *is* released, and a row that
+ #: reads `None` in Python. But `WHERE artefact IS NOT NULL` then counts it, which is how the
+ #: check run minutes after the first pull request was opened reported two artefacts where the
+ #: database held one. `forget_stale` filters on exactly that predicate.
+ artefact: Mapped[dict[str, Any] | None] = mapped_column(
+ JSON(none_as_null=True), default=None
+ )
+ #: The commit the suite ran against, which is where the branch is rooted — never wherever the
+ #: default branch points when somebody presses the button. The base can move freely in between.
+ base_sha: Mapped[str | None] = mapped_column(String(64), default=None)
+ #: When a person asked for this one to be opened. DR-0026: *open stays a button somebody
+ #: presses*, and the receiver that renders the button cannot open anything, so the request is
+ #: written here and the dispatcher acts on it.
+ asked_to_open_at: Mapped[datetime | None] = mapped_column(UtcDateTime(), default=None)
+ #: Where the pull request went. Its presence is what *opened* means.
+ opened_where: Mapped[str | None] = mapped_column(Text, default=None)
+ #: What became of it: `None` until the forge has been asked, then `open`, `merged` or `closed`.
+ #: Item 253.
+ #:
+ #: **Written once was the defect.** `opened_where` alone meant *a draft pull request is waiting
+ #: for a person*, for ever — so a merged one kept asking for a review that had happened, and one
+ #: a person closed without merging displayed their "no" as work they owed. Item 138 split the
+ #: same two facts on `Item` and this is that split, one noun along.
+ opened_state: Mapped[str | None] = mapped_column(String(10), default=None)
+ #: When the forge was last asked about it, so a pull request that sits open for a week costs one
+ #: request per report cycle rather than one per turn. `Item.merge_checked_at`'s counterpart.
+ open_checked_at: Mapped[datetime | None] = mapped_column(UtcDateTime(), default=None)
+ #: Why a request produced no pull request — already open from an earlier run, or the forge
+ #: refused. Never silence: a row that was asked for and shows neither outcome is a row somebody
+ #: presses again.
+ open_note: Mapped[str | None] = mapped_column(Text, default=None)
diff --git a/hullwork/page.py b/hullwork/page.py
index a9072f3..4dfa494 100644
--- a/hullwork/page.py
+++ b/hullwork/page.py
@@ -31,15 +31,16 @@
from __future__ import annotations
import html
+import json
import re
-from collections.abc import Sequence
+from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Any
from urllib.parse import urlsplit
-from sqlalchemy import select
+from sqlalchemy import func, select
from hullwork import __version__, spend
from hullwork.models import Attempt as _Attempt
@@ -124,6 +125,22 @@ def opens(session: Session, token: str, *, acting: Acting | None = None) -> bool
return verify_token(token, expected) and access is not None
+def the_url_is_the_credential(token: str) -> bool:
+ """Whether this request is reading through a minted URL rather than through its session.
+
+ **Asked by the one action that revokes it** (item 250). `page-token` mints a new read link and
+ stops every old one — including, when the answer is served at one of them, the URL it is being
+ read on: every link on that answer is correctly written and every one of them answers `404`.
+ Saying so is the difference between a reader who opens the new link and one who clicks.
+
+ Here rather than in the route, because `MINE` is this module's reserved word and a route that
+ knows it is a route with an opinion about the gate — which is the property
+ `test_there_is_one_gate_and_not_ten` exists to keep, at four days' cost across items 193, 194,
+ 200 and 203.
+ """
+ return token != MINE
+
+
def offers_a_login(token: str, acting: Acting | None) -> bool:
"""Whether a request that may not read should be shown the login rather than a `404`.
@@ -305,6 +322,12 @@ def _link(url: str | None, text: str | None = None) -> str:
to `--measure`, because the fix for dead margins is not 120-character lines. */
.wrap { max-width: 108rem; margin: 0 auto; padding: 0 2rem 4rem; }
.sheet > p, .sheet > .sub, .sheet .why, footer { max-width: var(--measure); }
+/* And the same measure inside a fold. A child selector cannot see them: everything a ` {_h(policies(settings).detail)} {_h(policies(settings).detail)} {_own_prose(_SPLIT)} {_own_prose(_SPLIT)} {_h(line)} {says} The dispatcher answered '
+ f"{_h(_since(when))}. '
+ + (
+ f"Nothing has claimed the lease since {_h(_since(when))}. "
+ if state == "stale"
+ else "No dispatcher has ever run against this instance. "
+ )
+ + "Nothing will be attempted or verified until one does. The last thing it said it was doing was '
+ f"{_h(row.doing)}, {_h(_since(row.doing_since))}. Its heartbeat has "
+ "stopped, so that is what it was doing rather than what it is doing. {_h(row.doing)} for {_h(_ago(row.doing_since))} {tail}'
- + (f' {queued} item(s) are ready and waiting.' if queued else "")
+ + (f" {queued} item(s) are ready and waiting." if queued else "")
+ " {" · ".join(parts)} Nothing disagrees: the three checks ran and found nothing. {_as_code(getattr(one, "detail", ""))} {_as_code(said)} {len(found) - len(worrying)} of {len(found)} check(s) are fine. All {len(found)} check(s) are fine. What this process was handed, which is a different question from what you '
- "wrote in a file. No credential is printed: a secret reads {_as_code(feature.does)} needs {_as_code(need.what)} — {_as_code(need.fix)} {_as_code(need.what)}: not from here — a dispatcher is running and '
+ f"this is a resource it uses, not one this process does. {_as_code(need.what)}: not asked yet — nothing on this instance '
+ f"reads your tree, and a page render does not spend a forge request to find out. "
+ f"{_as_code('`hullwork features --checkout .`')} answers it where the code is. {_as_code(limit)} Every feature this instance can have is on. '
- f"{len(standing)} of {len(standing)}. {on} of {len(standing)} feature(s) on. No delivery has ever been accepted for this project. That is not the same as "
+ "nobody having knocked: a call with the wrong secret is refused before anything is "
+ "written down, so this list can only say that none arrived with a working "
+ "secret. The rejection is in this instance's log. {total} delivery(s) accepted, carrying {carried} fact(s). A delivery carrying no fact is a tracker sending something that is not an '
+ "error, which is ordinary. One that was never understood carries its own reason. {"".join(tally)} Each upgrade was applied in a clone, built, and measured against this '
+ "project's own suite. This instance verifies on its own clock and never opens one "
+ "by itself — what gets opened, a person asks for, and nobody merges it but "
+ "you. That covers {already_red} upgrade(s), measured once and asked '
+ f"again when the next dependency report is taken. {ready} of these can be opened as draft pull requests. Signing in is '
+ f"what offers the control. {ready} passed your suite and none can be '
+ f"opened: this project has not permitted it. Set "
+ f" Not asked yet. This instance reads what you pin and asks OSV on its own clock, "
+ "within six hours of a project being connected. Asked {_h(_ago(report.taken_at))}. Could not ask: '
+ f'{_as_code(report.note or "the reason was not recorded")} This is not an empty report. Nothing here says your dependencies are fine; it "
+ "says the question did not reach an answer. Nothing pins a version. {_as_code(report.note or "")} OSV has nothing published against any of the {report.pinned} pinned version(s) "
+ f"this repository declares. It reads what you pinned, so a dependency your build resolves at '
+ "install time is invisible to it — and it asks one database. {report.pinned} versions pinned · {len(packages)} package(s) with '
+ f"something published · asked {_h(_ago(report.taken_at))}. Not watched. No error from it becomes an item and the sweep '
+ "skips it; nothing was deleted. Rotating the webhook secret stops the URL your tracker is using '
+ "now, and the new one is shown once and never again — only its hash is kept. {who} Releasing it means the next dispatcher does not wait for the expiry. Verdicts the dispatcher reached and could not send are finished by '
+ "publishing them again. The attempt is already spent either way. The read link is a shared key: anyone holding it reads every item and captured "
+ "output here. Issuing a new one stops the URL anybody is using now, "
+ "which is what you press it for — and the new one is shown once. Forgetting the verbatim bodies of old deliveries keeps every row, fingerprint and "
+ "issue reference. It is the only thing on this page that destroys anything, so it "
+ "says what it would drop first. {_as_code(getattr(one, "detail", ""))} Every check this instance runs on itself, and what '
+ "each one would stop working if it failed. {len(found) - len(worrying)} of {len(found)} check(s) are fine. All {len(found)} check(s) are fine. What this process was handed, which is a different question from what you '
+ "wrote in a file. No credential is printed: a secret reads Every feature this instance can have is on. '
+ f"{len(standing)} of {len(standing)}. {on} of {len(standing)} feature(s) on. The URL your tracker was posting to has stopped working — update it before the "
"next error, or nothing arrives. This is the only time the new one is shown: only "
"its hash is stored. Point your error tracker's webhook at this. This is the only time it is shown — "
"only its hash is stored, so nobody, including this page, can print it again. Lose it and "
" {says} No attempt has run for this project. An item is attempted when it is queued and "
+ "the dispatcher is running; an amber one waits for you first. No item has arrived for this project. Showing {len(rows)} of {total}. No project is registered yet. {_h(project.forge)} · {_h(project.repo)}'
f"{'' if project.active else ' · not active'} Showing {min(len(found), MAX_ITEMS)} of {len(found)}. {says} Showing {len(rows)} of {total}. {_h(found.forge)} · {_h(found.repo)}'
- f"{'' if found.active else ' · not active'} · "
- f'All projects · This instance No item has arrived for this project.What this instance allows
Which half holds what
"
+ for count, means, caveat in rows
+ )
+ # In the container the rule asks for (item 215): every table on this page sits in something
+ # that scrolls on its own, and a two-column table being unable to overflow is a reason to
+ # believe it rather than a reason to exempt it.
+ return f'{count} '
+ f'{_h(means)}'
+ + (f'{_h(caveat)}' if caveat else "")
+ + " {lines}
{_h(label)}
{said}{body} "
+ for when, colour, said in sorted(events, key=lambda one: one[0], reverse=True)[:limit]
+ )
+ return _section(
+ "What it has been doing",
+ "Newest first, across every project.",
+ f''
+ f''
+ f'{said} '
+ f'{rows}
',
+ )
+
+
+def _what_it_says_it_is_doing(session: Session) -> str:
+ """The dispatcher's own sentence about what it is doing, or `""` when there is none. Item 242.
+
+ **Its word, not an inference.** Everything a page could deduce — the heartbeat, an attempt with
+ no `finished_at`, a verdict appearing — has the same hole in the middle: between two writes
+ there is nothing to read, and that gap is exactly the four minutes somebody is watching.
+
+ A stale heartbeat is not busy. A dispatcher killed mid-sentence leaves its last one behind, and
+ rendering that as *now* would be this page's own version of the defect it is fixing — so the
+ lease's own reading of itself decides, and a stale one says so instead.
+ """
+ from hullwork import lease as lease_module
+ from hullwork.models import DispatcherLease
+
+ row = session.get(DispatcherLease, 1)
+ state, when = lease_module.state(session)
+ if row is None or not row.doing:
+ # **Idle is a fact and being unreachable is a different one.** A door that renders nothing
+ # in both cases answers *is it running?* with silence, which is the question the operator
+ # opened it to ask.
+ if state == "alive":
+ return (
+ 'What does not add up
{rows}
{rows}
'
+ return f'{_h(said[len(_BLOCK):])}Why it will not work
"
- + (
- f'{rows}
'
- f'{"".join(rows)}
',
)
- return _document("Hullwork — doctor", body, acting=acting, here="doctor")
-def what_it_received(settings: Settings, *, acting: Acting = READING) -> str:
- """`config`, for somebody without a shell. Item 208.
+def _configured_here(settings: Settings) -> list[str]:
+ """The **names** of the variables this process received, never their values.
- **No credential is printed**, and that is `settings_report`'s property rather than this
- function's: a secret reads `set` or `not set` before it ever reaches here. Worth saying because
- `config` reads like the most disclosing thing in the product and is in fact the most carefully
- disclosing thing in it.
+ `Checkout.configured` is names only by design — that is what lets this say *needs a model
+ credential, and none is configured* without ever holding one.
"""
- from hullwork import settings_report
+ from hullwork.features import CODE_TOKEN, MODEL_KEY
- rows = "".join(
- f" "
- for name, value, source, reaches in settings_report.rows(settings)
- )
- body = (
- "{_h(name)} {_h(value)} "
- f"{_h(source)} {_h(reaches)} What it received
"
- 'set or "
- "not set. {rows}variable value from '
- f"reaches {rows}
'
- f' '
+ for one in rows
+ )
+ carried = sum(facts.values())
+ return (
+ f''
+ f' "
+ f''
+ + (
+ f'{_h(one.error[:80])}'
+ if one.error
+ else ("yes" if one.processed_at else "not yet")
+ )
+ + f' {_h(facts.get(one.id, 0))} '
+ f'{_h(one.attempts)} {listed}arrived understood '
+ f"facts in it tries {len(row["advisories"])}
'
+ f'{says}
'
+ f' "
+ )
+
+
+def _action_for(row: Mapping[str, Any], *, project: _Project, acting: Acting,
+ permitted: bool) -> str:
+ """The one control this row has, or nothing at all.
+
+ **No column of empty cells** (DR-0028): the action column was empty in twenty-five rows out of
+ twenty-six, paying width to say nothing. It renders where it exists and the cell collapses
+ everywhere else.
+ """
+ if row["state"] in _CARRY_A_LINK and row["where"]:
+ return (
+ f''
+ f'#{_h(str(row["where"]).rsplit("/", 1)[-1])} ↗'
+ )
+ if row["state"] != "ready" or not permitted or not acting.csrf or not row["verdict"]:
+ return ""
+ csrf = f''
+ return (
+ # **`dependencies`, which is the URL this view is served from** (item 250). It posted to
+ # `../'
+ f'{_h(row["package"])}{move}{note} '
+ f'{_h(" · ".join(row["sources"]))} '
+ f'{fold} '
+ f''
+ f"{_action_for(row, project=project, acting=acting, permitted=permitted)} "
+ f"{_h(title)}'
+ f'{_h(says)}{len(here)}
'
+ f'{lines}
autofix: {{open_upgrades: true}} in its manifest if you want that "
+ f"button. It is false by default because having the credential is not the same as "
+ f"having agreed.{_h(holder)}."
+ if holder
+ else "No dispatcher holds it."
+ )
+ csrf = f''
+ body = (
+ f"Diagnostics
{rows}
'
+ f' "
+ for name, value, source, reaches in settings_report.rows(settings)
+ )
+ body = (
+ "{_h(name)} {_h(value)} "
+ f"{_h(source)} {_h(reaches)} What it received
"
+ 'set or "
+ "not set. {rows}variable value from '
+ f"reaches {rows}
'
+ f'This instance
"
# The answer and the decisions are the front door's now (item 212). They are still here,
# from the same function, because an operator who opens the report on a bad morning should
# not have to go back to learn whether anything wants them.
- does_this_need_you(session, settings, acting, error_reporting=error_reporting)[0]
+ + does_this_need_you(session, settings, acting, error_reporting=error_reporting)[0]
+ _proof(
session,
merged=merged,
@@ -1720,32 +3247,62 @@ def instance(
# The link row that used to live here is the rail now (item 212): two sets of navigation
# on one page is two places to add the next noun to, and one of them will be forgotten.
+ '{table}
{desk}
") if desk else "")
- + (_fold("What its attempts came to", f"{attempts}
") if attempts else "")
- + (_fold("What they cost", f"{spent}
") if spent else "")
- + (_fold("What reviewers did", f"{reviewed}
") if reviewed else "")
- + _fold(
- "Which half holds what, and what this instance allows",
- _the_credential_split(session) + _what_this_instance_allows(settings),
+ + (
+ _section(
+ "What left your desk",
+ "Of everything that arrived, how much this instance took off you.",
+ desk,
+ )
+ if desk
+ else ""
+ )
+ + (
+ _section(
+ "What attempts came to",
+ "Every attempt this instance has made, by how it ended.",
+ attempts,
+ )
+ if attempts
+ else ""
+ )
+ + (
+ _section("What they cost", "In tokens and in money, through one arithmetic.",
+ f"{spent}
")
+ if spent
+ else ""
+ )
+ + (
+ _section("What reviewers did", "What became of the pull requests it opened.", reviewed)
+ if reviewed
+ else ""
)
+ + _the_credential_split(session)
+ + _what_this_instance_allows(settings)
+ + _housekeeping(session, acting)
+ "{_h(slug)} has a new webhook secret
"
+ f'{_h(slug)} has a new webhook secret
'
"{_h(slug)} is connected
'
+ f'{_h(slug)} is connected
'
"hullwork projects rotate-secret issues another, which stops the old one.{_h(found.slug)} {_h(label)}
'
+ # **Above the body, because it is the answer to what was just pressed** (item 245). A
+ # sentence below eight hundred words of report is a sentence nobody sees, and this one says
+ # the pull request does not exist yet — which is the part a reader has to read.
+ #
+ # **A refusal answers here too** (item 250). It used to answer with the list of projects,
+ # rendered at this URL: six dead links handed to somebody whose forge had just gone down.
+ f' '
+ for one, bug in rows
+ )
+ cost = _project_cost(session, project.id, prices)
+ return (
+ ''
+ f"{_h(bug.title[:70])} "
+ f'{_h(one.phase_reached.value)} '
+ f'{_h(one.outcome.value if one.outcome else "still running")} '
+ f'{"spent" if one.consumed else "not spent"} '
+ f'{_h(_ago(one.started_at))} {listed}item reached ended '
+ f"its one try when '
+ for one in rows
+ )
+ bound = f"{one.id} '
+ f'{_h(one.title)} '
+ f'{_h(one.state.value)} '
+ f'{_h(one.lane.value)} '
+ f'{_h(_ago(one.state_since))} {listed}id title state '
+ f"lane since Projects
'
+ _the_form(acting, answered=answered)
+ "{_h(project.slug)}
'
f'{health}
"
- f"{_project_columns(session, project.id)}"
+ + (f"{health}
" if health else "")
+ + f"{_project_columns(session, project.id)}"
+ # **A list is a list** (items 223 and 225). The controls went to the project's own
+ # view first; the feature block followed, because it was 85% of this page and rendered
+ # once per project. On a list a reader is not looking *at* a project — they are looking
+ # *for* one.
)
bound = (
f" "
- for item_row in rows
- )
- total = len(list(session.scalars(select(_Item).where(_Item.project_id == found.id)).all()))
- bound = f"{item_row.id} "
- f"{_h(item_row.title)} "
- f"{_h(item_row.state.value)} "
- f"{_h(item_row.lane.value)} "
- f"{_h(_ago(item_row.state_since))} {_h(found.slug)}
"
+ _outcome(said)
+ + f'{_h(found.slug)} Overview
'
f'Health
{health}
"
- f"Where everything is
{_project_columns(session, found.id)}"
- f"What its attempts cost
{_project_cost(session, found.id, prices)}"
- f"Items
{bound}"
- + (
- "
"
- if listed
- else " "
- f"{listed}id title state lane since
No project is connected yet.
", + ) + + what_it_has_been_doing(session) + ) + return _document( + "Hullwork", + body, + acting=acting, + here="./", + state=badge, + projects=each_project(session), + ) + + +#: What each of `_COLUMNS` means, for the heading that now carries the grouping. DR-0028. +#: +#: **The words are the reader's question, not the state machine's.** `pr-open` is not a state a +#: person cares about; *a draft pull request is waiting for somebody to read it* is. +_WHO_IS_BLOCKED: dict[str, str] = { + "waiting": "a decision from you, or work no agent may attempt", + "review": "a draft pull request is waiting for somebody to read it", + "arrived": "triaged and not queued yet", + "queued": "eligible, and the dispatcher takes one per turn", + "working": "an attempt is running now", + "closed": "merged, rejected, or answered — nothing owed", +} + + +def _items_by_who_is_blocked(rows: Sequence[_Item], pulls: Mapping[int, str]) -> str: + """Every item, grouped by who is blocked and ordered by whether that is you. DR-0028, item 247. + + **`_COLUMNS` is the grouping, and it already existed.** Inventing a second vocabulary for this + list is how it and the strip on the front page would come to disagree about what *waiting* means + — which is the drift DR-0027 spent an item undoing. So the six columns are the six bands, in an + order this view chooses: **what the reader owns first, `closed` last**. + + The chronological order the flat table used put the one item waiting for a person at the top by + luck — it happened to be the most recently seen. At two hundred items that is wherever the clock + left it. + """ + owned = [one for one in _COLUMNS if one[4]] + rest = [one for one in _COLUMNS if not one[4] and one[1] != "closed"] + closed = [one for one in _COLUMNS if one[1] == "closed"] + bands = [] + for title, key, colour, states, _ in [*owned, *rest, *closed]: + here = [row for row in rows if row.state in states] + if not here: + continue + lines = "".join(_an_item(row, colour, pulls) for row in here) + bands.append( + f'| item | project | state | lane | " - "title | last seen | issue / pull |
|---|
The bugs your error tracker sent. Hullwork calls each one an ' + "item.
" f'{shown}{scope}.{order}{everything}
' + table ) - return _document("Hullwork — items", body, acting=acting, here=here, state=badge) + return _document( + "Hullwork — errors", body, acting=acting, here=here, state=badge, + projects=each_project(session), + ) def _above_the_fold(attempt: Attempt, prices: Prices | None) -> str: @@ -2374,6 +4401,17 @@ def _next_action(found: Item, acting: Acting, *, up: str) -> str: parts.append(f"Waiting for {_own_prose(waiting)}
") if stuck: parts.append(f'But {_h(stuck)}.
') + # **The one state `requeue` exists for** (item 093), and it had a route and no button until + # item 223: an item left `human-only` by a red baseline holds an attempt it never spent, and the + # only way to give it back was an `UPDATE` against a SQLite file inside a Docker volume. + if found.state is ItemState.HUMAN_ONLY and acting.csrf: + parts.append( + f'' + 'Only when what stopped it was the environment. Its attempt was never ' + "spent, so it still has one.
" + ) if found.state is ItemState.WAITING_APPROVAL and not stuck: # The buttons when there is a session, and otherwise how to get them — here as well as on # the front page, because a reader can arrive straight at an item from a forge issue. @@ -2409,7 +4447,7 @@ def _decide(found: Item, acting: Acting, *, up: str) -> str: return "" -def just_the_login(acting: Acting) -> str: +def just_the_login(acting: Acting, *, going_to: str = "") -> str: """The login and nothing else, for the door that replaces the token (DR-0021, item 204). **Nothing about the instance is on it.** A page showing a name, a version or a count beside the @@ -2434,12 +4472,12 @@ def just_the_login(acting: Acting) -> str: f'' f"