From 0bbae678f2ca43ede25cd09c774a0568d94be36f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Javier=20Miralles=20Ranca=C3=B1o?=
<68760931+FlagshipDev@users.noreply.github.com>
Date: Fri, 14 Aug 2026 10:07:41 +0200
Subject: [PATCH] The page a person actually uses, and what pressing every
button found
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three days of work on the half an operator looks at. Two decisions, twenty-one
items, and three defects that only turned up by using the thing.
## A project is the axis (DR-0027)
The page grouped by *feature*: one page holding every project's errors, another
holding every project's advisories. That is a wall at two projects and unusable
at ten, and it is not how anybody works — you work on a client, not on a column.
Each project now has its own five views, and the feature pages that spanned
projects are gone.
## The row is the subject (DR-0028)
The dependency view was **11,330 px, 12.6 screens, 2,830 words and 26 folds** for
twenty-six packages, with the outcome of an upgrade six screens away from the
advisory it answers. One package is now one row carrying every version of it that
is pinned, grouped by what can be done about it: **1,634 px**.
The same decision applied to the two views opened daily, where it found two
contradictions nothing was testing. The front door said **Nothing needs you** in
its headline and *2 waiting on you* in the row two lines below — both numbers
right, one name for them wrong. And the rail counted findings while the view it
linked to counted packages.
One measurement in that item falsified its own gate: the instance report got 3%
*taller*, and the gate said it would shrink. Recorded rather than chased —
DR-0028 is about what a page is made of, not how tall it is.
## The button DR-0026 always described
DR-0026 says an instance may try an upgrade and may not open one — *open stays a
button somebody presses*. The button had nowhere to press: the process that
renders the page refuses to hold a credential that can push, and the files a
clean verification produced were thrown away on the way out. So forty-one green
verdicts sat on a page correctly telling the reader nothing had been opened.
The verification now keeps what passed — the files and the commit it ran at — the
page writes down that a person asked, and the dispatcher opens it. The two
processes stay apart; nothing crossed to make this work.
## Three defects found by using it, not by reading
**Clicking.** An operator clicked a link and got `{"detail":"Not Found"}`. Three
broken relative URLs, and *seven dead forms* on a project's settings — every
administration control there posted into a 404. A dead link announces itself; a
dead button does not. The guard that existed for exactly this walked a
hand-written tuple of seven paths and had never been told about the five views
per project added since.
**Pressing.** Nothing had ever submitted a form and looked at what came back. A
`POST` answers with a document too, at the URL the form posted to, and eight of
thirty-five buttons answered with navigation that 404'd — a document built for one
URL served at another. The worst one was not a link: `rotate-secret` printed **a
single character** of the new webhook secret and type-checked, so pressing it
stopped the tracker's webhook and left nothing to replace it with.
**Asking.** `opened_where` was written when a pull request was opened and never
read back, so a merged one kept asking for a review that had happened and one a
person closed without merging displayed their explicit "no" as work they owed,
for ever. The error half has had that watcher since item 121; this is the same
split, one noun along.
## Also
A red baseline is measured once per project instead of once per upgrade — fifty
identical *your suite was already failing* verdicts in an hour became one. A
published version older than the one you pin is not a fix you can take. A
verification cleans up after itself. The page says what it is doing while the
four minutes pass.
Every test in here was verified by reintroducing the defect it covers.
`ruff check .`, `mypy .` and 2,320 tests are green in this tree.
Signed-off-by: Javier Miralles Rancaño <68760931+FlagshipDev@users.noreply.github.com>
---
docs/status.md | 16 +
hullwork.yml | 8 +
hullwork/advisories.py | 126 +
hullwork/cli.py | 367 ++-
hullwork/dependencies.py | 27 +
hullwork/features.py | 19 +
hullwork/lease.py | 24 +
hullwork/main.py | 738 ++++-
hullwork/models.py | 139 +
hullwork/page.py | 2704 +++++++++++++++--
hullwork/resolve.py | 163 +-
hullwork/sandbox/net.py | 4 +-
hullwork/sandbox/run.py | 6 +-
hullwork/sandbox/services.py | 17 +-
hullwork/upgrades.py | 787 ++++-
..._what_is_published_against_what_you_pin.py | 41 +
...the_verdict_carries_what_it_passed_with.py | 48 +
...f26_the_emptied_artefacts_read_as_empty.py | 41 +
...3_the_token_is_the_fact_not_the_account.py | 39 +
...295_what_happened_when_it_tried_the_fix.py | 49 +
...1f36b92_what_became_of_the_pull_request.py | 39 +
...f9e04_what_the_ingest_credential_may_do.py | 41 +
...b2_a_verdict_taken_without_its_database.py | 37 +
...b2e8d71a05_what_the_dispatcher_is_doing.py | 35 +
tests/test_a_fact_should_not_wait.py | 291 ++
tests/test_a_red_baseline_answers_once.py | 203 ++
tests/test_dependencies.py | 206 +-
tests/test_every_feature_has_a_place.py | 294 ++
tests/test_every_link_lands.py | 267 ++
tests/test_nothing_outlives_the_container.py | 84 +
tests/test_one_feature_one_section.py | 291 ++
tests/test_one_package_one_row.py | 253 ++
tests/test_page_surface.py | 17 +-
...st_reading_the_repository_from_the_page.py | 263 ++
tests/test_resolve.py | 155 +-
tests/test_sandbox_net.py | 7 +-
tests/test_the_desk_it_cleared.py | 6 +-
tests/test_the_door_and_the_errors.py | 219 ++
tests/test_the_door_answers_one_question.py | 184 ++
tests/test_the_door_you_arrive_at.py | 181 ++
...st_the_error_as_the_tracker_recorded_it.py | 178 ++
.../test_the_evidence_a_reviewer_came_for.py | 54 +-
tests/test_the_figures_are_a_table.py | 118 +
.../test_the_item_list_on_a_narrow_screen.py | 244 ++
tests/test_the_page_can_be_acted_on.py | 9 +-
tests/test_the_page_that_comes_back.py | 300 ++
tests/test_the_panel_measured.py | 22 +
tests/test_the_queue_that_stops_at_verify.py | 443 +++
tests/test_the_rest_of_a_projects_life.py | 88 +-
tests/test_the_sidebar_and_the_front_door.py | 2 +-
...est_the_six_that_needed_nothing_decided.py | 547 ++++
.../test_the_two_you_open_on_a_bad_morning.py | 6 +-
tests/test_the_verdict_can_be_acted_on.py | 524 ++++
tests/test_what_actually_arrived.py | 168 +
tests/test_what_became_of_the_pull_request.py | 269 ++
tests/test_what_happened_when_it_was_tried.py | 210 ++
tests/test_what_is_published_against_you.py | 467 +++
tests/test_what_it_is_doing_right_now.py | 251 ++
tests/test_what_this_can_do_for_you.py | 208 ++
59 files changed, 12048 insertions(+), 496 deletions(-)
create mode 100644 hullwork/advisories.py
create mode 100644 migrations/versions/a91c4e75d203_what_is_published_against_what_you_pin.py
create mode 100644 migrations/versions/a9f4d1c07b83_the_verdict_carries_what_it_passed_with.py
create mode 100644 migrations/versions/b1e7c3d94f26_the_emptied_artefacts_read_as_empty.py
create mode 100644 migrations/versions/b2e9f47a10c3_the_token_is_the_fact_not_the_account.py
create mode 100644 migrations/versions/c4d81b6ea295_what_happened_when_it_tried_the_fix.py
create mode 100644 migrations/versions/c4d8a1f36b92_what_became_of_the_pull_request.py
create mode 100644 migrations/versions/d5a7c31f9e04_what_the_ingest_credential_may_do.py
create mode 100644 migrations/versions/e0a7f31c88b2_a_verdict_taken_without_its_database.py
create mode 100644 migrations/versions/f4b2e8d71a05_what_the_dispatcher_is_doing.py
create mode 100644 tests/test_a_fact_should_not_wait.py
create mode 100644 tests/test_a_red_baseline_answers_once.py
create mode 100644 tests/test_every_feature_has_a_place.py
create mode 100644 tests/test_every_link_lands.py
create mode 100644 tests/test_nothing_outlives_the_container.py
create mode 100644 tests/test_one_feature_one_section.py
create mode 100644 tests/test_one_package_one_row.py
create mode 100644 tests/test_reading_the_repository_from_the_page.py
create mode 100644 tests/test_the_door_and_the_errors.py
create mode 100644 tests/test_the_door_answers_one_question.py
create mode 100644 tests/test_the_door_you_arrive_at.py
create mode 100644 tests/test_the_error_as_the_tracker_recorded_it.py
create mode 100644 tests/test_the_figures_are_a_table.py
create mode 100644 tests/test_the_item_list_on_a_narrow_screen.py
create mode 100644 tests/test_the_page_that_comes_back.py
create mode 100644 tests/test_the_queue_that_stops_at_verify.py
create mode 100644 tests/test_the_six_that_needed_nothing_decided.py
create mode 100644 tests/test_the_verdict_can_be_acted_on.py
create mode 100644 tests/test_what_actually_arrived.py
create mode 100644 tests/test_what_became_of_the_pull_request.py
create mode 100644 tests/test_what_happened_when_it_was_tried.py
create mode 100644 tests/test_what_is_published_against_you.py
create mode 100644 tests/test_what_it_is_doing_right_now.py
create mode 100644 tests/test_what_this_can_do_for_you.py
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"