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
48 changes: 44 additions & 4 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,28 +8,68 @@ permissions:
contents: read

jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: python -m pip install --upgrade pip && python -m pip install '.[dev,anthropic]'
- name: Verify release candidate
run: |
python -m pytest -q
ruff check .
ruff format --check .
mypy src/threadlang
bandit -q -r src/threadlang
pip-audit

build:
needs: verify
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: python -m pip install --upgrade pip build
- name: Verify tag matches pyproject version
- name: Verify tag matches package versions
run: |
PKG=$(python -c "import tomllib;print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
TAG="${GITHUB_REF_NAME#v}"
[ "$PKG" = "$TAG" ] || { echo "tag $GITHUB_REF_NAME != pyproject version $PKG"; exit 1; }
RUNTIME=$(PYTHONPATH=src python -c "import threadlang;print(threadlang.__version__)")
[ "$GITHUB_REF_NAME" = "v$PKG" ] || { echo "tag $GITHUB_REF_NAME != v$PKG"; exit 1; }
[ "$RUNTIME" = "$PKG" ] || { echo "runtime version $RUNTIME != pyproject version $PKG"; exit 1; }
- run: python -m build
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/

publish:
verify_artifact:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- run: python -m pip install --upgrade pip twine
- run: python -m twine check dist/*
- name: Smoke-test built wheel
run: |
python -m venv "$RUNNER_TEMP/threadlang-smoke"
"$RUNNER_TEMP/threadlang-smoke/bin/pip" install dist/*.whl
"$RUNNER_TEMP/threadlang-smoke/bin/threadlang" --version
"$RUNNER_TEMP/threadlang-smoke/bin/threadlang-serve" --help >/dev/null
"$RUNNER_TEMP/threadlang-smoke/bin/support-triage" --help >/dev/null

publish:
needs: verify_artifact
runs-on: ubuntu-latest
environment: pypi
permissions:
id-token: write # trusted publishing (OIDC) — no API token stored anywhere
Expand Down
57 changes: 40 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ workflows**. ThreadLang validates a workflow graph, executes model and tool
calls within explicit limits, and records every binding, step, model turn, tool
call, and result as a structured trace.

The current source version is **v0.13.2 (alpha)**. It includes:
The current source version is **v0.13.3 (alpha)**. It includes:

- model and allow-listed [agentic tool-use steps](#agentic-steps-v03);
- durable SQLite execution with [checkpoint, resume, and replay](#durability-v04);
Expand Down Expand Up @@ -50,7 +50,14 @@ thread TwoStep {

## Install

ThreadLang is not published on PyPI yet. Install the current source:
ThreadLang requires Python 3.11 or newer. Install the published package:

```bash
python -m pip install threadlang # core + OpenAI-compatible backend, zero runtime deps
python -m pip install 'threadlang[anthropic]' # optional Anthropic client
```

Or install the current source:

```bash
git clone https://github.com/minglong51/threadlang.git
Expand Down Expand Up @@ -120,6 +127,16 @@ vLLM, Together, …) over stdlib HTTP — no SDK. Tool-calling rides the OpenAI
of emitting native `tool_calls` — use them for `llm`/`complete` steps, and
DeepSeek (or Claude) for `agent` steps.

Provider selection fails closed: a requested real backend never silently
downgrades to dry-run output. `OPENAI_API_KEY` is read only for the official
`https://api.openai.com` endpoint; compatible endpoints use
`THREADLANG_API_KEY` or an explicit programmatic key. Keys require HTTPS unless
the endpoint is loopback; loopback HTTP bypasses environment proxies, and
unkeyed local HTTP endpoints remain supported. Provider redirects are refused,
base URLs cannot embed credentials, query parameters, or fragments, and
OpenAI-compatible response bodies are capped at 8 MiB. Invalid Unicode and
malformed tool-call payloads fail closed before execution.

## Agentic steps (v0.3)

An `agent` step is a model that can *act*. It runs a tool-use loop — model →
Expand Down Expand Up @@ -154,23 +171,27 @@ status. If it crashes, resume it from the last completed step — no re-running
finished work.

```bash
# Persist a run; prints run_id and (on failure) the resume command
# Persist a run; retryable provider-call failures print a resume command
threadlang examples/two_step.thread --input text="..." --backend openai --store runs.db

# A crash prints: run failed; resume with: --store runs.db --resume <id>
threadlang examples/two_step.thread --input text="..." --backend openai \
--store runs.db --resume <id> # skips completed steps, continues
# A retryable provider-call failure prints a copyable command with the original backend settings.
# Persisted inputs are loaded from the run; completed steps are skipped.
threadlang examples/two_step.thread --store runs.db --resume <id> \
--backend openai
```

```python
from threadlang import parse_program, run_durable, RunStore

store = RunStore("runs.db")
durable = run_durable(parse_program(src), {"text": "..."}, store)
durable.run_id # the run's id
store.get_run(durable.run_id).status # 'completed' | 'failed' | 'running'
store.load_events(durable.run_id) # the persisted trace
store.list_runs() # all runs (for a dashboard)
try:
durable = run_durable(parse_program(src), {"text": "..."}, store)
durable.run_id # the run's id
store.get_run(durable.run_id).status # 'completed' | 'failed' | 'running'
store.load_events(durable.run_id) # the persisted trace
store.list_runs() # all runs (for a dashboard)
finally:
store.close()
```

The runtime stays storage-agnostic — `run_durable` hands it a write-through
Expand Down Expand Up @@ -202,10 +223,10 @@ curl localhost:8765/runs # list all runs

| Method / path | Does |
|---|---|
| `POST /runs` | enqueue `{source, inputs}` (program validated first) → `run_id` |
| `GET /runs` | list runs (id, status, program, output) |
| `POST /runs` | enqueue exactly one of `{source, inputs}` or `{ir, inputs}` (validated first) → `run_id` |
| `GET /runs?limit=&offset=` | paginated run summaries |
| `GET /runs/{id}` | one run: status, output, error, and the persisted trace |
| `GET /healthz` | liveness |
| `GET /healthz` / `GET /readyz` | database liveness / worker readiness + queue depth |

Built from `process_one` (claim + run one queued run) and a `WorkerPool` of
threads; the claim is atomic so no run executes twice. Details:
Expand Down Expand Up @@ -250,7 +271,7 @@ app-specific support.
# one ticket, durably, in-process — deterministic, no key
support-triage run --ticket "The dashboard is down with 500s, urgent" --dry-run

# real model (DeepSeek native tool-calling, or local Ollama)
# real model with native tool-calling (DeepSeek, Claude, or a compatible local model)
support-triage run --ticket "..." --backend openai

# or serve the API + workers + dashboard with the app's tool registry wired in
Expand All @@ -265,8 +286,10 @@ entrypoint. The one core change is `serve(tools=...)`, so any app can serve its
own programs over the same API. `classify_priority` is deterministic keyword
rules (no model call — cheap, inspectable); the model is spent only on the
draft. Because the tools are pure and `DryRunClient` fires the first
allow-listed tool, the whole product runs end-to-end under `--dry-run` and is
golden-tested. Details:
allow-listed tool with placeholder arguments, the plumbing runs end-to-end
under `--dry-run` and is golden-tested. Dry-run does not validate ticket
classification, KB-search coverage, or reply quality; use a real tool-calling
model for those semantics. Details:
[`docs/design/phase-5-vertical-slice.md`](docs/design/phase-5-vertical-slice.md).

## Metrics (v0.8)
Expand Down
123 changes: 123 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# Releasing ThreadLang

ThreadLang publishes to PyPI from a GitHub Release through
[`.github/workflows/publish.yml`](.github/workflows/publish.yml). The workflow
uses trusted publishing; no PyPI API token is stored in the repository.

## Prepare and verify

Merge the release pull request only after all CI checks are green. Then start
from the exact, clean `main` commit that will be tagged. Run the verification
lane with Python 3.12, matching the publish workflow:

```bash
set -euo pipefail

git switch main
git pull --ff-only origin main

VERSION="$(python3.12 -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')"
test "$(PYTHONPATH=src python3.12 -c 'import threadlang; print(threadlang.__version__)')" = "$VERSION"
test -z "$(git status --short)"

RELEASE_ENV="$(mktemp -d)/venv"
python3.12 -m venv "$RELEASE_ENV"
"$RELEASE_ENV/bin/pip" install '.[dev,anthropic]'
"$RELEASE_ENV/bin/python" -m pytest -q
"$RELEASE_ENV/bin/ruff" check .
"$RELEASE_ENV/bin/ruff" format --check .
"$RELEASE_ENV/bin/mypy" src/threadlang
"$RELEASE_ENV/bin/bandit" -q -r src/threadlang
"$RELEASE_ENV/bin/pip-audit"

ARTIFACT_DIR="$(mktemp -d)"
"$RELEASE_ENV/bin/python" -m build --outdir "$ARTIFACT_DIR"
"$RELEASE_ENV/bin/python" -m twine check "$ARTIFACT_DIR"/*

SMOKE_ROOT="$(mktemp -d)"
python3.12 -m venv "$SMOKE_ROOT/venv"
"$SMOKE_ROOT/venv/bin/pip" install \
"$ARTIFACT_DIR/threadlang-$VERSION-py3-none-any.whl"
(
cd "$SMOKE_ROOT"
test "$(env -u PYTHONPATH -u PYTHONHOME "$SMOKE_ROOT/venv/bin/threadlang" --version)" = \
"threadlang $VERSION"
env -u PYTHONPATH -u PYTHONHOME \
"$SMOKE_ROOT/venv/bin/threadlang-serve" --help >/dev/null
env -u PYTHONPATH -u PYTHONHOME \
"$SMOKE_ROOT/venv/bin/support-triage" run \
--ticket "release smoke" --dry-run --store "$SMOKE_ROOT/triage.db" >/dev/null
)
```

The project version in `pyproject.toml` and `threadlang.__version__` must match.
The release tag must exactly equal `v<project version>`; the publish workflow
rejects any other tag or a runtime/project version mismatch.

## Tag and publish

Confirm `HEAD` is the intended commit on `origin/main`, then create and push a
new lightweight tag and create a draft GitHub Release from that existing tag:

```bash
set -euo pipefail

VERSION="$(python3.12 -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')"
test "$(PYTHONPATH=src python3.12 -c 'import threadlang; print(threadlang.__version__)')" = "$VERSION"
test -z "$(git status --short)"
git fetch origin main
test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)"
git tag "v$VERSION"
git push origin "v$VERSION"
gh release create "v$VERSION" --verify-tag --generate-notes --draft
```

Inspect the draft release's generated notes and tag target:

```bash
set -euo pipefail

VERSION="$(python3.12 -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')"
git show --no-patch --format=fuller "v$VERSION"
gh release view "v$VERSION" --json isDraft,tagName,targetCommitish,name,body
```

If the draft is correct, publish it explicitly:

```bash
set -euo pipefail

VERSION="$(python3.12 -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')"
gh release edit "v$VERSION" --draft=false
```

Publishing starts, but does not immediately complete, the PyPI release. The
workflow runs `verify`, `build`, and `verify_artifact` first. Its final
`publish` job targets the protected `pypi` environment and waits for the
configured maintainer approval before exchanging its OIDC identity for a PyPI
upload.

## Verify the release

Watch the complete `Publish to PyPI` workflow and approve the `pypi`
deployment only after the preceding jobs pass. After PyPI reports the new
version, verify it from a fresh environment outside the repository:

```bash
set -euo pipefail

VERSION="$(python3.12 -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')"
VERIFY_ROOT="$(mktemp -d)"
VERIFY_ENV="$VERIFY_ROOT/venv"
python3.12 -m venv "$VERIFY_ENV"
"$VERIFY_ENV/bin/pip" install "threadlang==$VERSION"
cd "$VERIFY_ROOT"
test "$(env -u PYTHONPATH -u PYTHONHOME "$VERIFY_ENV/bin/threadlang" --version)" = \
"threadlang $VERSION"
env -u PYTHONPATH -u PYTHONHOME "$VERIFY_ENV/bin/threadlang-serve" --help >/dev/null
env -u PYTHONPATH -u PYTHONHOME "$VERIFY_ENV/bin/support-triage" run \
--ticket "release smoke" --dry-run --store "$VERIFY_ROOT/triage.db" >/dev/null
```

PyPI versions are immutable. Never move or reuse a release tag. If a published
artifact needs correction, prepare and release a new patch version.
4 changes: 2 additions & 2 deletions docs/benchmarks/dsl-comparison.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

This is a primary-source **semantics matrix**, not a model-quality or performance score. `Full`, `Partial`, and `No` describe documented behavior; they are not weighted or aggregated.

| Capability | ThreadLang v0.12 | Temporal | AWS Step Functions / ASL | LangGraph | AutoGen GraphFlow | Dapr Workflow |
| Capability | ThreadLang v0.13 | Temporal | AWS Step Functions / ASL | LangGraph | AutoGen GraphFlow | Dapr Workflow |
|---|---|---|---|---|---|---|
| Deterministic control structure | Full: parsed forward-only graph | Full: replay-compatible workflow commands | Full: declarative state-machine interpreter | Partial: graph/super-step control, not Temporal replay | Partial: deterministic routing structure; experimental | Full: replay-compatible workflow code |
| Crash durability | Partial: SQLite step-boundary checkpoint | Full event-history reconstruction | Full for Standard Workflows | Full with persistent checkpointer | Application-managed save/load | Full event-sourced state via Dapr |
Expand All @@ -17,7 +17,7 @@ This is a primary-source **semantics matrix**, not a model-quality or performanc

## Design conclusions

1. ThreadLang should describe v0.12 as **step-checkpoint durability**, not durable replay.
1. ThreadLang should describe v0.13 as **step-checkpoint durability**, not durable replay.
2. LLM and agent calls are nondeterministic activities and may execute more than once after a hard crash.
3. The current product boundary is intentionally smaller than Temporal, ASL, LangGraph, or Dapr: a compact textual agent DSL with local execution and inspection.
4. Durable external events, version pinning/migrations, typed state, and call-level idempotency keys belong in later versioned designs, not undocumented semantics.
Expand Down
Loading
Loading