Skip to content
Closed
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
3 changes: 3 additions & 0 deletions docs/reference/daemon.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,9 @@ the daemon answers it ([Routing](#routing)). `Daemon.asked(said)` sends one with
| `state` | string | `running`, `stopping` or `idle`. |
| `run` | int | The run going, else the one stopping, else the last; `0` for none. |
| `flow` | string | That run's flow, or `""`. |
| `epic`, `task` | string | That run's epic (its directory's name, `""` before it has one) and task, or `""`. |
| `pending` | list | `[{question, role, text, mode}]`: the questions waited on, as the [`pending` snapshot](#snapshots) says them. |
| `ended` | object \| null | How the last run to end here ended: `how` (`done`, `stopped`, `refused`, `budget`, `failed` or `crashed`), `why`, `epic`, `at` (Unix seconds), `run`, `flow`, `task`; `null` before one has. |
| `budget`, `usage` | object \| null | As JSON, for the run going; `null` while stopping or idle. An infinite cost is the JSON string `"Infinity"`. |
| `flows` † | list[string] | Canonical refs of the flow calls in progress. |
| `calls` † | list | `[{ref, name, depth, seconds, id, parent}]`, oldest-started first across every run in the process; `seconds` rounded to 0.001; `parent` the index of the calling entry, or `null`. |
Expand Down
24 changes: 24 additions & 0 deletions docs/reference/web.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,30 @@ Every `error` but the last is the runtime's own sentence, e.g.

An account is listed by the names of the variables it sets (`sets`), never by a value.

### Notices {#api-notices}

What happened to this machine's runs worth telling somebody about. They are worked out of each
workspace's host [`status`](/reference/daemon#status), asked every **2 s** without attaching to
any host, so a host goes as it would with nobody reading. Anything already standing when
`hmz web` starts is not news.

| Method and path | Takes | Answers |
| --- | --- | --- |
| `GET /api/notices` | `after` (an `id`; `0`, the default, for every one kept) | `notices` (those kept after it, oldest first; the last **200**), `last` (the newest `id`, `0` for none); `400` where `after` is not a whole number |
| `GET /api/notices/stream` | `Last-Event-ID` header, or `?last=` | `text/event-stream`: each `notice` event's `id` is its `id` and its data the notice, from after the one named (without one, from now on), then each as it comes; `: quiet` after **15 s** of nothing |

A notice is `id` (a whole number, larger than any before it, on this machine too after
`hmz web` starts again), `kind`, `workspace`, `epic`, `flow`, `task`, `role`, `text` and `at`
(Unix seconds):

| `kind` | When | `role`, `text` |
| --- | --- | --- |
| `done` | An epic ended done | `""`, `""` |
| `failed` | An epic ended failed, crashed, refused or over its budget | `""`, why |
| `stopped` | An epic was stopped | `""`, `""` |
| `asked` | A flow asks a person a question (mode `ask`) | The role asking, the question |
| `listening` | A flow waits for what to say next: a chat between turns (mode `listen`) | The role asking, what it asks |

## The stream {#stream}

`GET /api/held/stream` answers `text/event-stream`, starting with `retry: 2000`. Each event is
Expand Down
36 changes: 34 additions & 2 deletions src/hmz/runtime/doing/hosting.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,12 @@ def _json(model: Any) -> Any:
return json.loads(model.model_dump_json())


def _epic_of(shown: _Run | None) -> str:
"""The name of the epic a run is written into, or "" before it has one."""
epic = shown.run.epic if shown is not None else None
return epic.name if epic is not None else ""


def _spoken(given: Mapping[str, Any]) -> dict[str, str]:
"""What each role was given, as the words `-a` or `-e` would spell it."""
return {
Expand Down Expand Up @@ -347,6 +353,8 @@ def __init__(self, hmz: Hmz) -> None:
self._current: _Run | None = None
self._stopping: _Run | None = None
self._last: _Run | None = None
#: How the last run to end here ended, for whoever asks from outside the runs.
self._ended: dict[str, Any] | None = None
self._runs = 0
self._starting = False
self._questions: list[_Question] = []
Expand Down Expand Up @@ -569,21 +577,36 @@ def status(self) -> dict[str, Any]:

Returns:
How many frontends are attached and who, the state and number of the run, the
flow, what it may spend and has spent -- both None with nothing running -- and the
flows running, as refs and as calls.
flow, its epic and task, what it may spend and has spent -- both None with nothing
running -- the questions it waits on, how the last run to end ended, and the flows
running, as refs and as calls.
"""
with self._lock:
running = self._current
shown = running or self._stopping or self._last
clients = self._clients_of()
state = self._state()
pending = [
{
"question": one.id,
"role": one.role,
"text": one.text,
"mode": one.mode,
}
for one in self._questions
]
ended = dict(self._ended) if self._ended is not None else None
calls = self._calls(now=time.monotonic())
return {
"attached": len(clients),
"clients": clients,
"state": state,
"run": shown.number if shown is not None else 0,
"flow": shown.run.flow if shown is not None else "",
"epic": _epic_of(shown),
"task": shown.started.get("task", "") if shown is not None else "",
"pending": pending,
"ended": ended,
"budget": _json(running.run.budget) if running is not None else None,
"usage": _json(running.run.usage) if running is not None else None,
"flows": [one["ref"] for one in calls],
Expand Down Expand Up @@ -1030,6 +1053,15 @@ def _drives(self, current: _Run) -> None:
# arrive is the one it ended for.
if not self._frontends and not current.stopped_by:
self._keeps = True
self._ended = {
"how": how,
"why": why,
"epic": _epic_of(current),
"at": time.time(),
"run": current.number,
"flow": current.run.flow,
"task": current.started.get("task", ""),
}
self._record(
{
"type": "ended",
Expand Down
8 changes: 7 additions & 1 deletion src/hmz/web/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ def listening(*, port: int = 0, apart: bool = True) -> Generator[Site]:

from hmz.coganchor import releases

from .notices import Notices
from .places import Places
from .runs import Runs
from .site import Site
Expand All @@ -67,14 +68,19 @@ def listening(*, port: int = 0, apart: bool = True) -> Generator[Site]:
with contextlib.ExitStack() as held_open:
places = Places(Path.cwd(), apart=apart)
held_open.callback(places.close)
site = Site(port, places=places, runs=Runs(places))
notices = Notices(places.statuses)
site = Site(port, places=places, runs=Runs(places), notices=notices)
# What each index lists now, fetched once in the background as the interface starts.
fetches(places.hmz)
# And which release of humanize is the newest, where what is kept has got old.
releases.refresh()
held_open.callback(site.server_close)
# Set first as the block ends, which is what ends every stream being sent.
held_open.callback(site.going.set)
# And before it, what ends the notices' streams at once rather than once they wake.
held_open.callback(notices.close)
# What stands as it starts to answer is where the notices start from.
notices.start()
yield site


Expand Down
Loading
Loading