Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 41 additions & 10 deletions docs/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,12 +122,35 @@ Losing them would make every already-known error look new tomorrow morning.

### Asking it how it is

The CLI lives *inside* the container in this stack, so every command is reached the same way:
> **Built and unreleased**, as of 2026-08-10. Everything in this section is in no image you can pull:
> `docs/published-surface.json` records `0.1.0a8`, and these routes are items 203 to 208. Until a
> release carries them, the CLI below is the whole of it — and this note stays, because the rule is
> that documentation describes the released artefact rather than this checkout (`CONTRIBUTING.md`).
>
> Written down rather than deleted because the guard that catches this **cannot see it**: it compares
> documented *commands* against the published image and knows nothing about URLs, so a route
> documented before it exists passes every test. Recorded as work item 209.

**From the page, which is the point** (DR-0022).
Set a password once — `docker compose exec api hullwork password`, read from a prompt so it is not in
your shell history — and everything below is a URL:

| | |
|---|---|
| `/page/me/` | how it is, and every feature this instance has switched on, off, or cannot do |
| `/page/me/doctor` | what is broken, a sentence per check |
| `/page/me/config` | what it actually received — every variable, where it came from, and which half gets it. **No credential is printed**: a secret reads `set` or `not set` |
| `/page/me/projects` | connect a project, re-read its manifest, disable it, rotate its webhook secret, name it in the tracker |

That is the whole of running an instance, and none of it needs a shell. The password is what unlocks
it; a read link handed to a colleague reads the instance and reaches none of those four.

The CLI still does all of it, unchanged, for scripts and for installing:

```bash
docker compose exec api hullwork status # how it is — exit 1 when degraded
docker compose exec api hullwork doctor # what is broken — a sentence per check
docker compose exec api hullwork config # what it is set to — every variable, source, and half
docker compose exec api hullwork status # exit 1 when degraded — `hullwork status || mail me`
docker compose exec api hullwork doctor
docker compose exec api hullwork config
```

`status` exiting 1 when degraded is what makes `hullwork status || mail me` a whole monitoring setup,
Expand All @@ -141,13 +164,21 @@ you wrote in a file.

### A page a teammate can read

`hullwork page-token` mints one URL, prints it once and stores only its hash. Behind it is what
`status` says, read from the same functions, plus the evidence of every attempt. It is **off until you
run that command**, and everything without the token — including a wrong token — gets the same `404` an
unknown path gets, so it cannot be found by probing.
Two doors, and they are for two people
(DR-0021). **The first is unreleased**, per the note
above; the second has worked since `0.1.0a5`.

**Yours is `/page/me/`**, behind the password. Nothing to lose, nothing to copy, and it is the only
one that administers anything.

**Theirs is a link.** `hullwork page-token` mints one URL, prints it once and stores only its hash —
read-only, and it reaches no button and neither `doctor` nor `config`. That URL **is** the credential:
anyone holding it can read every item and captured output here, so treat it as a secret. Rotating
replaces it and stops the old one.

That URL **is** the credential. Anyone who has it can read every item and captured output on this
instance. It is read-only, and rotating replaces it.
Everything without a valid token — including a wrong one — gets the same `404` an unknown path gets,
so the page cannot be found by probing. `/page/me/` offers a login **only where a password is set**,
so an instance that never set one is as undiscoverable as it ever was.

### Two things about the port

Expand Down
2 changes: 1 addition & 1 deletion hullwork/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""Hullwork — from production errors to reviewable pull requests, on your own infrastructure."""

__version__ = "0.1.0a8"
__version__ = "0.1.0a9"

__all__ = ["__version__"]
38 changes: 32 additions & 6 deletions hullwork/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
)
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
from hullwork.credentials import PushCapability
Expand Down Expand Up @@ -1498,6 +1499,20 @@ def rotate_secret(session: Session, slug: str) -> str:
return token


def set_tracker(session: Session, slug: str, tracker_project: str | None) -> Project:
"""Name this project in the tracker, or unname it. Instance configuration, never the manifest.

**Extracted by item 207**, the only production code that item moved: this lived inside
`_cmd_set_tracker` and had no caller but that command, so the page would have had to reimplement
it — the drift items 193, 194, 200 and 203 each cost a day to. Empty means *stop sweeping it*,
which is a real answer and not a missing one.
"""
project = _require(session, slug)
project.tracker_project = tracker_project or None
session.commit()
return project


def disable_project(session: Session, slug: str) -> Project:
"""Deactivate. Never delete: destroying history to unregister a project is a footgun."""
project = _require(session, slug)
Expand Down Expand Up @@ -2261,6 +2276,18 @@ def _cmd_status(
# and stopped mentioning the dispatcher at all — while it was stopped. That is the failure item
# 075's fourth gate exists to prevent, arrived at from the other side: an operator could not
# tell a quiet healthy instance from one with nothing running.
# **The same function the page renders** (item 203), so a reader with a terminal and a reader
# with a browser cannot come to disagree about the same instance.
standing = features_module.on_this_instance(session, settings)
worrying = [one for one in standing if one.state is not features_module.ON]
print("\n Features:", file=out)
if worrying:
for one in worrying:
print(f" ! {one.name}: {one.state} — {one.detail}", file=out)
print(f" - {len(standing) - len(worrying)} of {len(standing)} on", file=out)
else:
print(f" - all {len(standing)} on", file=out)

print("\n Dispatcher:", file=out)
for note in dispatcher:
mark = "!" if note.degraded else "-"
Expand Down Expand Up @@ -2611,9 +2638,7 @@ def _cmd_set_tracker(
args: argparse.Namespace, session: Session, settings: Settings, out: TextIO
) -> int:
"""Name this project in the tracker. Instance configuration, never the manifest (DR-0011)."""
project = _require(session, args.slug)
project.tracker_project = args.tracker_project or None
session.commit()
project = set_tracker(session, args.slug, args.tracker_project)
if project.tracker_project is None:
print(f"'{project.slug}' will no longer be swept.", file=out)
return 0
Expand Down Expand Up @@ -3053,9 +3078,10 @@ def _cmd_try(args: argparse.Namespace, settings: Settings, out: TextIO) -> int:
print(f"\n{verdict}. What it produced is under {into}.", file=out)
print(
" Nothing was published and no forge was contacted. The artefact there is the same one a "
"pull request would carry.\n"
" A real instance shows this and the rest — cost, policies, review debt — on a page: "
"`hullwork page-token` (DR-0014).",
"pull request would carry, and `evidence.html` beside it is the page a reviewer is shown "
"on a real instance — open it, or send it to somebody.\n"
" What that page cannot have here: the numbers an instance keeps across runs — cost over "
"time, review debt, whether a fix held. Those need one (DR-0014).",
file=out,
)
# A trial cannot consume an item, so its exit code answers a different question from `work`'s:
Expand Down
155 changes: 155 additions & 0 deletions hullwork/features.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,14 @@

from collections.abc import Callable, Sequence
from dataclasses import dataclass, field
from typing import TYPE_CHECKING

from hullwork.config import Settings
from hullwork.manifest import Manifest
from hullwork.models import Project

if TYPE_CHECKING: # pragma: no cover - a type, never a runtime dependency
from sqlalchemy.orm import Session


@dataclass(frozen=True)
Expand Down Expand Up @@ -367,3 +373,152 @@ def lines(answers: Sequence[Answer]) -> list[str]:
said.append(f" limit: {limit}")
said.append("")
return said


# --- the instance side of the same question (item 203) -------------------------------------------

#: The three answers a dashboard may give about a feature **on this instance**, and they are three
#: because collapsing any pair costs a reader something specific.
#:
#: `OFF` is a decision somebody took, and DR-0019's rule is that having is not permitting: rendering
#: a decision as a fault tells its reader to go and repair a choice they made. `CANNOT` is a fault
#: and names what is missing. `ON` is the least interesting of the three, which is why the ordering
#: below puts it last.
ON = "on"
OFF = "off"
CANNOT = "cannot"


@dataclass(frozen=True)
class Standing:
"""One feature, its state on this instance, and the sentence a reader acts on."""

name: str
state: str
detail: str


def _filing(session: Session, settings: Settings) -> Standing:
"""Whether a production error becomes an issue here.

**Configured, not reachable.** Whether the forge answers costs a network call and this renders
on
a page request; a dashboard that opened a socket per view would be a load test of somebody's
forge. So this says what it established and names what asks the other question — the distinction
items 193, 194 and 199 each cost a day to.
"""
name = "filing a production error as an issue"
if not settings.forge_url:
return Standing(
name, CANNOT,
"no forge configured. Set HULLWORK_FORGE_URL and HULLWORK_FORGE_TOKEN — a token that "
"can read content and write issues, and provably not push.",
)
active = session.query(Project).filter(Project.active.is_(True)).count()
if not active:
return Standing(
name, OFF,
"a forge is configured and no project is registered here yet: "
"`hullwork projects add --slug NAME --repo owner/name`.",
)
return Standing(
name, ON,
f"a forge is configured and {active} project(s) are registered. Whether it answers is not "
f"asked here — `hullwork doctor` asks it.",
)


def _the_page(session: Session, settings: Settings) -> Standing:
"""Off until somebody mints a token, which is a decision rather than an omission."""
del settings
from hullwork import page

if page.configured(session):
return Standing(
"the daily page", ON,
"a token has been minted. That URL is the credential: anyone holding it can read every "
"item and captured output here.",
)
return Standing(
"the daily page", OFF,
"off until you run `hullwork page-token`, and everything without the token gets the same "
"404 an unknown path gets, so it cannot be found by probing.",
)


def _notifications(session: Session, settings: Settings) -> Standing:
"""What each project asked for, against what actually delivers.

`telegram` and `email` parse in a manifest and are refused at delivery — true, documented in
prose, and said nowhere a person would look. This is where they would look.
"""
del settings
name = "notifications"
delivers = {"none", "console"}
asked = {
str(((project.manifest or {}).get("notify") or {}).get("channel", "none"))
for project in session.query(Project).filter(Project.active.is_(True)).all()
}
undeliverable = sorted(asked - delivers)
if undeliverable:
return Standing(
name, CANNOT,
f"{', '.join(undeliverable)} parses in a manifest and is refused at delivery: a "
f"transport nobody has exercised would have its first real run in front of a user. "
f"`console` and `none` are what deliver.",
)
if asked <= {"none"}:
return Standing(
name, OFF,
"every project here asks for `none`, which is the default and a decision. `console` is "
"the other one that delivers.",
)
return Standing(name, ON, f"delivering to {', '.join(sorted(asked - {'none'}))}.")


def _recurrence(session: Session, settings: Settings) -> Standing:
"""Whether a fix that did not hold can be noticed at all. It needs the tracker, not the
forge."""
del session
name = "the recurrence watch"
if not settings.tracker_url:
return Standing(
name, CANNOT,
"no tracker configured, so a returning error cannot be seen: a tracker notifies once "
"per issue for that issue's whole life, and a recurrence arrives by asking. Set "
"HULLWORK_TRACKER_URL and HULLWORK_TRACKER_TOKEN.",
)
return Standing(
name, ON,
"a tracker is configured and the sweep asks it. Whether it answers is not asked here.",
)


#: One answerer per name in `INSTANCE_SHAPED`, so the list is what drives the dashboard rather than
#: something kept beside it. A fifth name added there tomorrow fails this module loudly rather than
#: going unanswered the way all four did until item 203.
_ANSWERS = {
"filing a production error as an issue": _filing,
"the daily page": _the_page,
"notifications": _notifications,
"the recurrence watch": _recurrence,
}


def on_this_instance(session: Session, settings: Settings) -> list[Standing]:
"""What this instance has switched on, off, and cannot do — worst first.

**The other half of `examine`.** That one answers for a checkout and hands `INSTANCE_SHAPED`
back as somebody else's question, with a comment saying `doctor` owns it. `doctor` answers
resources, and a resource is not a feature: four capabilities were named as nobody's question
until this took them.

Ordered with `ON` last on purpose. A reader opens this looking for what is not working, and a
wall of green with one red line in the middle is a wall of green.
"""
missing = [name for name in INSTANCE_SHAPED if name not in _ANSWERS]
if missing: # pragma: no cover - the structural test below is what keeps this true
msg = f"no answer on this instance for: {missing}"
raise NotImplementedError(msg)
standing = [_ANSWERS[name](session, settings) for name in INSTANCE_SHAPED]
return sorted(standing, key=lambda one: one.state is ON)
Loading
Loading