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
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,8 @@ grapharc demo stage6 # memory: provenance, supersession, recall
grapharc demo capstone # all of the above in one research agent

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

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

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.
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.

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).
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.

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.

Expand Down
391 changes: 391 additions & 0 deletions docs/architecture-review.md

Large diffs are not rendered by default.

15 changes: 13 additions & 2 deletions docs/cookbook/01-basics.md
Original file line number Diff line number Diff line change
Expand Up @@ -799,6 +799,7 @@ for event in trace.read_events():
Output:

```
{'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']]}}
{'attempt': 1, 'graph': 'counter', 'node': 'load', 'phase': 'start', 'step': 1}
{'attempt': 1, 'graph': 'counter', 'node': 'load', 'phase': 'end', 'step': 1, 'state_delta': {'items': ['a', 'b', 'c']}, 'tokens': 0}
{'attempt': 1, 'graph': 'counter', 'node': 'count', 'phase': 'start', 'step': 2}
Expand All @@ -811,6 +812,12 @@ printout only because they differ every run.

So, by phase:

- **`topology`** is written once per entry, before any node runs: the graph's declared
nodes and edges (conditional routes included, tagged by kind). It is what lets a
diagram show the whole orchestration — branches not taken included — rather than
only the path that happened to run. It carries `step: 0` on every attempt: it
states shape, not order.

- **`start`** carries identity and nothing else: run, thread, attempt, graph, node,
step, timestamp. It is written *before* the node body, so it exists even when the
node never returns.
Expand Down Expand Up @@ -1035,16 +1042,20 @@ conn.close()
Output:

```
attempt 1 step 0 topology topology
attempt 1 step 1 fetch start
attempt 1 step 1 fetch end
attempt 1 step 2 save start
attempt 1 step 2 save error
attempt 2 step 0 topology topology
attempt 2 step 3 save start
attempt 2 step 3 save end
```

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

---

Expand Down
103 changes: 87 additions & 16 deletions docs/cookbook/06-serving-and-ops.md
Original file line number Diff line number Diff line change
Expand Up @@ -1068,8 +1068,8 @@ status : succeeded
answer : Budgets cap iterations, tokens and time.
usage : 1.0 iterations, 15.0 tokens
event : False recorded: this runtime does not deliver 'message' events into a running graph (ROADMAP §6.4 event queue / §6.5 approval node)
frames : ['event: trace', 'event: trace', 'event: status', 'event: done']
trace : ['start', 'end']
frames : ['event: trace', 'event: trace', 'event: trace', 'event: status', 'event: done']
trace : ['topology', 'start', 'end']
```

The routes:
Expand Down Expand Up @@ -1280,6 +1280,7 @@ $ curl -s localhost:8124/sessions/bf5ca55bff7b480f
}

$ curl -s localhost:8124/sessions/bf5ca55bff7b480f/trace
{"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"]]}}
{"ts": "...", "run_id": "0d9dce7f61c4", "thread_id": "bf5ca55bff7b480f", "attempt": 1, "graph": "qa", "node": "answer", "phase": "start", "step": 1}
{"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}
```
Expand All @@ -1304,6 +1305,45 @@ could use, without contacting any provider.

---

## How do I watch a run live in a browser?

`grapharc serve --live-root PATH` mounts a read-only live view at `/live` over
the trace files under `PATH` — including files other processes are appending
right now. Traces are append-only JSONL written line-at-a-time under a lock,
so a reader that stops at the last complete newline (`TailRecorder`, in
`grapharc.observe.trace`) can follow a run another process is executing;
that is exactly what the view does.

`GET /live` lists every `*.jsonl` under the root, newest first.
`GET /live/view?trace=REL` is the page: it opens
`GET /live/api/stream?trace=REL` (server-sent events) and receives a fresh
`snapshot` — the run's Mermaid diagram, `metrics`-style numbers, cost, and
status — each time the file grows. The server recomputes the snapshot;
the page only renders it. Add `&run=ID` to pin one run in a file that holds
several; without it the view follows the newest.

This composes with the Slack bot, which gives every tracing command a trace
path under its working directory: run `grapharc serve --live-root` over that
same directory, set `GRAPHARC_SLACK_LIVE_URL`, and the bot posts a
"watch live" link when a run starts — the walkthrough is in
[07-slack.md](07-slack.md). It also composes with this page's own server
sessions: point `--live-root` at the session root and each
`<session>/trace.jsonl` gets a page.

The posture is the same as everything else in `grapharc.observe`: the view is
derived from the trace file and nothing else, and it is read-only. Requested
paths are confined inside the root (escapes are 404s), and `state_delta`
contents — arbitrary node writes — are never serialized into any live
response; the exposure is what `viz` already prints. The bind stays
`127.0.0.1` unless you say otherwise; binding wider prints a warning, because
reachability is meant to come from a tunnel or tailnet in front, optionally
with `--live-token TOKEN` (or `GRAPHARC_LIVE_TOKEN`) required on every
`/live` request. The diagram renders with mermaid.js from a pinned CDN; with
no CDN reachable the page falls back to the raw Mermaid source plus the same
mermaid.live fragment link the Slack bot posts.

---

## How do I reconstruct a run after it finished?

`replay(trace, run_id)`. It is a *reconstruction*, not a re-execution: it reads
Expand Down Expand Up @@ -1529,8 +1569,16 @@ total 17 tok complete: True
metrics : 2 nodes, 17 tokens, {'draft': 1, 'polish': 1}

flowchart TD
n0["draft"]
n1["polish"]
start((start)) --> n0["draft"]
n0["draft"] --> n1["polish"]
n1["polish"] --> fin((end))
classDef done fill:#d3f2d3,stroke:#2f7d32
classDef running fill:#fff3cd,stroke:#b8860b
classDef pending fill:#eeeeee,stroke:#999999,color:#666666
classDef errored fill:#f8d7da,stroke:#b02a37
class n0,n1 done
```

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

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

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

## The CLI tour

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

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

$ grapharc trace trace.jsonl --run-id 2a47f18064b7 | head -6
[ 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']]}
[ 1] start start
[ 1] start end Δ{'pending': ['budgets', 'verifier']}
[ 2] plan start
[ 2] plan end Δ{'proposal': 'budgets', 'round': 1}
[ 3] act start
[ 3] act end Δ{'candidate': 1}

$ grapharc metrics trace.jsonl 2a47f18064b7
run_id: 2a47f18064b7
Expand All @@ -1623,19 +1677,35 @@ duration_ms: 0.68
attempts: 1
termination_reason: target_met
per_node: {'start': 1, 'plan': 2, 'act': 2, 'verify': 2, 'finish_target_met': 1}
events: 16
per_phase: {'start': 8, 'end': 8}
events: 17
per_phase: {'topology': 1, 'start': 8, 'end': 8}

$ grapharc viz trace.jsonl 2a47f18064b7
flowchart TD
n0["start"]
n1["plan"]
n2["act"]
n3["verify"]
n4["finish_target_met"]
n5["finish_max_iterations"]
n6["finish_no_progress"]
start((start)) --> n0["start"]
n0["start"] --> n1["plan"]
n1["plan"] --> n2["act"]
n2["act"] --> n3["verify"]
n3["verify"] --> n4["plan"]
n4["plan"] --> n5["act"]
n5["act"] --> n6["verify"]
n6["verify"] --> n7["finish_target_met"]
n4["finish_target_met"] --> fin((end))
n5["finish_max_iterations"] --> fin((end))
n6["finish_no_progress"] --> fin((end))
n3["verify"] -.-> n1["plan"]
n3["verify"] -.-> n4["finish_target_met"]
n3["verify"] -.-> n5["finish_max_iterations"]
n3["verify"] -.-> n6["finish_no_progress"]
classDef done fill:#d3f2d3,stroke:#2f7d32
classDef running fill:#fff3cd,stroke:#b8860b
classDef pending fill:#eeeeee,stroke:#999999,color:#666666
classDef errored fill:#f8d7da,stroke:#b02a37
class n0,n1,n2,n3,n4 done
class n5,n6 pending

$ grapharc replay trace.jsonl 2a47f18064b7 | tail -4
pending = []
Expand Down Expand Up @@ -1677,8 +1747,9 @@ $ grapharc metrics trace.jsonl 2a47f18064b7 --json
"verify": 2,
"finish_target_met": 1
},
"events": 16,
"events": 17,
"per_phase": {
"topology": 1,
"start": 8,
"end": 8
}
Expand Down
74 changes: 71 additions & 3 deletions docs/cookbook/07-slack.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,12 +109,80 @@ Configuration is environment-only, read once at startup:
| `GRAPHARC_SLACK_ALLOW_MODEL` | off | `1` admits `--model`/`--reviewer-model` |
| `GRAPHARC_SLACK_ALLOW_AGENT` | off | `1` admits `agent` — only together with `ALLOW_MODEL` |
| `GRAPHARC_SLACK_COMMAND` | `/grapharc` | the slash command to answer to |
| `GRAPHARC_SLACK_LIVE` | on | `0` turns off the live-edited status message |
| `GRAPHARC_SLACK_LIVE_INTERVAL` | `2.5` | seconds between two edits of the status message |
| `GRAPHARC_SLACK_LIVE_URL` | unset | base URL of a `grapharc serve --live-root` the requester can reach; posts a "watch live" link |

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

## Live progress

A command that traces (`demo`, `run`, `plan`, `agent`) is narrated while it
runs. The gate gives every such command a trace path the bot knows — a unique
`slack-runs/<stamp>/trace.jsonl` under the working directory, unless the
request named its own `--trace` — and the bot tails that file from a side
thread while the subprocess runs. What you see in Slack is one status message,
edited in place every couple of seconds:

```
`grapharc run pipeline.toml --trace slack-runs/…/trace.jsonl` — running (14s)
✓ ingest 312ms
✓ extract 1.8s 1543 tok
✗ verify err: citation not found
▸ report running…
6 events · 2/4 nodes done · 1543 tok
<current diagram>
```

The `current diagram` link is the same mermaid.live fragment URL `viz` gets —
the diagram is compressed into the URL itself and shipped to no one — and it is
refreshed on every edit, so mid-run it renders the path *so far*. When the
command finishes, the status message is edited one last time into the same
final result the bot has always posted.

Everything about this path is best-effort by construction. If the bot cannot
post the status message (it is not in the channel, the API errored), the whole
live layer steps aside and you get today's single blocking reply; if a mid-run
edit fails, the narration goes quiet; and if the *final* edit fails, the result
is posted as an ordinary reply instead. A broken live view can cost you the
narration, never the answer. One visibility note: for a slash command the
status message is posted to the channel (a `respond()`-style reply would allow
only five updates), so a live run is visible to everyone in it — the mention
path threads it under your message as before.

Because the trace now lands inside the working directory, the run is also
inspectable afterwards from Slack itself: `/grapharc metrics
slack-runs/<stamp>/trace.jsonl <run-id>`, `viz` for the finished diagram,
`replay` for the reconstruction. The `slack-runs/` directories are the audit
trail and are never cleaned up automatically; prune them like any other logs.

## Watching it live in a browser

The status message is text. For the actual diagram redrawing itself as nodes
run, pair the bot with the live view server on the same machine:

```bash
grapharc serve --live-root "$GRAPHARC_SLACK_WORKDIR" --port 8300
export GRAPHARC_SLACK_LIVE_URL=https://laptop.tailnet.ts.net:8300
python -m grapharc.slack
```

With the URL configured, the bot's first status message includes
`watch live: <url>/live/view?trace=slack-runs/…` — a page that renders the
Mermaid diagram and the run's numbers and updates itself over SSE as the trace
file grows. `/live` lists every trace under the root. The server is read-only,
confines every requested path inside the root, and never serves `state_delta`
contents — what the page shows is what `viz` and `metrics` already show.

Reachability is deliberately your problem, not the bot's: the bot never opens
a port (that is the whole point of Socket Mode), and `serve` still binds
loopback by default. Put a tailnet or tunnel (Tailscale, cloudflared) in front
for the person on the phone, and add `--live-token` if the URL is guessable.
Details in [06-serving-and-ops.md](06-serving-and-ops.md).

## A `plan` that reads

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