Skip to content

Commit 841393b

Browse files
Add a live trace view to serve, and a file-backed plan approval flow (#39)
The server grows an optional read-only /live view (grapharc serve --live-root PATH, token-gated via --live-token / GRAPHARC_LIVE_TOKEN) that lists trace files and streams a run's events over SSE with a mermaid topology render. Slack gains the same live link plumbing, the planner gains a file-backed approval store with a grapharc approve command, and to_mermaid learns clustered multi-round topologies. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 99a5aca commit 841393b

46 files changed

Lines changed: 4724 additions & 118 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,8 @@ grapharc demo stage6 # memory: provenance, supersession, recall
157157
grapharc demo capstone # all of the above in one research agent
158158

159159
grapharc plan "look into the outage" # governed loop: propose -> admit -> execute -> replan
160+
grapharc plan "..." --approve # park each admitted round until a human answers
161+
grapharc approve <trace> # answer a parked run (--deny to refuse)
160162
grapharc run graph.json # a topology you wrote, through the same gate
161163
grapharc run graph.json --check-only # admission as a linter; executes nothing
162164

@@ -167,14 +169,14 @@ python -m grapharc.slack # the same commands from Slack (needs the `slac
167169
grapharc models # what a model spec resolves to
168170
grapharc trace <path> # pretty-print a run trace
169171
grapharc metrics <path> <run-id> # tokens, retries, termination reason, per-node counts
170-
grapharc viz <path> <run-id> # Mermaid diagram of the executed path
172+
grapharc viz <path> <run-id> # Mermaid diagram: the declared graph, execution status overlaid
171173
grapharc replay <path> <run-id> # reconstruct a run from its trace
172174
grapharc diff <path> <a> <b> # what changed between two runs
173175
```
174176

175-
Eleven commands, and every one of them takes `--json` — in JSON mode the failure is the document rather than a line on stderr. Exit codes are part of the interface: `0` did the job, `1` ran and the answer was negative (an agent stopped short, a run id had no events, two runs differed), `2` could not run at all.
177+
Twelve commands, and every one of them takes `--json` — in JSON mode the failure is the document rather than a line on stderr. Exit codes are part of the interface: `0` did the job, `1` ran and the answer was negative (an agent stopped short, a run id had no events, two runs differed), `2` could not run at all.
176178

177-
The Slack bot puts most of these commands one `/grapharc …` away from a phone, behind an allowlisting gate that keeps the default spend at zero — setup in [docs/cookbook/07-slack.md](docs/cookbook/07-slack.md), and a command-by-command session, refusals included, in [docs/cookbook/08-slack-walkthrough.md](docs/cookbook/08-slack-walkthrough.md).
179+
The Slack bot puts most of these commands one `/grapharc …` away from a phone, behind an allowlisting gate that keeps the default spend at zero — setup in [docs/cookbook/07-slack.md](docs/cookbook/07-slack.md), and a command-by-command session, refusals included, in [docs/cookbook/08-slack-walkthrough.md](docs/cookbook/08-slack-walkthrough.md). A tracing command run from Slack is narrated live — one status message edited in place as nodes run, with a refreshed diagram link — and `grapharc serve --live-root` adds a browser page that redraws the orchestration graph in real time over SSE.
178180

179181
The `run` stages use scripted models by default, so they cost nothing and produce the same trace every time. Add `--model` to run one against a real backend — that works for stage1 through stage6 and the capstone; stage0 is pure code with no model in it. `grapharc agent` is the exception: it needs a tool-calling backend and says so rather than degrading, because a scripted model has no `bind_tools` to drive a tool loop with.
180182

docs/architecture-review.md

Lines changed: 391 additions & 0 deletions
Large diffs are not rendered by default.

docs/cookbook/01-basics.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -799,6 +799,7 @@ for event in trace.read_events():
799799
Output:
800800

801801
```
802+
{'attempt': 1, 'graph': 'counter', 'node': 'topology', 'phase': 'topology', 'step': 0, 'state_delta': {'nodes': ['load', 'count'], 'edges': [['__start__', 'load', 'static'], ['load', 'count', 'static'], ['count', '__end__', 'static']]}}
802803
{'attempt': 1, 'graph': 'counter', 'node': 'load', 'phase': 'start', 'step': 1}
803804
{'attempt': 1, 'graph': 'counter', 'node': 'load', 'phase': 'end', 'step': 1, 'state_delta': {'items': ['a', 'b', 'c']}, 'tokens': 0}
804805
{'attempt': 1, 'graph': 'counter', 'node': 'count', 'phase': 'start', 'step': 2}
@@ -811,6 +812,12 @@ printout only because they differ every run.
811812

812813
So, by phase:
813814

815+
- **`topology`** is written once per entry, before any node runs: the graph's declared
816+
nodes and edges (conditional routes included, tagged by kind). It is what lets a
817+
diagram show the whole orchestration — branches not taken included — rather than
818+
only the path that happened to run. It carries `step: 0` on every attempt: it
819+
states shape, not order.
820+
814821
- **`start`** carries identity and nothing else: run, thread, attempt, graph, node,
815822
step, timestamp. It is written *before* the node body, so it exists even when the
816823
node never returns.
@@ -1035,16 +1042,20 @@ conn.close()
10351042
Output:
10361043

10371044
```
1045+
attempt 1 step 0 topology topology
10381046
attempt 1 step 1 fetch start
10391047
attempt 1 step 1 fetch end
10401048
attempt 1 step 2 save start
10411049
attempt 1 step 2 save error
1050+
attempt 2 step 0 topology topology
10421051
attempt 2 step 3 save start
10431052
attempt 2 step 3 save end
10441053
```
10451054

1046-
The resumed attempt starts at step 3 rather than restarting the numbering, and
1047-
`fetch` has no attempt-2 line because it did not re-run.
1055+
The resumed attempt's *work* starts at step 3 rather than restarting the numbering,
1056+
and `fetch` has no attempt-2 line because it did not re-run. Each attempt restates
1057+
the graph's topology at step 0 — shape, not order — which is why step comparisons
1058+
across attempts filter that phase out.
10481059

10491060
---
10501061

docs/cookbook/06-serving-and-ops.md

Lines changed: 87 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1068,8 +1068,8 @@ status : succeeded
10681068
answer : Budgets cap iterations, tokens and time.
10691069
usage : 1.0 iterations, 15.0 tokens
10701070
event : False recorded: this runtime does not deliver 'message' events into a running graph (ROADMAP §6.4 event queue / §6.5 approval node)
1071-
frames : ['event: trace', 'event: trace', 'event: status', 'event: done']
1072-
trace : ['start', 'end']
1071+
frames : ['event: trace', 'event: trace', 'event: trace', 'event: status', 'event: done']
1072+
trace : ['topology', 'start', 'end']
10731073
```
10741074

10751075
The routes:
@@ -1280,6 +1280,7 @@ $ curl -s localhost:8124/sessions/bf5ca55bff7b480f
12801280
}
12811281

12821282
$ curl -s localhost:8124/sessions/bf5ca55bff7b480f/trace
1283+
{"ts": "...", "run_id": "0d9dce7f61c4", "thread_id": "bf5ca55bff7b480f", "attempt": 1, "graph": "qa", "node": "topology", "phase": "topology", "step": 0, "state_delta": {"nodes": ["answer"], "edges": [["__start__", "answer", "static"], ["answer", "__end__", "static"]]}}
12831284
{"ts": "...", "run_id": "0d9dce7f61c4", "thread_id": "bf5ca55bff7b480f", "attempt": 1, "graph": "qa", "node": "answer", "phase": "start", "step": 1}
12841285
{"ts": "...", "run_id": "0d9dce7f61c4", "thread_id": "bf5ca55bff7b480f", "attempt": 1, "graph": "qa", "node": "answer", "phase": "end", "step": 1, "state_delta": {"answer": "Budgets cap iterations, tokens and time."}, "duration_ms": 1.0288769999533542, "tokens": 15}
12851286
```
@@ -1304,6 +1305,45 @@ could use, without contacting any provider.
13041305

13051306
---
13061307

1308+
## How do I watch a run live in a browser?
1309+
1310+
`grapharc serve --live-root PATH` mounts a read-only live view at `/live` over
1311+
the trace files under `PATH` — including files other processes are appending
1312+
right now. Traces are append-only JSONL written line-at-a-time under a lock,
1313+
so a reader that stops at the last complete newline (`TailRecorder`, in
1314+
`grapharc.observe.trace`) can follow a run another process is executing;
1315+
that is exactly what the view does.
1316+
1317+
`GET /live` lists every `*.jsonl` under the root, newest first.
1318+
`GET /live/view?trace=REL` is the page: it opens
1319+
`GET /live/api/stream?trace=REL` (server-sent events) and receives a fresh
1320+
`snapshot` — the run's Mermaid diagram, `metrics`-style numbers, cost, and
1321+
status — each time the file grows. The server recomputes the snapshot;
1322+
the page only renders it. Add `&run=ID` to pin one run in a file that holds
1323+
several; without it the view follows the newest.
1324+
1325+
This composes with the Slack bot, which gives every tracing command a trace
1326+
path under its working directory: run `grapharc serve --live-root` over that
1327+
same directory, set `GRAPHARC_SLACK_LIVE_URL`, and the bot posts a
1328+
"watch live" link when a run starts — the walkthrough is in
1329+
[07-slack.md](07-slack.md). It also composes with this page's own server
1330+
sessions: point `--live-root` at the session root and each
1331+
`<session>/trace.jsonl` gets a page.
1332+
1333+
The posture is the same as everything else in `grapharc.observe`: the view is
1334+
derived from the trace file and nothing else, and it is read-only. Requested
1335+
paths are confined inside the root (escapes are 404s), and `state_delta`
1336+
contents — arbitrary node writes — are never serialized into any live
1337+
response; the exposure is what `viz` already prints. The bind stays
1338+
`127.0.0.1` unless you say otherwise; binding wider prints a warning, because
1339+
reachability is meant to come from a tunnel or tailnet in front, optionally
1340+
with `--live-token TOKEN` (or `GRAPHARC_LIVE_TOKEN`) required on every
1341+
`/live` request. The diagram renders with mermaid.js from a pinned CDN; with
1342+
no CDN reachable the page falls back to the raw Mermaid source plus the same
1343+
mermaid.live fragment link the Slack bot posts.
1344+
1345+
---
1346+
13071347
## How do I reconstruct a run after it finished?
13081348

13091349
`replay(trace, run_id)`. It is a *reconstruction*, not a re-execution: it reads
@@ -1529,8 +1569,16 @@ total 17 tok complete: True
15291569
metrics : 2 nodes, 17 tokens, {'draft': 1, 'polish': 1}
15301570
15311571
flowchart TD
1572+
n0["draft"]
1573+
n1["polish"]
15321574
start((start)) --> n0["draft"]
15331575
n0["draft"] --> n1["polish"]
1576+
n1["polish"] --> fin((end))
1577+
classDef done fill:#d3f2d3,stroke:#2f7d32
1578+
classDef running fill:#fff3cd,stroke:#b8860b
1579+
classDef pending fill:#eeeeee,stroke:#999999,color:#666666
1580+
classDef errored fill:#f8d7da,stroke:#b02a37
1581+
class n0,n1 done
15341582
```
15351583

15361584
`RunCost.tokens` and `RunMetrics.tokens` agree by construction — both count the
@@ -1553,10 +1601,15 @@ a cost report and an audit trail that disagree are worse than either alone.
15531601
spend, reported as `tokens_before_error` — kept out of the total so the total
15541602
keeps matching `metrics`.
15551603

1556-
`to_mermaid` renders the *executed* path, keyed by `(node, step)`, so parallel
1557-
instances of a fan-out worker are distinct boxes rather than one box with a
1558-
self-loop the graph never had. Paste it into any Markdown renderer that speaks
1559-
Mermaid.
1604+
`to_mermaid` renders the graph's *declared topology* — the `topology` event every
1605+
run now writes — with execution status overlaid per node: `done`, `running`,
1606+
`errored`, or still `pending`. Branches not taken stay on the diagram in grey,
1607+
conditional routes draw dotted, and a multi-round planner run gets one cluster
1608+
per admitted round. A trace with no topology event (an `AgentNode` driven with
1609+
no enclosing graph, or a file written before the event existed) falls back to
1610+
the executed path in event order, keyed by `(node, step)` so parallel instances
1611+
of a fan-out worker are distinct boxes. Paste either form into any Markdown
1612+
renderer that speaks Mermaid.
15601613

15611614
`attribute_thread(trace, thread_id)` is the same for a whole session across
15621615
resumes, and `by_node(trace)` ranks every node in a file by cost.
@@ -1565,15 +1618,16 @@ resumes, and `by_node(trace)` ranks every node in a file by cost.
15651618

15661619
## The CLI tour
15671620

1568-
Eleven commands. Every one takes `--json`, which prints the same payload as one
1621+
Twelve commands. Every one takes `--json`, which prints the same payload as one
15691622
document on stdout — including failures, which become the document rather than a
15701623
line on stderr.
15711624

15721625
| Command | What it is for |
15731626
| --- | --- |
15741627
| `grapharc demo <example>` | run a built-in example graph (`stage0``stage6`, `capstone`) |
15751628
| `grapharc run <graph.json>` | run a topology you wrote, through the admission gate; `--check-only` lints it |
1576-
| `grapharc plan <goal>` | governed loop: propose → admit → execute → replan |
1629+
| `grapharc plan <goal>` | governed loop: propose → admit → execute → replan; `--approve` parks each admitted round for a human |
1630+
| `grapharc approve <trace>` | answer a plan run waiting on its approval gate (`--deny` to refuse) |
15771631
| `grapharc agent <task>` | run an agent node with the core tools against a task |
15781632
| `grapharc serve` | run the HTTP API |
15791633
| `grapharc models [spec]` | what a spec resolves to; `--check` probes this machine |
@@ -1606,12 +1660,12 @@ $ grapharc trace trace.jsonl --json | jq -r '.events[0].run_id'
16061660
2a47f18064b7
16071661

16081662
$ grapharc trace trace.jsonl --run-id 2a47f18064b7 | head -6
1663+
[ 0] topology topology Δ{'nodes': ['start', 'plan', 'act', 'verify', 'finish_target_met', 'finish_max_iterations', 'finish_no_progress'], 'edges': [['__start__', 'start', 'static'], ['start', 'plan', 'static'], ['plan', 'act', 'static'], ['act', 'verify', 'static'], ['finish_target_met', '__end__', 'static'], ['finish_max_iterations', '__end__', 'static'], ['finish_no_progress', '__end__', 'static'], ['verify', 'plan', 'conditional'], ['verify', 'finish_target_met', 'conditional'], ['verify', 'finish_max_iterations', 'conditional'], ['verify', 'finish_no_progress', 'conditional']]}
16091664
[ 1] start start
16101665
[ 1] start end Δ{'pending': ['budgets', 'verifier']}
16111666
[ 2] plan start
16121667
[ 2] plan end Δ{'proposal': 'budgets', 'round': 1}
16131668
[ 3] act start
1614-
[ 3] act end Δ{'candidate': 1}
16151669

16161670
$ grapharc metrics trace.jsonl 2a47f18064b7
16171671
run_id: 2a47f18064b7
@@ -1623,19 +1677,35 @@ duration_ms: 0.68
16231677
attempts: 1
16241678
termination_reason: target_met
16251679
per_node: {'start': 1, 'plan': 2, 'act': 2, 'verify': 2, 'finish_target_met': 1}
1626-
events: 16
1627-
per_phase: {'start': 8, 'end': 8}
1680+
events: 17
1681+
per_phase: {'topology': 1, 'start': 8, 'end': 8}
16281682

16291683
$ grapharc viz trace.jsonl 2a47f18064b7
16301684
flowchart TD
1685+
n0["start"]
1686+
n1["plan"]
1687+
n2["act"]
1688+
n3["verify"]
1689+
n4["finish_target_met"]
1690+
n5["finish_max_iterations"]
1691+
n6["finish_no_progress"]
16311692
start((start)) --> n0["start"]
16321693
n0["start"] --> n1["plan"]
16331694
n1["plan"] --> n2["act"]
16341695
n2["act"] --> n3["verify"]
1635-
n3["verify"] --> n4["plan"]
1636-
n4["plan"] --> n5["act"]
1637-
n5["act"] --> n6["verify"]
1638-
n6["verify"] --> n7["finish_target_met"]
1696+
n4["finish_target_met"] --> fin((end))
1697+
n5["finish_max_iterations"] --> fin((end))
1698+
n6["finish_no_progress"] --> fin((end))
1699+
n3["verify"] -.-> n1["plan"]
1700+
n3["verify"] -.-> n4["finish_target_met"]
1701+
n3["verify"] -.-> n5["finish_max_iterations"]
1702+
n3["verify"] -.-> n6["finish_no_progress"]
1703+
classDef done fill:#d3f2d3,stroke:#2f7d32
1704+
classDef running fill:#fff3cd,stroke:#b8860b
1705+
classDef pending fill:#eeeeee,stroke:#999999,color:#666666
1706+
classDef errored fill:#f8d7da,stroke:#b02a37
1707+
class n0,n1,n2,n3,n4 done
1708+
class n5,n6 pending
16391709

16401710
$ grapharc replay trace.jsonl 2a47f18064b7 | tail -4
16411711
pending = []
@@ -1677,8 +1747,9 @@ $ grapharc metrics trace.jsonl 2a47f18064b7 --json
16771747
"verify": 2,
16781748
"finish_target_met": 1
16791749
},
1680-
"events": 16,
1750+
"events": 17,
16811751
"per_phase": {
1752+
"topology": 1,
16821753
"start": 8,
16831754
"end": 8
16841755
}

docs/cookbook/07-slack.md

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,12 +109,80 @@ Configuration is environment-only, read once at startup:
109109
| `GRAPHARC_SLACK_ALLOW_MODEL` | off | `1` admits `--model`/`--reviewer-model` |
110110
| `GRAPHARC_SLACK_ALLOW_AGENT` | off | `1` admits `agent` — only together with `ALLOW_MODEL` |
111111
| `GRAPHARC_SLACK_COMMAND` | `/grapharc` | the slash command to answer to |
112+
| `GRAPHARC_SLACK_LIVE` | on | `0` turns off the live-edited status message |
113+
| `GRAPHARC_SLACK_LIVE_INTERVAL` | `2.5` | seconds between two edits of the status message |
114+
| `GRAPHARC_SLACK_LIVE_URL` | unset | base URL of a `grapharc serve --live-root` the requester can reach; posts a "watch live" link |
112115

113116
The bot reads tokens from the process environment only. The `.env`
114117
upward-directory search that the model gateway performs is deliberately not
115118
used here: a bot that a whole workspace can drive must not discover
116119
credentials in a file the operator did not point it at.
117120

121+
## Live progress
122+
123+
A command that traces (`demo`, `run`, `plan`, `agent`) is narrated while it
124+
runs. The gate gives every such command a trace path the bot knows — a unique
125+
`slack-runs/<stamp>/trace.jsonl` under the working directory, unless the
126+
request named its own `--trace` — and the bot tails that file from a side
127+
thread while the subprocess runs. What you see in Slack is one status message,
128+
edited in place every couple of seconds:
129+
130+
```
131+
`grapharc run pipeline.toml --trace slack-runs/…/trace.jsonl` — running (14s)
132+
✓ ingest 312ms
133+
✓ extract 1.8s 1543 tok
134+
✗ verify err: citation not found
135+
▸ report running…
136+
6 events · 2/4 nodes done · 1543 tok
137+
<current diagram>
138+
```
139+
140+
The `current diagram` link is the same mermaid.live fragment URL `viz` gets —
141+
the diagram is compressed into the URL itself and shipped to no one — and it is
142+
refreshed on every edit, so mid-run it renders the path *so far*. When the
143+
command finishes, the status message is edited one last time into the same
144+
final result the bot has always posted.
145+
146+
Everything about this path is best-effort by construction. If the bot cannot
147+
post the status message (it is not in the channel, the API errored), the whole
148+
live layer steps aside and you get today's single blocking reply; if a mid-run
149+
edit fails, the narration goes quiet; and if the *final* edit fails, the result
150+
is posted as an ordinary reply instead. A broken live view can cost you the
151+
narration, never the answer. One visibility note: for a slash command the
152+
status message is posted to the channel (a `respond()`-style reply would allow
153+
only five updates), so a live run is visible to everyone in it — the mention
154+
path threads it under your message as before.
155+
156+
Because the trace now lands inside the working directory, the run is also
157+
inspectable afterwards from Slack itself: `/grapharc metrics
158+
slack-runs/<stamp>/trace.jsonl <run-id>`, `viz` for the finished diagram,
159+
`replay` for the reconstruction. The `slack-runs/` directories are the audit
160+
trail and are never cleaned up automatically; prune them like any other logs.
161+
162+
## Watching it live in a browser
163+
164+
The status message is text. For the actual diagram redrawing itself as nodes
165+
run, pair the bot with the live view server on the same machine:
166+
167+
```bash
168+
grapharc serve --live-root "$GRAPHARC_SLACK_WORKDIR" --port 8300
169+
export GRAPHARC_SLACK_LIVE_URL=https://laptop.tailnet.ts.net:8300
170+
python -m grapharc.slack
171+
```
172+
173+
With the URL configured, the bot's first status message includes
174+
`watch live: <url>/live/view?trace=slack-runs/…` — a page that renders the
175+
Mermaid diagram and the run's numbers and updates itself over SSE as the trace
176+
file grows. `/live` lists every trace under the root. The server is read-only,
177+
confines every requested path inside the root, and never serves `state_delta`
178+
contents — what the page shows is what `viz` and `metrics` already show.
179+
180+
Reachability is deliberately your problem, not the bot's: the bot never opens
181+
a port (that is the whole point of Socket Mode), and `serve` still binds
182+
loopback by default. Put a tailnet or tunnel (Tailscale, cloudflared) in front
183+
for the person on the phone, and add `--live-token` if the URL is guessable.
184+
Details in [06-serving-and-ops.md](06-serving-and-ops.md).
185+
118186
## A `plan` that reads
119187

120188
The default planning registry is the incident-response demo: its node bodies
@@ -189,9 +257,9 @@ stays unreachable from Slack.
189257
- **The bot is alive while the process is.** Laptop lid closed means commands
190258
from a phone go unanswered — Slack shows the slash command timing out, and
191259
nothing queues. The same script runs unchanged on any always-on box.
192-
- **Slack's three-second ack.** The bot acks immediately ("running …") and
193-
posts the result when the command finishes; the timeout bounds how long
194-
that can be.
260+
- **Slack's three-second ack.** The bot acks immediately ("running …"), then
261+
narrates a tracing command through the live status message and lands the
262+
result there when it finishes; the timeout bounds how long that can be.
195263
- **The workspace is the trust boundary.** The gate stops path escapes,
196264
module imports and spend, but anyone in the workspace can run every allowed
197265
command against every file in the working directory. Give the bot a

0 commit comments

Comments
 (0)