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
2 changes: 1 addition & 1 deletion docs/reference/tui.md
Original file line number Diff line number Diff line change
Expand Up @@ -1079,7 +1079,7 @@ duration written as the sheet reopens it (e.g. `stops at 6h, $50.00`; `stops at
| --- | --- |
| Title | `Epics` |
| About | `Every epic in this directory, newest first: task, status, and session count.`; on this machine, `Every epic on this machine, newest first: workspace, task, status, and session count.` |
| Rows | `YYYY-MM-DD HH:MM · <flow>`, about `[<workspace> · ]<task (≤ 60 chars)\|no task> · <n> session(s)[ · failed\|stopped\|was left unfinished][ · resumable]`. `<workspace>`, with `~` for the home directory, only on this machine. `resumable`: the run is of this directory, its flow is resumable now and it left a journal. A run in progress reads `was left unfinished`. |
| Rows | `YYYY-MM-DD HH:MM · <flow>`, about `[<workspace> · ]<task (≤ 60 chars)\|no task> · <n> session(s)[ · failed\|stopped\|is still going\|was left unfinished][ · resumable]`. `<workspace>`, with `~` for the home directory, only on this machine. `resumable`: the run is of this directory, its flow is resumable now and it left a journal. A run in progress -- here, under a host, or in `hmz exec` -- reads `is still going`; `was left unfinished` is one whose process went before it ended. |
| Buttons | **Search…** (flow, task, run name, and workspace on this machine); **this machine** / **this directory**, as `ctrl+t`; **Import…**, as `i` |
| Keys | `enter open ctrl+t this machine\|this directory i import / search tab actions esc close` |
| Empty | `no flow has been run in this directory yet` / `no flow has been run on this machine yet`; `reading…` while a machine's are read |
Expand Down
7 changes: 4 additions & 3 deletions docs/reference/web.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,9 +201,10 @@ asks one, through the same side conversations: a second question to one still an
| `GET /api/usage` | `days` (`30`; 1 to 365); `workspace` (only its runs) | `days` (per UTC day, oldest first), `flows` (costliest first), `ended` (`{how: n}`), `whole`: each with `runs`, `cost`, `output_tokens`, `seconds`, and `money`, `tokens`, `worked` as the terminal interface says them |

A row is `name`, `flow`, `task` (its first line, cut at 240 characters), `began`, `ended`,
`how` (as the run ended; `running` for the run going, `unfinished` for one that never said),
`agents` (`[{role, runs}]`), `sessions` (a count), `resumable`, `held` (whether it is the run
going), `spent`: what its budget counted as it ended, or `null`, `backup` (the targets it was
`how` (as the run ended; `running` for a run going -- held by a host, or by another process such
as `hmz exec` -- and `unfinished` for one whose process went before it said),
`agents` (`[{role, runs}]`), `sessions` (a count), `resumable`, `held` (whether it is the run a
host is going with, which the page follows live and can stop), `spent`: what its budget counted as it ended, or `null`, `backup` (the targets it was
started with) and `backed` (`{target: {at, ok, error, why}}`: how its last backup to each
went). A run of no such name is `404`.

Expand Down
2 changes: 1 addition & 1 deletion docs/user/web.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ lists its chats, and an epic opens out into its agents and the sessions each ope

| Where | What it shows | What you can do there |
| --- | --- | --- |
| **Sidebar** | **New epic**, **Search epics**, **Flows**, then a folder per directory -- every one an epic ran in or that was opened here -- with its epics newest first: how each ended, or `live` | Open an epic; press its arrow to open it out into its agents, and each agent into its sessions; **+** on a folder starts an epic there |
| **Sidebar** | **New epic**, **Search epics**, **Flows**, then a folder per directory -- every one an epic ran in or that was opened here -- with its epics newest first: how each ended, or `live`; `●` without `live` is one another process goes with, such as `hmz exec`, read as it writes itself down | Open an epic; press its arrow to open it out into its agents, and each agent into its sessions; **+** on a folder starts an epic there |
| **New epic** | A box for the task, the directory and the flow picked at the top as a chat app picks a model | Start it; **Options** on the box sets the rest up. See [Starting an epic](#starting-an-epic) |
| **An epic** | Its graph: who handed to whom -- a `chat` opens on its conversation instead | Read it, steer it while it goes, export it, pick it up. See [An epic](#an-epic) |
| **A session** | Its thread, as the terminal draws it | Say a line into it, or answer what it asks, while the epic goes |
Expand Down
1 change: 1 addition & 0 deletions specs/runtime/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ def where(epic: Path, session: Session) -> Path: ...
def logs(epic: Path, session: Session) -> dict[str, Path]: ...
def picks_up(epic: Path) -> bool: ...
def foreign(epic: Path) -> str: ... # why an imported run cannot be picked up here, or ""
def going(epic: Path) -> bool: ... # whether its run is held now, by this process or another
def state(epic: Path, flow: str = "") -> dict[str, Any]: ... # flow by canonical ref
def resumed(flow: str, workspace: Path | str | None = None) -> Path | None: ...
BACKED = "backed" # the line saying how one backup of an epic went
Expand Down
1 change: 1 addition & 0 deletions specs/runtime/doing.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ class Epics:
def resumed(self, flow: str) -> Path | None: ...
def picks_up(self, epic: Path) -> bool: ...
def foreign(self, epic: Path) -> str: ...
def going(self, epic: Path) -> bool: ...
def traced(
self,
epic: Path,
Expand Down
6 changes: 6 additions & 0 deletions src/hmz/runtime/doing/epics.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,12 @@ def foreign(self, epic: Path) -> str:

return foreign(epic)

def going(self, epic: Path) -> bool:
"""Whether one epic's run is still going, in this process or any other."""
from hmz.runtime.epic import going

return going(epic)

def state(self, epic: Path, flow: str = "") -> dict[str, Any]:
"""What a resumable flow kept in one run: the run's own flow, or one it called by ref."""
from hmz.runtime.epic import state
Expand Down
5 changes: 3 additions & 2 deletions src/hmz/runtime/epic.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
"called",
"epics",
"foreign",
"going",
"harbor",
"harnessed",
"logs",
Expand Down Expand Up @@ -487,7 +488,7 @@ def picks_up(epic: Path) -> bool:
and for one imported from a workspace this machine has not got (:func:`foreign`).
"""
return (
not _going(epic)
not going(epic)
and any(one.get("t") == "call" for one in _journal(epic))
and not foreign(epic)
)
Expand Down Expand Up @@ -586,7 +587,7 @@ def _hold(at: Path) -> int | None:
return held


def _going(epic: Path) -> bool:
def going(epic: Path) -> bool:
"""Whether the run one epic is of is still going, here or in any other process.

Args:
Expand Down
7 changes: 5 additions & 2 deletions src/hmz/tui/pick.py
Original file line number Diff line number Diff line change
Expand Up @@ -10041,9 +10041,12 @@ def _how(ran: Ran) -> str:
ran: The run.

Returns:
What became of it, in words: a run with no end written down is one that was abandoned
where it stood -- the machine it was on went, or the interface came down under it.
What became of it, in words: a run with no end written down is still going where a
process holds it -- this one, a host, an `hmz exec` -- and otherwise one that was
abandoned where it stood: the machine it was on went, or the interface came down under it.
"""
if not ran.how and _hmz().epics.going(ran.at):
return "is still going"
return {
"done": "finished",
"failed": "failed",
Expand Down
43 changes: 33 additions & 10 deletions src/hmz/web/runs.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,16 @@ def named(self, name: str) -> Ran:
return ran
raise Refusal(f"There is no epic {name}.", 404)

def going(self, ran: Ran) -> bool:
"""Whether a run with no end written down is still going, in whatever process holds it.

The host says which run it holds; a run another process holds -- `hmz exec`, a
terminal interface holding its runs itself -- says so only by holding its epic.
"""
from hmz.runtime.epic import going

return not ran.how and going(ran.at)

def hmz(self, ran: Ran) -> Any:
"""humanize, for the workspace a run was run in -- there still or not."""
return self._places.of(ran.workspace or str(self._places.here), made=True).hmz
Expand Down Expand Up @@ -307,8 +317,14 @@ def held_by(runs: list[Ran], running: dict[str, dict[str, Any]]) -> set[Path]:
return held


def row(ran: Ran, *, held: bool) -> dict[str, Any]:
"""One run as the list says it."""
def row(ran: Ran, *, held: bool, going: bool = False) -> dict[str, Any]:
"""One run as the list says it.

Args:
ran: The run.
held: Whether it is the run a host is going with, which the page follows live.
going: Whether it is going at all, held by a host or by any other process.
"""
first = ran.task.strip().splitlines()[0] if ran.task.strip() else ""
return {
"name": ran.name,
Expand All @@ -317,7 +333,7 @@ def row(ran: Ran, *, held: bool) -> dict[str, Any]:
"task": first[:_TOLD] + ("…" if len(first) > _TOLD else ""),
"began": ran.began,
"ended": ran.ended,
"how": ran.how or (RUNNING if held else UNFINISHED),
"how": ran.how or (RUNNING if held or going else UNFINISHED),
"agents": [{"role": one.agent, "runs": one.spec} for one in ran.agents],
"sessions": len(ran.sessions),
"resumable": ran.resumable,
Expand Down Expand Up @@ -346,17 +362,18 @@ def labelled(spent: dict[str, Any] | None) -> dict[str, Any] | None:


def detail(
ran: Ran, *, held: bool, picks_up: bool, foreign: str = ""
ran: Ran, *, held: bool, picks_up: bool, foreign: str = "", going: bool = False
) -> dict[str, Any]:
"""One run, whole, as its own page says it.

Args:
ran: The run.
held: Whether it is the run going.
held: Whether it is the run a host is going with.
going: Whether it is going at all, held by a host or by any other process.
picks_up: Whether it can be picked up.
foreign: Why one imported cannot be picked up here, or "".
"""
return row(ran, held=held) | {
return row(ran, held=held, going=going) | {
"at": str(ran.at),
"task": ran.task,
"ref": ran.ref,
Expand Down Expand Up @@ -555,7 +572,9 @@ def _listed(asked: Asked) -> dict[str, Any]:
status = asked.query.get("status", "")
flow = asked.query.get("flow", "")
wanted = asked.query.get("q", "").casefold()
rows = [row(ran, held=ran.at in held) for ran in runs]
rows = [
row(ran, held=ran.at in held, going=asked.site.runs.going(ran)) for ran in runs
]
counts: dict[str, int] = {}
for one in rows:
counts[one["how"]] = counts.get(one["how"], 0) + 1
Expand Down Expand Up @@ -593,9 +612,13 @@ def _one(asked: Asked) -> dict[str, Any]:
hmz = runs.hmz(ran)
picks_up = hmz.epics.picks_up(ran.at)
foreign = hmz.epics.foreign(ran.at)
return detail(ran, held=ran.at in held, picks_up=picks_up, foreign=foreign) | {
"calls": calls(hmz.epics.tree(ran.at))
}
return detail(
ran,
held=ran.at in held,
picks_up=picks_up,
foreign=foreign,
going=runs.going(ran),
) | {"calls": calls(hmz.epics.tree(ran.at))}


def _trace(asked: Asked) -> dict[str, Any]:
Expand Down
6 changes: 4 additions & 2 deletions src/hmz/web/static/sidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,9 @@ export function mountSidebar(root, context, where) {
/** What can be done to one epic, from wherever it is listed. */
export function epicMenu(run) {
const base = `#/epics/${encodeURIComponent(run.name)}`
const resumes = run.resumable && !run.held && run.how !== 'done'
// Going, whether a host holds it or another process does -- `hmz exec`, say.
const going = run.held || run.how === 'running'
const resumes = run.resumable && !going && run.how !== 'done'
return [
{ head: run.task.split('\n')[0] },
{ label: 'Open', glyph: 'open', href: base },
Expand All @@ -330,7 +332,7 @@ export function epicMenu(run) {
{ label: 'Copy link', glyph: 'link', act: () => copy(`${location.origin}/${base}`, 'Link copied') },
{ label: 'Copy task', glyph: 'copy', act: () => copy(run.task, 'Task copied') },
{ label: 'Copy name', glyph: 'copy', act: () => copy(run.name, 'Name copied') },
run.held ? null : { label: 'Export', glyph: 'download', act: () => (location.href = `/api/runs/${encodeURIComponent(run.name)}/bundle`) },
going ? null : { label: 'Export', glyph: 'download', act: () => (location.href = `/api/runs/${encodeURIComponent(run.name)}/bundle`) },
{ label: 'Upload', glyph: 'up', title: backedSaid(run.backed), act: () => upload(run.name) },
run.held ? '-' : null,
run.held ? { label: 'Stop', glyph: 'stop', danger: true, act: () => ask(run.workspace, 'stop').then(() => toast('Stopping'), (error) => toast(error.message)) } : null,
Expand Down
2 changes: 1 addition & 1 deletion src/hmz/web/static/views/search.js
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ export function mount(root, { query }) {
h('span', { class: 'mono' }, run.spent ? `${run.spent.money || '—'} · ${run.spent.tokens} out` : '—'),
run.ended ? h('span', { class: 'mono' }, `took ${lasting((Date.parse(run.ended) - Date.parse(run.began)) / 1000)}`) : null,
h('span', { class: 'mono' }, `${run.sessions} session${run.sessions === 1 ? '' : 's'}`),
run.resumable && !run.held && run.how !== 'done' ? h('span', { class: 'node-tag' }, 'Resumable') : null,
run.resumable && !run.held && run.how !== 'running' && run.how !== 'done' ? h('span', { class: 'node-tag' }, 'Resumable') : null,
),
), () => epicMenu(run)),
),
Expand Down
13 changes: 13 additions & 0 deletions tests/integration/test_tui_epics.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,19 @@ async def test_a_run_stopped_is_listed_with_its_task_and_how_it_ended(
assert rows(app) == [one.name for one in epics(workspace)]


async def test_a_run_another_process_holds_is_listed_as_still_going(
workspace: Path,
) -> None:
# Held as `hmz exec` holds the run it goes with, which no host here speaks for.
with Epic("chat", "elsewhere", workspace):
async with Humanize().run_test(size=SIZE) as pilot:
await opened(pilot)
await typed(pilot, "/epics")
await on(pilot, Epics)

await shows(pilot, "elsewhere · 0 sessions · is still going")


async def test_a_run_exported_from_epics_is_an_archive_where_it_was_asked_for(
workspace: Path,
) -> None:
Expand Down
19 changes: 19 additions & 0 deletions tests/integration/test_web_runs.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import pytest

from hmz import machine
from hmz.runtime.epic import Epic
from tests.integration.doubles_core import AGENT, install, started
from tests.integration.doubles_web import Browser, elsewhere, served, until

Expand Down Expand Up @@ -201,6 +202,24 @@ def _chatted(browser: Browser, task: str) -> None:
until(heard, _record("ended"), "the run ending")


def test_an_epic_another_process_goes_with_is_listed_as_running(project: Path) -> None:
# Held as `hmz exec` holds the run it goes with, which no host here speaks for.
with Epic("chat", "elsewhere", project) as held, served() as site:
browser = Browser(site)
browser.signs_in()

(ran,) = browser.get("/api/runs").json()["runs"]
assert (ran["how"], ran["held"]) == ("running", False)
one = browser.get(f"/api/runs/{held.path.name}").json()
assert one["how"] == "running"

with served() as site:
browser = Browser(site)
browser.signs_in()
(ran,) = browser.get("/api/runs").json()["runs"]
assert ran["how"] == "done"


def test_an_epic_written_down_is_read_back_out_of_the_logs_it_kept(
project: Path,
) -> None:
Expand Down
8 changes: 8 additions & 0 deletions tests/unit/runtime/doing/test_epics.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,14 @@ def test_the_runs_of_a_workspace_are_read_back(workspace: Path) -> None:
assert epics.state(second, "official/other") == {}


def test_a_run_reads_as_going_only_while_it_is_held(workspace: Path) -> None:
epics = Epics(workspace)
with Epic("ralph", "t", workspace) as held:
assert epics.going(held.path) is True

assert epics.going(held.path) is False


def test_every_run_on_this_machine_is_read_back_oldest_first(
workspace: Path, tmp_path: Path
) -> None:
Expand Down
8 changes: 8 additions & 0 deletions tests/unit/runtime/test_epic.py
Original file line number Diff line number Diff line change
Expand Up @@ -722,6 +722,14 @@ def test_a_run_still_going_cannot_be_picked_up(workspace: Path) -> None:
assert epic.picks_up(going.path) is True


def test_a_run_is_going_while_its_epic_is_held(workspace: Path) -> None:
with Epic("f", "t", workspace) as held:
assert epic.going(held.path) is True

assert epic.going(held.path) is False
assert epic.going(workspace / "never") is False


def test_what_a_flow_kept_is_read_back_key_by_key(tmp_path: Path) -> None:
_journal(
tmp_path / RESUME,
Expand Down
42 changes: 41 additions & 1 deletion tests/unit/web/test_runs.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import pytest

from hmz.web.runs import held_by, refused, sessions_of, spending
from hmz.web.runs import held_by, refused, row, sessions_of, spending

if TYPE_CHECKING:
from hmz.runtime.epic import Ran
Expand Down Expand Up @@ -174,3 +174,43 @@ def test_an_import_refused_is_a_conflict_naming_the_epic_only_where_it_is_here(
assert refusal.status == status
assert str(refusal) == f"{said}."
assert refusal.said.get("name") == named


@dataclass(frozen=True)
class _Row:
"""As much of a run written down as a row of the list reads."""

how: str = ""
name: str = "20261012T135159.936Z-bc3e81"
workspace: str = "/work/one"
flow: str = "ralph"
task: str = "fix it"
began: str = "2026-10-08T10:00:00+00:00"
ended: str = ""
agents: tuple[Any, ...] = ()
sessions: tuple[Any, ...] = ()
resumable: bool = False
spent: dict[str, Any] | None = None
backup: tuple[str, ...] = ()
backed: dict[str, Any] | None = None


@pytest.mark.parametrize(
("how", "held", "going", "said"),
[
("", True, True, "running"),
# Held by a process no host speaks for -- `hmz exec` -- and going all the same.
("", False, True, "running"),
("", False, False, "unfinished"),
("done", False, False, "done"),
],
)
def test_a_run_is_said_running_while_any_process_holds_it(
how: str, held: bool, going: bool, said: str
) -> None:
ran = cast("Ran", _Row(how=how))

listed = row(ran, held=held, going=going)

assert listed["how"] == said
assert listed["held"] is held
Loading