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
111 changes: 111 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@

### Added

- **What each of a part's containers uses.** The ontology's answer for a
part says, per container, its memory against its limit, CPU, state and
restarts, as its Overseer page last sampled them, so Illiana can say
which worker is using the memory. A part's health check carries the
same.

- **What a queue runs.** The worker's health check names each queue's
tasks beside its concurrency and timeout, the same for every worker
backend.

- **Who owns a meeting nobody pressed send for.** With auth, an agent
has an optional default owner: a meeting it joins from its calendar
that nobody on the invite owns goes to them. A meeting nobody owns is
Expand All @@ -27,6 +37,30 @@
map, flow card or ontology read no longer stalls the server for
seconds while it parses the source.

- **One model list, by modality.** The chat's model picker narrows to
All, Text or Voice, and what a live call runs on sits under its own
vendor, marked Voice with its price, instead of a group apart at the foot
of the list; the model a call runs on leads beside the chat model's,
both checked. Picking one still sets the active voice's engine.

- **A path card follows what Illiana names.** Each part a path card
draws is named by its map title (Authentication) or its own name
(auth). In a settled answer every such name is marked: hover or tap it
and the card lights that part and its lines, still, the rest stepping
back. On a live call she draws a card as she would in a typed answer
(the call manners now say they can see the screen), and the card she
drew on that call lights the part she named last as you hear it (timed
to the audio queued ahead, not to when the words arrive); her words
reach the page as she speaks (`saying`). An answer's Listen button
plays it back with its card following along. Names only
match parts the card draws; nothing is marked up in what she writes.
A code path card stops where a loop over a registry fans out (every
part's health check, read each turn): the walk marks each hop it
reached through one (`through`) and each step only those make
(`fanned`); the card leaves those out, while the `ontology` tool's
route answer still has every hop. The tool also leaves out a field at
its default, so a call that waits no longer says `sync`.

- **Illiana draws flows.** A flow plays across the map under her answer,
with the Replay page's own player and steps: a request's replay
(`request_id`) at its real timing, a route's code path (`route`: what
Expand Down Expand Up @@ -328,6 +362,83 @@

### Fixed

- **The card keeps lighting until she stops talking.** A call saves her
answer when it is written, before it has all been heard; the card she
drew moved into the saved answer and stopped following her. It is now
followed wherever it is, by its own address, until the next question.

- **A job's wiring is its part's.** A job has no box on the map, so its
wiring card drew the part that runs it alone; it now draws the lines
that part is joined by.

- **A card with no rows is not drawn.** Drawn before its lookup came
back, a table showed "Nothing here yet" under an answer naming six
jobs; `draw_card` now refuses it and says to draw from what the lookup
returned.

- **A card drawn again takes its place.** On a call, a chart she drew a
second time for one answer showed twice until the turn was saved; it
now replaces the first, as the saved answer does.

- **A bar chart measures from zero.** Its axis started at the lowest
value, so two workers at 190 and 199 MB drew one bar as nothing.

- **A chart of nothing is not drawn.** An idle worker's task counts came
back as a bar chart of zeros; a chart whose every value is 0 is
refused, and said in words.

- **A call's card stays with its turn.** A turn that ended with nothing
said lost track of the cards, so the next one she drew showed under
the following answer instead of its own.

- **A call ends on a goodbye, not a noise.** A turn with no words in it
(a noise, her own voice heard back) read as "that's all", and she said
goodbye and hung up.

- **A card stays up while she talks about it.** A call reloads the
thread as each turn is saved, mid-sentence, and every card in it was
drawn again; the card already up is now moved into place.

- **A job's next run says its time zone.** It was read back from the
scheduler's store as a local time with no zone, so a reader could not
tell it was UTC; it is UTC, and says so.

- **Illiana sees the scheduler's jobs.** A component's details are cut to
what fits a prompt; the cut (500 characters a detail) dropped short
lists too, such as the scheduler's jobs and when each runs next. It now
keeps up to 2,000, a few dozen short rows; a schema is still left out.

- **A code path plays smoothly.** Read function by function, a code path
folds onto a few parts on the map, and it relit the same line over and
over (Auth to Database, once a table). A hop through parts an earlier
one lit now joins it, its table named there, and the rest play a beat
apart.

- **Cards stay put on a call.** Each saved turn reloads the thread; its
cards no longer load again (a flash of "Loading the chart..." each
time): a drawn card's markup is kept by its address and put straight
back.

- **A call shows wherever the chat is.** Leaving the Chat page mid-call
kept the call talking with nothing on screen to show it, and the
drawer's phone then read idle, so pressing it hung up the call you
could hear. The chat that comes in next now shows the call as it
stands: phone pressed, the call bar with its time, engine and cost.

- **A route defined inside a function can be walked.** The dry path
names a handler written inside the function that adds it (every chat
surface's routes) and reads its source there, so a chat page's route
has a code path like any other.

- **Hot reloads leave no Stripe forwarder behind.** On Linux the
`stripe listen` the test-mode webhook forwarder starts now stops with
its server; a reload that timed out shutting down left one running
each time.

- **Chat follows a card to the bottom.** A settling answer no longer
drops the thread's hold on the newest message: its card reloading made
the page shorter for a moment, which read as the reader scrolling up.

- **A tool's result lands on its own call.** Calls to one tool made at
once finish in any order, and each result was attached to the latest
unanswered call of that name: the trail (Overseer's, and the chat's)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,12 @@
"""

import asyncio
import ctypes
import re
import shutil
import signal
import subprocess
import sys
from typing import Any

from app.core import secrets
Expand Down Expand Up @@ -63,6 +66,15 @@ def _stripe_listen_args(port: int, api_key: str) -> list[str]:


STRIPE_KEYS = ("STRIPE_SECRET_KEY", "STRIPE_WEBHOOK_SECRET")
PR_SET_PDEATHSIG = 1 # prctl(2): the signal a child gets when its parent dies


def _dies_with_server() -> None:
"""In the forked ``stripe listen``, before it runs: the kernel sends it
SIGTERM when the server process dies (Linux). A hot reload that times
out shutting down never runs the shutdown hook, and each one would
otherwise leave a forwarder behind."""
ctypes.CDLL(None, use_errno=True).prctl(PR_SET_PDEATHSIG, signal.SIGTERM)


def _should_auto_forward(keys: dict[str, str | None]) -> tuple[bool, str]:
Expand Down Expand Up @@ -206,6 +218,7 @@ async def startup_payment_webhook_forwarder() -> None:
stderr=subprocess.STDOUT,
text=True,
bufsize=1, # line-buffered — secret arrives as a single line
preexec_fn=_dies_with_server if sys.platform == "linux" else None,
)
except OSError as e:
logger.warning("Stripe webhook forwarder failed to launch: %s", e)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,14 @@
silent truncation. Needs the catalog, so offered with a persistence
backend only.

With voice, the same list picks what a live call runs on: a "Live calls"
group of the realtime models a call can reach, a pick making it the
active voice profile's engine (made on the spot when it has none).
With voice, the same list picks what a live call runs on: the realtime
models a call can reach sit under their vendor, marked Voice, a pick of
one making it the active voice profile's engine (made on the spot when
it has none); the list narrows to Text or Voice, the model a call runs on
leading beside the chat model's.
"""

import asyncio
from typing import Any

from app.core.model_picker import (
Expand All @@ -29,6 +32,9 @@
newest_first,
)

# The list's modalities, with voice: every model, the chat models, or what
# a live call can run on.
FILTERS = (("all", "All"), ("text", "Text"), ("voice", "Voice"))
# How many recently used models lead the list; read from a few more, as
# some may not be callable now.
RECENT = 3
Expand Down Expand Up @@ -172,14 +178,16 @@ def _row(
vendor in flat views that have no group around them."""
lab = lab_for_model(model)
vendor = str(model.get("vendor") or "")
voice = model.get("kind") == "realtime"
if under_vendor:
icon_for = lab if lab and lab.casefold() != under_vendor.casefold() else None
else:
icon_for = lab or vendor
facts = " · ".join(
part
for part in (
format_context_window(model.get("context_window")),
# A voice model's window is not what picks it; its price is.
None if voice else format_context_window(model.get("context_window")),
format_price(
model.get("input_price"),
model.get("output_price"),
Expand All @@ -191,6 +199,7 @@ def _row(
return {
"model_id": model["model_id"],
"kind": model.get("kind", "chat"),
"voice": voice,
"title": display_title(model, under_vendor=under_vendor),
"facts": facts,
"icon_name": icon_for,
Expand Down Expand Up @@ -251,40 +260,34 @@ def _recent(
]


def _calls(live: list[dict[str, Any]], icons: dict[str, str]) -> dict[str, Any]:
"""The live-call models, a group of their own: what a call runs on."""
return {
"name": "Live calls",
"icon_url": None,
"count": len(live),
"rows": [_row(m, under_vendor=None, icons=icons) for m in live],
"hidden": 0,
}


async def picker(
query: str, current: str, path: str, calls: bool = False
query: str, current: str, path: str, calls: bool = False, show: str = "all"
) -> dict[str, Any]:
"""The dialog's contents: grouped by vendor, or flat when searching,
with the recently used leading and the active model marked; with
``calls``, the live-call models too, the one calls run on marked.
``path`` is the picker's own URL on the chat mount it opened from."""
models = await catalog()
live = [{**m, "kind": "realtime"} for m in await call_models()] if calls else []
calling = await calling_model() if calls else None
every = models + live
``path`` is the picker's own URL on the chat mount it opened from;
``show`` narrows it to one modality (``FILTERS``), offered with calls."""
show = show if calls and show in dict(FILTERS) else "all"
# Three reads of their own, at once: the picker redraws on each key.
models, live, calling = await asyncio.gather(
catalog() if show in ("all", "text") else asyncio.sleep(0, []),
call_models() if calls and show in ("all", "voice") else asyncio.sleep(0, []),
calling_model() if calls else asyncio.sleep(0, None),
)
every = models + [{**m, "kind": "realtime"} for m in live]
icons = await vendor_icons(sorted({str(m.get("vendor") or "") for m in every}))
query = query.strip()
if query:
sections = _flat(every, query, icons)
else:
# A pick leads at once: it has no usage until it answers.
leading = [current, *await recent_ids()]
sections = _recent(models, leading, icons) + _grouped(models, icons)
if live:
sections = [_calls(live, icons), *sections]
leading = [current, *([calling] if calling else []), *await recent_ids()]
sections = _recent(every, leading, icons) + _grouped(every, icons)
return {
"query": query,
"show": show,
"filters": FILTERS if calls else (),
"active": {current, calling} - {None},
"sections": sections,
"empty": not any(section["rows"] for section in sections),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,11 @@
from markupsafe import Markup

from app.core.edges import SERVER
from app.core.ontology import graph
from app.core.ontology import PartNode, graph
from app.core.ontology.model import Edge, EdgeKind, State
from app.core.ontology.observed import running
from app.core.ownership import HOST
from app.services.system import kept_requests, topology
from app.services.system import kept_requests, staged_paths, topology
from app.services.system.kept_requests import RequestHop, RequestHops, RequestSummary
from app.services.system.ui import is_service

Expand Down Expand Up @@ -84,6 +84,7 @@ async def replay(
the same way after it, on a track of its own (on several, the way in
is not on the last; on the Server opened up, there is no second
track). ``scenes``: the first so many levels drawn (all when None)."""
recording = _folded(recording)
hops, span = _timed(recording)
parts = [_parts(hop) for hop in hops]
installed = {entry.name for entry in navigation["components"]}
Expand All @@ -107,15 +108,17 @@ async def replay(
lead = beat * len(way)
out = [*entry, *reversed(way)] if len(levels) == 1 and key != HOST else []
ways = [*lit, way + entry, *([out] if out else [])]
drawn = [await _scene(navigation, key, ways, 0.0, span, lead)]
# Staged (read from the code, or joined on the map), nothing was seen.
seen = frozenset() if recording.staged else None
drawn = [await _scene(navigation, key, ways, 0.0, span, lead, seen)]
found = _beats(key, [(None, way[0]), *pairwise(way)], 0.0, beat) if way else []
found += _steps(key, lead, hops, lit, titles, recording.staged)
if out:
answered = ANSWERED[answer_tone(recording.status)]
found += _beats(key, list(pairwise(out)), lead + span, beat, answered=answered)
for key, titles, lit in levels[1:scenes]:
at = drawn[-1]["at"] + drawn[-1]["lead"] + drawn[-1]["ms"]
drawn.append(await _scene(navigation, key, lit, at, span))
drawn.append(await _scene(navigation, key, lit, at, span, seen=seen))
found += _steps(key, at, hops, lit, titles, recording.staged)
return {
"replay": recording,
Expand Down Expand Up @@ -144,14 +147,21 @@ async def wiring(node_id: str) -> dict[str, Any]:
reach the cache), drawn on the Server's map as a replay draws a path,
so the card shows what she read; still, nothing to play."""
model = await running()
_, _, edges = graph.neighbors(model, node_id)
here, _, edges = graph.neighbors(model, node_id)
# Code the map has no box for (a job) runs as its part does: the
# part's lines are its own.
if here.owner and not isinstance(here, PartNode):
edges += graph.neighbors(model, here.owner)[2]
held = [e.target for e in edges if e.kind is EdgeKind.CONTAINS]
edges += [e for part in held for e in graph.neighbors(model, part)[2]]
pairs: dict[tuple[str, str], list[Edge]] = {}
for edge in edges:
pair = registry_of(edge.source), registry_of(edge.target)
pairs.setdefault(pair, []).append(edge)
ways = [[a, b] for a, b in pairs if a != b]
# A part joined to it both ways (seen calling it, and called) is drawn
# once, as its caller: above it, not tangled through what it calls.
key = registry_of(node_id)
ways = [[a, b] for a, b in pairs if a != b and not (a == key and (b, a) in pairs)]
# Seen only where no edge of it is declared: a line its code draws is not.
seen = frozenset(
p for p, found in pairs.items() if all(e.state in UNDECLARED for e in found)
Expand All @@ -160,7 +170,7 @@ async def wiring(node_id: str) -> dict[str, Any]:
return {
"path_steps": [],
"path_scenes": [scene],
"path_wired": title_of(registry_of(node_id)),
"path_wired": title_of(key),
}


Expand All @@ -186,6 +196,26 @@ def listed(recording: RequestHops) -> dict[str, Any]:
}


def _folded(recording: RequestHops) -> RequestHops:
"""A staged path as it plays: read function by function, it folds onto
a few parts on the map, so a hop through the parts an earlier one went
through is that one again; it joins it (its tables named there) rather
than lighting the same line again, and the rest play a beat apart. A
request that ran keeps every hop at its own time."""
if not recording.staged:
return recording
kept: dict[tuple[str, ...], RequestHop] = {}
for hop in recording.hops:
# What the map lights for it (the Server's own map draws no Server).
key = tuple(k for k in _parts(hop) if k != HOST)
if (first := kept.get(key)) is None:
kept[key] = hop
else:
tables = tuple(dict.fromkeys((*first.tables, *hop.tables)))
kept[key] = first.model_copy(update={"tables": tables})
return staged_paths.paced(recording, kept.values())


def _timed(recording: RequestHops) -> tuple[tuple[RequestHop, ...], float]:
"""Its hops, and how long it all ran."""
hops = recording.hops
Expand Down
Loading
Loading