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: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ and "used to be true" — the two things a reader most needs kept apart.

Entries are newest-last within a release, matching the order they were written.

## Unreleased
## 0.1.6

- **the supervised loop had no exit from Slack.** A Slack-launched `plan --approve` parked, showed a human the run, was approved — and then returned `stopped: planned (awaiting `grapharc go`)`, into a subcommand the bot's gate does not carry and cannot be talked into carrying. So the one exchange the bot exists for (ask for work, see the graph, let it run) stopped one step short of running anything, and the approval it collected authorised nothing. `plan --go` is admitted from Slack now, and admitting it is only safe because of the rule that came with it: **from Slack, `--go` forces `--approve`**, on every registry, whether or not the requester typed it. Anyone in a workspace can type into this bot; without that rule one message would take a model's proposal straight to execution on the host with the graph visible only afterwards. It is forced rather than refused so the useful command stays one message — propose it, show me, run it if I say yes.
- **the graph a human was asked to approve was not in the message they were asked to approve it from.** The parked status message said "planned graph is in the live view link" and left the reader to open a URL — on a phone, from a chat client, to see the thing they were being asked about. The proposal is rendered into the message now: the planner's rationale, every node, every edge, and the worst-case token estimate the gate computed. Two things ride with it. First, **kinds, not just names**: `ProposedNode.kind` is what admission governs and the name is a label the planner picked freely, so a node named `fix_it` of kind `apply_change` used to read as harmless — the kind is shown, and a kind the registry declares mutating is marked `✎`. Second, a registry that declares no `MUTATING_KINDS` marks *every* node and says so, matching the fail-closed reading `plan` already takes when it writes `mutating: true` into a plan file it cannot vouch for; the bot resolves the registry from the flag or the working directory's `grapharc.toml` and refuses to import anything outside the shipped set to find out.
Expand All @@ -23,6 +23,12 @@ Entries are newest-last within a release, matching the order they were written.
- **three CLI recordings, on the same terms as the Slack one.** `docs/demo/capture_cli.py` runs a scenario's commands in a pseudo-terminal — so the CLI takes its tty branch and emits the colour a person actually sees, rather than the byte-stable colourless form a pipe would give — and records each command's bytes, exit code and wall clock. `render_cli.py` draws them, parsing exactly `\x1b[…m` (including the `38;5;N` form the CLI emits under a 256-colour terminal) and dropping every other escape rather than half-interpreting it. The three: the admission gate refusing and then admitting, free and deterministic; GraphARC fixing the `graph_status` bug above in a copy of itself, where the interesting frame is the planner's own rationale under a deny — *"Since apply_change cannot be reached by an edge, this round investigates … for a human to act on"*, `mutating: false` — against the five-node `mutating: true` graph the same goal and model produce once a human amends the rule; and one trace file answering `trace`, `replay` and `diff`, including a run priced and refused during admission so that `diff` reports `path 3 -> 0 nodes`.
- **a demo you can re-make and check.** `docs/demo/capture_supervised_slack.py` drives the real Slack path — the real gate, a real planner, the real file handshake, a real fingerprint-checked click, a real delegated Claude Code phase — against a recording sink instead of a socket, and `render_demo.py` turns the recording into the film in the README. What is real and what is mocked is written down rather than implied, and the closing frame is computed from the trace file rather than from any message: `approval_request → approval_response → start`, with `start` last. The same property is asserted against a real CLI subprocess in `tests/test_slack_supervision.py`, so it does not depend on anyone re-recording a video.

- **`grapharc[mcp]` was broken on arrival.** `grapharc/mcp/server.py` builds on `mcp.server.fastmcp`, which exists in the `mcp` 1.x line and was removed in 2.0 — and the extra was declared `mcp>=1.2`, so a fresh `pip install 'grapharc[mcp]'` resolved to 2.0.0 and could not import. `uv.lock` pins 1.28, so the whole dev environment and every local `pytest` passed; only a resolver starting from `pyproject.toml`, which is to say a real user, ever saw it. The ceiling is `<2` now, with the reason written above it so raising it has to be a diff someone argues for rather than a resolver accident. It exposed a second defect on the way out: CI reported the subpackage *missing from the wheel* when it was in the wheel, because `pkgutil.walk_packages` imports a package to walk into it and `grapharc/mcp/__init__.py` imported the SDK eagerly. The names resolve lazily now (PEP 562), the posture `grapharc.slack` already took. Every other extra is still unbounded and the lockfile will hide the next one the same way — tracked separately.
- **a second `grapharc go <run-dir>` silently ran the whole graph again.** Bare `go` has always skipped executed plans; the explicitly-named-directory form made no such check, so re-issuing it executed the plan again, exit 0, and overwrote the stamp — three executions left a `plan.json` naming one while the trace, which is the audit trail and was right, held all three. Two things were wrong and the second is the one that matters. The record now accumulates `executed_run_ids` with an `executed_at`, keeping the scalar as the newest so `find_unexecuted_plan` and the MCP driver keep working and a `plan.json` written before this change still reports its one run. And **one approval can no longer be spent N times**: an approval binds to a proposal fingerprint, which does not change between runs of a saved plan, so a silent re-run of a `mutating: true` plan was an agent editing the tree once per invocation on the strength of a single human yes. A plan carrying an `executed_run_id` is refused with exit 2 before anything executes, naming the previous run, unless `--again` asks for the re-run in as many words.
- **the deep dive's *Verified this pass* figures rotted, twice.** The paragraph quotes concrete numbers and nothing checked them: it read "1,533 passed … 103 submodules" against a tree with 1,754 tests and 116 submodules, and later "1,985 passed, 12 deselected" against one with 2,132 selected and 13 live. A reader who spots one stale figure discounts every other verified claim on the page, including the ones the suite genuinely enforces. The figures are now what one command re-derives — how many tests `pytest` selects, and how many it holds back as `live` — rather than a pass count, and that reword is the substance: a pass count cannot be re-derived without running the suite from inside itself, which is exactly how the old number came to be one no test owned. `tests/test_deep_dive.py` re-derives both in a single collection pass, holds the version the line claims is on PyPI against `pyproject.toml`, and carries two guards behind the comparisons — one that deleting a figure makes the test red rather than vacuous, one that an *unowned* figure appearing on the line fails with wording that says to add a check or take the number off.
- **five `max_seconds` timing tests failed only on a loaded machine.** Three suites running at once turned exactly those five red on an untouched `main`; the same files run 16 consecutive times on the same idle box were 16 × exit 0. A test that fails only under load is indistinguishable from a real regression in the deadline machinery, which is the subsystem where a true red matters most. The machinery was never what failed — the assertions were. Each bounds how long an interrupted node took, and each bound was a constant sitting close to the deadline (`< 2.0` on a 0.2s deadline leaves 1.8s, and scheduling delays under contention run 2-3×), so it answered a question about machine load instead of about the guard. Every bound now sits *between* the deadline and the uninterrupted body with seconds of slack on both sides, and where the gap was too narrow to put a bound inside, the body was lengthened instead — an uninterrupted body only ever runs its full length when the test is failing, so it costs nothing on the green path. Verified 20/20 under three parallel suites and 30 spinners at load average 33-35; reverting `deadline_guard`'s exit check still turns four of them red, so the widened bounds have not gone vacuous.
- **Novita joins the model gateway** (thanks @jax-novita). A fifth OpenAI-wire backend reaching Novita's own endpoint through `NovitaChatModel`, resolved from a single `NOVITA_API_KEY` by the existing env/`.env` lookup, registered in the backend list and the lazy-load map, probed by `grapharc models --check` like the others, and behind a `novita` extra that needs no Novita-specific package. Model ids there are already `author/slug`, the shape OpenRouter uses, so `vendor()` needed no special case. Novita reports no per-call cost, so its calls count as `SpendMeter.unpriced_calls` unless a caller supplies `price_per_million=` — the same posture as the OpenAI backend, and deliberately not OpenRouter's.

## 0.1.5

- a documentation and demo release; no runtime code changed between `0.1.4` and this wheel. The demo film was re-cut to open on the graph itself — frame one is the nine-node incident graph with its first node already running, then the question that built it, then the finished audited run — and the README now leads with it. The README and website stopped describing the project as early and unstable: the status line states the version and the testing discipline, and *Status and limits* became *Limits*, framed as edges that are documented and tested rather than confessed. A PyPI downloads badge joined the badge row. This release exists mostly so the PyPI project page, which renders the README frozen at publish time, catches up with all of it.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,4 +210,4 @@ The edges are documented, not denied — the full list with mechanisms is in the
- Policy documents govern planning; the tool plane still reads CLI flags.
- The MCP gate binds the MCP surface, not the host: an agent with its own file tools in the run directory could forge the approval decision. The trust boundary is the working directory, as it is for the Slack workspace.

Version `0.1.5` · [changelog](CHANGELOG.md) · [roadmap](ROADMAP.md) · [website](https://codegraphcontext.github.io/GraphARC/) · MIT
Version `0.1.6` · [changelog](CHANGELOG.md) · [roadmap](ROADMAP.md) · [website](https://codegraphcontext.github.io/GraphARC/) · MIT
4 changes: 2 additions & 2 deletions docs/cookbook/01-basics.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ projection instead and says which fields it dropped.
`tests/test_cookbook_basics.py` reproduces every recipe here and asserts these
exact strings, so the page cannot rot quietly.

Verified against `grapharc 0.1.5`, Python 3.14.6, `langgraph 1.2.9`,
Verified against `grapharc 0.1.6`, Python 3.14.6, `langgraph 1.2.9`,
`langchain-core 1.5.1`, `pydantic 2.13.4`.

Each snippet is a complete file. Save it and run it; nothing carries over between
Expand All @@ -41,7 +41,7 @@ uv run grapharc --version
Output:

```
grapharc 0.1.5
grapharc 0.1.6
```

Everything below uses only the base install — no API key, no network, no optional
Expand Down
4 changes: 2 additions & 2 deletions docs/cookbook/06-serving-and-ops.md
Original file line number Diff line number Diff line change
Expand Up @@ -1062,7 +1062,7 @@ with TestClient(app) as client:
```

```
health : {'status': 'ok', 'version': '0.1.5', 'graphs': ['qa']}
health : {'status': 'ok', 'version': '0.1.6', 'graphs': ['qa']}
created: 201 queued
status : succeeded
answer : Budgets cap iterations, tokens and time.
Expand Down Expand Up @@ -1259,7 +1259,7 @@ graphs : qa
ctrl-c to stop

$ curl -s localhost:8124/healthz
{"status":"ok","version":"0.1.5","graphs":["qa"]}
{"status":"ok","version":"0.1.6","graphs":["qa"]}

$ curl -s -X POST localhost:8124/sessions -H 'content-type: application/json' \
-d '{"graph":"qa","input":{"question":"how do budgets work?"}}'
Expand Down
2 changes: 1 addition & 1 deletion docs/deep-dive.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ A stable system is not one that claims to have no edges — it is one whose edge
- **`.env` and `grapharc.toml` follow the same discovery rule: the working directory, and nowhere else.** Neither searches parent directories — a run must not be governed by a file you did not know about, and must not be *billed* to one either. **This is a behaviour change:** the credential loader used to walk up to `/`, so a `.env` in an ancestor directory (a `$HOME` one on a shared box, a client project one above a demo checkout) was picked up silently. If you relied on that, move the file into the directory you run from, `export` the variable, or pass `env_file=` to name it explicitly. A real environment variable still beats any file.
- **`grapharc run` has no budget unless you give it one.** Set any of `--max-tokens`, `--max-iterations`, `--max-seconds`, or `--max-concurrency`; without them each dimension is unlimited and the gate admits a topology of any worst-case cost.

**Verified this pass:** `pytest` → green, 2,145 selected and 13 deselected (the live ones); `ruff check .` clean; all eight `grapharc demo` stages green, plus the `trace` / `metrics` / `viz` / `replay` tour against a freshly recorded demo trace; the wheel builds and imports all submodules in a clean virtualenv with `[all]`, and `0.1.5` on PyPI is that wheel. The counts are a snapshot, not a property of the project — `pytest` re-derives them in one command, which is the only reason they are quoted, and `tests/test_deep_dive.py` fails this line rather than letting it drift.
**Verified this pass:** `pytest` → green, 2,145 selected and 13 deselected (the live ones); `ruff check .` clean; all eight `grapharc demo` stages green, plus the `trace` / `metrics` / `viz` / `replay` tour against a freshly recorded demo trace; the wheel builds and imports all submodules in a clean virtualenv with `[all]`, and `0.1.6` on PyPI is that wheel. The counts are a snapshot, not a property of the project — `pytest` re-derives them in one command, which is the only reason they are quoted, and `tests/test_deep_dive.py` fails this line rather than letting it drift.

[ROADMAP.md](../ROADMAP.md) tracks what is built and what is not, item by item.

Expand Down
2 changes: 1 addition & 1 deletion grapharc/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from grapharc.runtime.graph import GraphARC, WritePermissionError
from grapharc.runtime.state import GraphARCState

__version__ = "0.1.5"
__version__ = "0.1.6"

__all__ = [
"GraphARC",
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "grapharc"
version = "0.1.5"
version = "0.1.6"
description = "A graph engineering toolkit on LangGraph: typed state contracts, per-node write permissions, enforced budgets, and JSONL traces that double as replay points."
readme = "README.md"
license = "MIT"
Expand Down
24 changes: 23 additions & 1 deletion tests/test_cookbook_basics.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@
import asyncio
import operator
import os
import re
import sqlite3
import subprocess
import sys
import time
from pathlib import Path
from typing import Annotated, Literal

import pytest
Expand Down Expand Up @@ -55,7 +57,27 @@


def test_version_matches_the_page():
assert __version__ == "0.1.5"
"""Read off the page rather than pinned here.

This used to be `assert __version__ == "0.1.5"` — a *third* place the
version was written down, after `pyproject.toml` and `grapharc/__init__.py`,
and the only one of the three that CI's version check does not compare. Its
effect was that a release bump reddened this test for saying nothing about
the page it is named after. What the page actually claims is that it was
verified against a version and that `grapharc --version` prints it; both are
now held against the package, so a bump moves the page or fails.
"""
page = (
Path(__file__).resolve().parents[1] / "docs" / "cookbook" / "01-basics.md"
).read_text(encoding="utf-8")

claimed = re.findall(r"grapharc (\d+\.\d+\.\d+)", page)

assert claimed, "the page no longer states the version it was verified against"
assert set(claimed) == {__version__}, (
f"docs/cookbook/01-basics.md says {sorted(set(claimed))}, "
f"this package is {__version__}"
)


# -- "How do I build and run my first graph?" ------------------------------
Expand Down
Loading