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
28 changes: 28 additions & 0 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,17 @@ jobs:
type=semver,pattern={{major}}.{{minor}}
type=sha,format=short

# The version tpk reports at runtime (#85): the release tag without its
# "v", or dev-<sha> for branch / PR builds.
- name: tpk version
id: tpkver
run: |
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
else
echo "version=dev-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
fi

- name: Build and push
uses: docker/build-push-action@v6
with:
Expand All @@ -78,6 +89,9 @@ jobs:
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
TPK_VERSION=${{ steps.tpkver.outputs.version }}
TPK_COMMIT=${{ github.sha }}
cache-from: type=gha,scope=app
cache-to: type=gha,mode=max,scope=app

Expand Down Expand Up @@ -124,6 +138,17 @@ jobs:
type=semver,pattern={{major}}.{{minor}}
type=sha,format=short

# The version tpk reports at runtime (#85): the release tag without its
# "v", or dev-<sha> for branch / PR builds.
- name: tpk version
id: tpkver
run: |
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
else
echo "version=dev-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
fi

- name: Build and push
uses: docker/build-push-action@v6
with:
Expand All @@ -134,5 +159,8 @@ jobs:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
TPK_VERSION=${{ steps.tpkver.outputs.version }}
TPK_COMMIT=${{ github.sha }}
cache-from: type=gha,scope=allinone
cache-to: type=gha,mode=max,scope=allinone
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export the matching env var — whichever suits your deployment. Secrets are
| `TIMEPLUS_PORT` | `[db].port` | `8123` | DB HTTP port |
| `TIMEPLUS_DATABASE` | `[db].database` | `tpk` | Database all tpk streams live under |
| `TPK_DB_BACKEND` | `[db].backend` | `timeplusd` | Stream-semantics mode (`timeplusd`\|`proton`) |
| `TPK_STREAM_PREFIX` | `[db].stream_prefix` | `` | Namespace prefix for all streams |
| `TPK_STREAM_PREFIX` | `[db].stream_prefix` | `` | Namespace prefix for all streams — honoured by every entry point (`serve`, `ingest`, `export`/`import`, `auth`, stdio MCP) |
| `TPK_DB_WAIT_SECONDS` | `[db].wait_seconds` | `60` | `serve`: DB connect retry budget |
| `TPK_AGENT_PROVIDER` | `[agent].provider` | auto | Chat LLM backend (`anthropic`\|`openai`) |
| `TPK_AGENT_MODEL` | `[agent].model` | per-provider | Chat model override |
Expand Down Expand Up @@ -356,7 +356,15 @@ counts being bit-for-bit reproducible across ingests of the same commit.
/chat`, Server-Sent Events: each event is a `data: {...}\n\n` line with
`type` one of `token` | `tool` | `done` | `error`) over the knowledge graph,
plus the built React web UI at `/` (mounted from `web/dist` when present) and
`GET /healthz`.
`GET /healthz` — unauthenticated, returning `{"status": "ok", "version":
"0.0.5", "commit": "bc549ef"}`.

**Which version is running?** The same string everywhere: `tpk --version`,
`/healthz`, the web UI (sidebar footer and login page), the `tpk serve` startup
line, and MCP `serverInfo.version`. Published images report their release tag
(the Docker workflow bakes `TPK_VERSION` / `TPK_COMMIT` in), a git checkout
reports `git describe` (e.g. `0.0.5-3-gbc549ef`), and a local `docker build`
without those build-args reports `dev`.

The agent needs its own LLM configuration, separate from graphify's
extraction backend — set in `.env` or the shell:
Expand Down
14 changes: 12 additions & 2 deletions deploy/docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,16 @@ RUN printf '#!/bin/sh\nPATH=/opt/tpk/.venv/bin:$PATH exec /opt/tpk/.venv/bin/tpk
&& chmod +x /usr/local/bin/tpk /usr/local/bin/tpk-mcp \
&& mkdir -p /opt/tpk/.checkouts
# TIMEPLUS_HOST is overridden at runtime to point at the db service.
# The running version (#85). The build context has no .git, so the image can't
# work it out: the publish workflow passes the release tag + commit in.
ARG TPK_VERSION=dev
ARG TPK_COMMIT=
ENV TIMEPLUS_HOST=localhost \
TIMEPLUS_USER=default \
PYTHONUNBUFFERED=1 \
TPK_CHECKOUT_DIR=/opt/tpk/.checkouts
TPK_CHECKOUT_DIR=/opt/tpk/.checkouts \
TPK_VERSION=${TPK_VERSION} \
TPK_COMMIT=${TPK_COMMIT}
EXPOSE 8000
ENTRYPOINT ["tpk"]
CMD ["serve", "--host", "0.0.0.0", "--port", "8000"]
Expand Down Expand Up @@ -86,11 +92,15 @@ RUN chmod +x /usr/local/bin/render-users.sh /usr/local/bin/allinone-entrypoint.s
&& chmod +x /usr/local/bin/tpk /usr/local/bin/tpk-mcp \
&& mkdir -p /opt/tpk/.checkouts \
&& chown -R 101:101 /opt/tpk
ARG TPK_VERSION=dev
ARG TPK_COMMIT=
ENV TIMEPLUS_HOST=localhost \
TIMEPLUS_USER=default \
TPK_DB_BACKEND=proton \
PYTHONUNBUFFERED=1 \
TPK_CHECKOUT_DIR=/opt/tpk/.checkouts
TPK_CHECKOUT_DIR=/opt/tpk/.checkouts \
TPK_VERSION=${TPK_VERSION} \
TPK_COMMIT=${TPK_COMMIT}
EXPOSE 8000
ENTRYPOINT ["/usr/local/bin/allinone-entrypoint.sh"]
USER 101:101
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
[project]
name = "tpk"
version = "0.1.0"
# Placeholder only -- hatchling needs a static value. The REAL version comes from
# the release tag (see src/tpk/version.py) and this number is reported nowhere.
version = "0.0.0"
description = "Timeplus knowledge graph toolkit"
authors = [
{ email = "gang@timeplus.io" }
Expand Down
6 changes: 5 additions & 1 deletion src/tpk/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
__version__ = "0.1.0"
from tpk.version import get_version

# Resolved from the release tag (image build env) or `git describe`; see
# tpk/version.py. Not a hand-edited constant (#85).
__version__ = get_version()[0]
34 changes: 30 additions & 4 deletions src/tpk/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,24 @@ def _progress_line(

app = typer.Typer(help="Timeplus knowledge graph toolkit")


def _print_version(value: bool) -> None:
if value:
from tpk.version import version_string

typer.echo(f"tpk {version_string()}")
raise typer.Exit()


@app.callback()
def _main(
version: bool = typer.Option(
False, "--version", callback=_print_version, is_eager=True,
help="Show the tpk version and exit",
),
):
"""Timeplus knowledge graph toolkit"""

REPOS_TOML = config_path()


Expand Down Expand Up @@ -218,17 +236,25 @@ def serve(
Settings.from_env(),
timeout_s=setting("TPK_DB_WAIT_SECONDS", "db", "wait_seconds", 60.0, cast=float),
)
db.ensure_schema(client)
# The whole server runs on the configured stream prefix, like ingest /
# export / import / auth do (#84) -- otherwise a prefixed deployment would
# ingest into one set of streams and serve from another.
prefix = setting("TPK_STREAM_PREFIX", "db", "stream_prefix", "")
db.ensure_schema(client, prefix)
if REPOS_TOML.exists():
corpus.seed_from_toml(client, REPOS_TOML)
corpus.seed_from_toml(client, REPOS_TOML, prefix=prefix)
# Eager bootstrap (issue #6): seed the admin user before uvicorn starts
# serving. `serve` here always runs as a single uvicorn process (no
# `workers=` argument), so the list_users-then-upsert_user race in
# seed_admin() cannot happen between two processes of this command; and
# even if it somehow raced, kg_users' PRIMARY KEY makes a double-seed of
# the same "admin" row converge to one final row anyway.
auth_mod.seed_admin(client)
uvicorn.run(create_app(), host=host, port=port)
auth_mod.seed_admin(client, prefix)
from tpk.version import version_string

typer.echo(f"tpk {version_string()} starting on {host}:{port}"
+ (f" (stream prefix {prefix!r})" if prefix else ""))
uvicorn.run(create_app(stream_prefix=prefix), host=host, port=port)


if __name__ == "__main__":
Expand Down
11 changes: 7 additions & 4 deletions src/tpk/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from tpk.config import Settings, config_path, load_repos
from tpk.config import repo_paths as resolved_repo_paths
from tpk.tools import KnowledgeGraph
from tpk.version import get_version

REPOS_TOML = config_path()

Expand All @@ -32,7 +33,7 @@ def build_server(kg, guard=None) -> FastMCP:
mcp_http.make_guard(), which applies the caller's role scope and the
source:view gate (#74)."""
run = guard or _unguarded
server = FastMCP("timeplus-knowledge")
server = FastMCP("timeplus-knowledge", version=get_version()[0])

@server.tool()
async def search_entities(ctx: Context, query: str, kinds: list[str] | None = None,
Expand Down Expand Up @@ -90,7 +91,7 @@ def main() -> None:
settings = Settings.from_env()
try:
client = db.get_client(settings)
db.ensure_schema(client)
db.ensure_schema(client, settings.stream_prefix)
except Exception as exc:
# An MCP client shows a crashed stdio server only as "Connection
# closed" and hides the traceback -- so say why in ONE line (#77).
Expand All @@ -107,9 +108,11 @@ def main() -> None:
file=sys.stderr,
)
sys.exit(1)
corpus.seed_from_toml(client, REPOS_TOML)
# Same stream prefix as `tpk serve` and the rest of the CLI (#84).
prefix = settings.stream_prefix
corpus.seed_from_toml(client, REPOS_TOML, prefix=prefix)
repos = load_repos(REPOS_TOML)
kg = KnowledgeGraph(client, repo_paths=resolved_repo_paths(repos))
kg = KnowledgeGraph(client, stream_prefix=prefix, repo_paths=resolved_repo_paths(repos))
build_server(kg).run() # stdio transport


Expand Down
22 changes: 14 additions & 8 deletions src/tpk/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ def _usage_tokens(msg) -> int:
return 0


def _build_kg_and_repos():
def _build_kg_and_repos(prefix: str = ""):
"""Production path: one shared `KnowledgeGraph` (+ the parsed repo
config it needs) built up front in `create_app`, reused by both the
lazily-built chat agent and the graph API router -- instead of each
Expand All @@ -114,16 +114,17 @@ def _build_kg_and_repos():
repos = load_repos(REPOS_TOML)
settings = Settings.from_env()
client = db.get_client(settings)
db.ensure_schema(client)
corpus.seed_from_toml(client, REPOS_TOML)
db.ensure_schema(client, prefix)
corpus.seed_from_toml(client, REPOS_TOML, prefix=prefix)
kg = KnowledgeGraph(
client,
stream_prefix=prefix,
repo_paths=resolved_repo_paths(repos),
)
return kg, repos


def _build_production_agent(kg, repos):
def _build_production_agent(kg, repos, prefix: str = ""):
from tpk import corpus, db
from tpk.agent import build_agent
from tpk.config import AgentConfig, Settings
Expand All @@ -142,7 +143,7 @@ def _build_production_agent(kg, repos):

def _live_corpus():
with provider_lock:
return [e for e in corpus.list_entries(provider_client) if e.enabled]
return [e for e in corpus.list_entries(provider_client, prefix=prefix) if e.enabled]

return build_agent(
kg,
Expand Down Expand Up @@ -204,7 +205,7 @@ async def _lifespan(_app):
# (fake/no-op) and, when it wants the graph router mounted, its own
# `kg` built over the test stream prefix -- so this branch never
# runs there and never touches the network in unit tests.
kg, repos_for_agent = _build_kg_and_repos()
kg, repos_for_agent = _build_kg_and_repos(stream_prefix)
# Per-user daily token budget (#62). Always-on in production (not gated
# by the audit toggle); a fresh client per read/write. Tests inject
# their own `usage` (or leave it None to disable enforcement).
Expand All @@ -219,12 +220,17 @@ async def _lifespan(_app):

def _agent():
if state["agent"] is None:
state["agent"] = _build_production_agent(kg, repos_for_agent)
state["agent"] = _build_production_agent(kg, repos_for_agent, stream_prefix)
return state["agent"]

@app.get("/healthz")
def healthz():
return {"status": "ok"}
# Unauthenticated on purpose: a version string is not sensitive, and
# it makes "is the rollout live?" a one-line curl (#85).
from tpk.version import get_version

version, commit = get_version()
return {"status": "ok", "version": version, "commit": commit}

@app.get("/chat/model")
def chat_model(user: User = Depends(auth.require_cap(auth_mod.CAP_CHAT))):
Expand Down
43 changes: 43 additions & 0 deletions src/tpk/version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""The running tpk version (#85). One source of truth: the release tag.

Resolution order:
1. TPK_VERSION / TPK_COMMIT -- baked into the published images by the Docker
workflow from the git tag (the build context has no .git, so the image
cannot work it out for itself).
2. `git describe` -- a dev checkout, e.g. `0.0.5-3-gbc549ef-dirty`.
3. `dev` -- no metadata at all. Never a stale hand-edited constant: the
`version` in pyproject.toml is a placeholder and is not reported anywhere.
"""

import os
import subprocess
from pathlib import Path

_REPO_ROOT = Path(__file__).resolve().parents[2]


def _git(*args: str) -> str:
try:
out = subprocess.run(
["git", "-C", str(_REPO_ROOT), *args],
capture_output=True, text=True, timeout=2, check=False,
)
except (OSError, subprocess.SubprocessError):
return ""
return out.stdout.strip() if out.returncode == 0 else ""


def get_version() -> tuple[str, str]:
"""(version, short commit) -- either may be "" / "dev" when unknown."""
version = os.environ.get("TPK_VERSION", "").strip()
commit = os.environ.get("TPK_COMMIT", "").strip()
if not version:
version = _git("describe", "--tags", "--always", "--dirty")
commit = commit or _git("rev-parse", "HEAD")
return (version.removeprefix("v") or "dev", commit[:7])


def version_string() -> str:
"""`0.0.5 (bc549ef)`, or just the version when the commit is unknown."""
version, commit = get_version()
return f"{version} ({commit})" if commit else version
Loading
Loading