diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..51a9367 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: gomod + directory: "/" + schedule: + interval: weekly + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f519425..de33aec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,10 +9,15 @@ on: permissions: contents: read +concurrency: + group: ui-ci-${{ github.ref }} + cancel-in-progress: true + jobs: build: name: build and check runs-on: ubuntu-latest + timeout-minutes: 10 steps: - name: Check out uses: actions/checkout@v4 @@ -20,24 +25,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.24' - check-latest: true - - - name: Verify gofmt - run: | - unformatted=$(gofmt -l .) - if [ -n "$unformatted" ]; then - echo "These files are not gofmt-clean:" - echo "$unformatted" - echo "Run 'make fmt' and commit the result." - exit 1 - fi - - - name: go vet - run: go vet ./... - - - name: Build - run: go build ./... + go-version: '1.24.2' - - name: Test - run: go test ./... + - name: Run the local gate + run: make check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a379c72 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,93 @@ +name: release binaries + +on: + push: + tags: ["v*"] + +permissions: + contents: read + +jobs: + build: + name: ${{ matrix.goos }}-${{ matrix.goarch }} + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: + - goos: linux + goarch: amd64 + - goos: linux + goarch: arm64 + - goos: darwin + goarch: amd64 + - goos: darwin + goarch: arm64 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.24.2" + cache: true + - name: Verify tag has changelog entry + run: | + python3 - <<'PY' + import os + from pathlib import Path + + version = os.environ["GITHUB_REF_NAME"].removeprefix("v") + heading = f"## [{version}]" + if heading not in Path("CHANGELOG.md").read_text(encoding="utf-8"): + raise SystemExit(f"CHANGELOG.md has no release heading for {version}") + declared = Path("VERSION").read_text(encoding="utf-8").strip() + if declared != version: + raise SystemExit(f"VERSION declares {declared}, but tag is {version}") + print(f"changelog entry: {heading}") + print(f"UI version: {declared}") + PY + - name: Build and package + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: "0" + run: | + set -euo pipefail + export SOURCE_DATE_EPOCH="$(git log -1 --format=%ct)" + version="${GITHUB_REF_NAME#v}" + name="lachesis-ui-${GITHUB_REF_NAME}-${GOOS}-${GOARCH}" + mkdir -p dist + go build -trimpath -ldflags="-s -w -X github.com/UnboundCompute/lachesis-ui/internal/mcp.Version=$version" -o "dist/$name/lachesis-ui" . + cp README.md LICENSE "dist/$name/" + # Normalize tar metadata and the gzip header so the same tag rebuilds + # to byte-identical artifacts. + tar -C dist --sort=name --mtime="@${SOURCE_DATE_EPOCH}" \ + --owner=0 --group=0 --numeric-owner -cf - "$name" \ + | gzip -n > "dist/$name.tar.gz" + sha256sum "dist/$name.tar.gz" > "/tmp/${name}.first.sha256" + tar -C dist --sort=name --mtime="@${SOURCE_DATE_EPOCH}" \ + --owner=0 --group=0 --numeric-owner -cf - "$name" \ + | gzip -n > "dist/$name.tar.gz" + sha256sum -c "/tmp/${name}.first.sha256" + (cd dist && sha256sum "$name.tar.gz") >> dist/SHA256SUMS + - name: Smoke-test native binary + if: matrix.goos == 'linux' && matrix.goarch == 'amd64' + run: | + set -euo pipefail + version="${GITHUB_REF_NAME#v}" + name="lachesis-ui-${GITHUB_REF_NAME}-${{ matrix.goos }}-${{ matrix.goarch }}" + expected="lachesis-ui ${version}" + verify_dir="$(mktemp -d)" + trap 'rm -rf "$verify_dir"' EXIT + tar -xzf "dist/$name.tar.gz" -C "$verify_dir" + test -f "$verify_dir/$name/lachesis-ui" + actual="$("$verify_dir/$name/lachesis-ui" --version)" + test "$actual" = "$expected" || { + echo "version mismatch: got '$actual', want '$expected'" >&2 + exit 1 + } + - name: Upload binary artifact + uses: actions/upload-artifact@v4 + with: + name: lachesis-ui-${{ github.ref_name }}-${{ matrix.goos }}-${{ matrix.goarch }} + path: dist/ diff --git a/CHANGELOG.md b/CHANGELOG.md index f1a88ca..2d5685a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,12 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2 ## [Unreleased] +- Added a machine-readable `VERSION` source used by the installer’s source-build + default; release CI now rejects tags that do not match it. + ### Added +- Tagged releases now build checksummed Linux and macOS binaries for amd64 and arm64. - Nested, collapsible source tree on the Tree screen. Folders expand in place and load their children lazily, with child-count badges, so a large repo opens as a handful of top-level folders rather than a flat wall of files. @@ -18,9 +22,65 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2 ### Changed +- Release verification now executes the native Linux binary and checks its stamped + version from the extracted archive before uploading cross-platform archives. + +- The stack installer now disables Git terminal prompts so unattended setup fails + clearly when a checkout cannot authenticate or reach its repository. + +- The stack installer now applies Git low-speed and pip network bounds, so stalled + repository or dependency transfers fail clearly instead of hanging unattended. + +- UI MCP tool requests now have a two-minute default bound, configurable with + `LACHESIS_UI_REQUEST_TIMEOUT`, and report recent engine diagnostics when a request + times out instead of leaving the terminal waiting indefinitely. + +- The install documentation now uses `python -m pip` so engine dependencies + stay in the interpreter the UI discovers. + +- Engine startup recovery now points to the supported source-checkout install and + labels the PyPI command as conditional until a release is published. + +- The binary-only install section now distinguishes the working source build from + the `go install` command reserved for a published UI tag. + +- The stack installer now warns when engine or catalog refs remain on mutable `main`, + while preserving those defaults for development use. + +- UI engine shutdown now uses a dedicated POSIX process group, so startup or request + timeouts terminate compiler/helper children along with the MCP parent. +- The stack installer now exposes the generated graph helper's frontend timeout as + `LACHESIS_BUILD_TIMEOUT` instead of requiring edits to generated shell code. +- Documented the engine's Python 3.10–3.12 release-tested window in the UI setup guide. +- The Makefile now derives its default binary version from `VERSION`, matching the + installer and release workflow while retaining explicit `VERSION=...` overrides. + +- UI startup diagnostics now use `python -m pip` in their engine-install hint, + avoiding interpreter mismatches during recovery. + +- The installer rejects empty, option-like, or whitespace-containing engine, + catalog, and UI refs before invoking Git. + +- MCP startup and request failures now include the bounded recent engine stderr + tail, making wrong-interpreter, missing-graph, and import failures visible in + the UI instead of looking like a silent server exit. + +- Added a single `make check` developer/CI gate covering formatting, vet, build, + and tests. + +- The generated `build-graph.sh` helper rejects path-like graph names so output + cannot escape the configured graph directory. + +- The generated graph helper now reports a clear error for a missing source + directory before attempting to resolve it. + - The Tree keys now match a nested tree: right expands a folder, left collapses it or jumps to its parent, and enter opens a folder or a symbol's neighborhood. +- Custom `LACHESIS_HOME` installs now work end-to-end, including UI engine and graph + discovery. +- Engine startup is bounded by a configurable timeout, and invalid graph artifacts + are rejected with actionable diagnostics. ## [0.1.0] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index abce16b..b340be2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,7 +19,7 @@ about how that information is shown or navigated, it belongs here. ## Getting set up -You need Go 1.24+ and, to run the UI against a real graph, the engine. +You need Go 1.24.2+ and, to run the UI against a real graph, the engine. ```sh git clone https://github.com/UnboundCompute/lachesis-ui @@ -64,15 +64,14 @@ in the screen's own file. ## Before you open a pull request -Run the local checks. CI runs the same ones and will block a merge if any fail. +Run the local gate. CI runs the same command and will block a merge if any check fails. ```sh -make fmt # gofmt: formatting must be clean -make vet # go vet: no reported problems -make build # the binary must build -go test ./... # tests must pass +make check # gofmt check, vet, build, and tests ``` +Use `make fmt` when the formatting check reports files that need rewriting. + Guidelines: - Keep formatting gofmt-clean. CI fails on any file gofmt would rewrite. diff --git a/Makefile b/Makefile index fdb095a..9ee37fc 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,13 @@ BIN ?= lachesis-ui PREFIX ?= $(HOME)/.lachesis/bin +VERSION ?= $(shell tr -d '[:space:]' < VERSION 2>/dev/null || printf '0.1.0') +LDFLAGS ?= -X github.com/UnboundCompute/lachesis-ui/internal/mcp.Version=$(VERSION) -.PHONY: build install run tidy fmt vet clean stack +.PHONY: build install run tidy fmt vet check clean stack ## build the binary into the working directory build: - go build -o $(BIN) . + go build -ldflags "$(LDFLAGS)" -o $(BIN) . ## build and drop the binary into ~/.lachesis/bin install: build @@ -29,5 +31,18 @@ fmt: vet: go vet ./... +## run the complete local gate used by CI +check: + @unformatted="$$(gofmt -l .)"; \ + if [ -n "$$unformatted" ]; then \ + echo "These files are not gofmt-clean:"; \ + echo "$$unformatted"; \ + echo "Run 'make fmt' and commit the result."; \ + exit 1; \ + fi + go vet ./... + go build ./... + go test ./... + clean: rm -f $(BIN) diff --git a/README.md b/README.md index a812a6f..af1f22c 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,8 @@ A keyboard-driven terminal UI for navigating a [lachesis](https://github.com/UnboundCompute/lachesis) code-property-graph, the way a developer reads a codebase rather than as a raw graph. +Release and rollback guidance is in [`RELEASING.md`](RELEASING.md). + You point it at a graph built from any Python, TypeScript, JavaScript, or C tree and it opens a persistent, k9s-style screen: subsystems and entry points up front, a source tree with per-file symbol outlines, and a symbol's neighborhood @@ -87,8 +89,55 @@ and lays them out as: ~/.lachesis/build-graph.sh helper to build a graph from any source tree ``` +The installer also vendors Lachesis's pinned TypeScript compiler, so the resulting +stack can analyze TypeScript without a separate Node/npm setup. + Re-running `install.sh` updates the checkouts in place. Requirements: `git`, -`python3` (3.10+), and Go 1.24+ to build the binary. +`python3` (3.10–3.12 are the engine's release-tested versions), and Go 1.24.2+ to +build the binary. Verify newer Python versions against the engine/Kùzu dependency set +before using them in production. +The installer takes an atomic lock, so concurrent invocations fail safely rather than +mutating the shared virtualenv and checkouts at the same time. + +Unattended installs fail fast on stalled network transfers: Git aborts after 60 seconds +below its low-speed threshold, and pip uses a 60-second default index/download timeout +with prompts disabled. Override `GIT_HTTP_LOW_SPEED_LIMIT`, `GIT_HTTP_LOW_SPEED_TIME`, +or `PIP_DEFAULT_TIMEOUT` when a slower private mirror requires it. + +The UI bounds each MCP tool request to two minutes so a stalled engine cannot leave the +terminal waiting forever. For unusually expensive local graphs, override it with a Go +duration such as `LACHESIS_UI_REQUEST_TIMEOUT=5m`; a timed-out request terminates the +engine and shows the recent engine diagnostics. + +To keep the stack outside your home directory (for example, on a CI volume), set +`LACHESIS_HOME`; the generated graph helper and UI discovery use the same root: + +```sh +LACHESIS_HOME=/var/cache/lachesis ./scripts/install.sh +``` + +For reproducible deployments, pin the engine and catalog before installing (use +reviewed release tags rather than mutable branches). Re-running the +installer applies those refs to existing clean checkouts and refuses to touch a +checkout with local edits or untracked files: + +```sh +LACHESIS_REF= ATROPOS_REF= ./scripts/install.sh +``` + +The installer pins its binary fallback to `v0.1.0`; set `LACHESIS_UI_REF` to a +reviewed tag or commit when selecting another UI release. + +The generated `build-graph.sh` helper bounds each frontend invocation to 3,600 +seconds by default. Set `LACHESIS_BUILD_TIMEOUT` before running the installer to +use a different positive-integer limit. + +When building from the checkout, set `LACHESIS_UI_VERSION` if the binary should report +a version other than the default `0.1.0`: + +```sh +LACHESIS_UI_VERSION=1.2.0 ./scripts/install.sh +``` Then put the binary on your PATH: @@ -99,17 +148,36 @@ export PATH="$HOME/.lachesis/bin:$PATH" ### Just the binary If you already have the engine installed (`~/.lachesis/venv`, or `lachesis` on -your PATH), you only need the UI: +your PATH), you only need the UI. From this checkout, build it with: + +```sh +go build -trimpath -o "$HOME/.lachesis/bin/lachesis-ui" . +``` + +For a tagged release published to the Go module proxy, use: ```sh -go install github.com/UnboundCompute/lachesis-ui@latest +go install github.com/UnboundCompute/lachesis-ui@v0.1.0 ``` +Replace `v0.1.0` with the reviewed release tag you intend to deploy; avoid +`@latest` in production automation. + ### Not pip or npm `lachesis-ui` is a static Go binary, so it is **not** a pip or npm package. The -engine it drives is Python (`pip install -e` from the checkout, which -`install.sh` does for you). Prebuilt binaries and a Homebrew tap are planned. +engine it drives is Python (`python -m pip install -e` from the checkout, which +`install.sh` does for you). Tagged releases build Linux and macOS binaries for +amd64 and arm64 in the `release binaries` workflow. Download the matching archive +and verify its SHA-256 checksum before unpacking it: + +```sh +sha256sum -c SHA256SUMS --ignore-missing +``` + +On macOS, use `shasum -a 256 -c SHA256SUMS` instead. + +A Homebrew tap is still planned. --- @@ -119,7 +187,7 @@ Build a graph from any source tree, then launch: ```sh # 1. build a graph (prints the store path) -~/.lachesis/build-graph.sh /path/to/some/repo +~/.lachesis/build-graph.sh /path/to/some/repo [name] # 2. open it. With no args, the UI picks the newest graph you've built lachesis-ui @@ -128,6 +196,10 @@ lachesis-ui lachesis-ui --graph ~/.lachesis/graphs/somerepo.kuzu ``` +The optional `name` is a single graph-name component; `/`, `\\`, `.`, and `..` are +rejected so output stays under `~/.lachesis/graphs` (or your configured +`LACHESIS_HOME`). + The first screen you touch triggers a one-time graph load (a few seconds for a large tree); after that every move is instant. @@ -179,6 +251,11 @@ lachesis-ui [flags] [graph.kuzu] **Engine discovery** order: `$LACHESIS_PYTHON`, then `~/.lachesis/venv/bin/python`, then `python3` or `python` on PATH. +Engine startup is bounded to five minutes by default while a large graph loads. Set +`LACHESIS_UI_STARTUP_TIMEOUT` to a Go duration such as `10m` when a larger codebase +needs more time; an expired startup terminates the child and reports an actionable +error. + --- ## Contributing @@ -187,6 +264,8 @@ Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for how to set up the stack, the code layout, and what CI checks before a merge. By participating you agree to the [Code of Conduct](CODE_OF_CONDUCT.md). +From a checkout, `make check` runs the same formatting, vet, build, and test gate as CI. + --- ## License diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..2b91e41 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,42 @@ +# Releasing lachesis-ui + +The UI version is recorded in [`VERSION`](VERSION). The UI is a Go binary released independently from the Lachesis engine and Atropos +catalog. Keep the UI tag and the engine/catalog refs it discovers separate. + +Before tagging: + +```bash +gofmt -l . # must print nothing +go vet ./... +go test ./... +go build ./... +``` + +Update `VERSION` and the matching changelog heading, then create an annotated +`vMAJOR.MINOR.PATCH` tag from a clean commit. The tag workflow +builds Linux and macOS binaries for amd64 and arm64 with CGO disabled, packages the +README and license, and writes `SHA256SUMS`. Downloaded archives must pass: + +```bash +sha256sum -c SHA256SUMS --ignore-missing +``` + +On macOS, the equivalent verification command is: + +```bash +shasum -a 256 -c SHA256SUMS +``` + +The workflow stamps each binary with the tag version; verify a downloaded binary with +`./lachesis-ui --version` before promoting it. + +The release workflow extracts the native Linux amd64 archive and performs that version +smoke test against the extracted binary; the other targets are cross-compiled and +verified by their reproducible archive hashes. + +Archive ordering, timestamps, ownership metadata, and the gzip header are normalized +from the tagged commit, so rebuilding the same tag produces identical archive bytes. + +The workflow uploads artifacts but does not publish a GitHub release or modify a +package registry. Promote the reviewed artifacts explicitly and retain the prior +version for rollback. Never overwrite a published tag; cut a new patch release. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/internal/mcp/client.go b/internal/mcp/client.go index 74cf2ed..e767206 100644 --- a/internal/mcp/client.go +++ b/internal/mcp/client.go @@ -13,6 +13,7 @@ import ( "encoding/json" "fmt" "io" + "os" "os/exec" "strings" "sync" @@ -53,6 +54,7 @@ type rpcError struct { // python is the interpreter that can import lachesis (see engine.Discover). func Spawn(python, graphPath string) (*Client, error) { cmd := exec.Command(python, "-m", "lachesis.nav.mcp_server", graphPath) + configureProcess(cmd) stdin, err := cmd.StdinPipe() if err != nil { @@ -78,11 +80,53 @@ func Spawn(python, graphPath string) (*Client, error) { } go c.drainStderr(stderr) - if err := c.initialize(); err != nil { + timeout := startupTimeout() + ready := make(chan error, 1) + go func() { ready <- c.initialize() }() + select { + case err := <-ready: + if err == nil { + return c, nil + } + err = withEngineLogs(err, c.Logs()) _ = c.Close() return nil, err + case <-time.After(timeout): + diagnostic := withEngineLogs( + fmt.Errorf("engine initialization timed out after %s", timeout), + c.Logs(), + ) + _ = c.Close() + return nil, diagnostic + } +} + +// withEngineLogs keeps startup failures actionable without putting stderr on the +// MCP stdout channel. In particular, a wrong interpreter/cwd and an unreadable +// graph otherwise look identical to a client as a silent process exit. +func withEngineLogs(err error, logs []string) error { + if len(logs) == 0 { + return err } - return c, nil + const maxLines = 20 + if len(logs) > maxLines { + logs = logs[len(logs)-maxLines:] + } + return fmt.Errorf("%w\nengine stderr:\n%s", err, strings.Join(logs, "\n")) +} + +const defaultStartupTimeout = 5 * time.Minute + +func startupTimeout() time.Duration { + value := os.Getenv("LACHESIS_UI_STARTUP_TIMEOUT") + if value == "" { + return defaultStartupTimeout + } + timeout, err := time.ParseDuration(value) + if err != nil || timeout <= 0 { + return defaultStartupTimeout + } + return timeout } func (c *Client) initialize() error { @@ -117,10 +161,10 @@ func (c *Client) Call(tool string, args map[string]any) (json.RawMessage, error) if _, ok := args["format"]; !ok { args["format"] = "json" } - raw, err := c.request("tools/call", map[string]any{ + raw, err := c.requestBounded("tools/call", map[string]any{ "name": tool, "arguments": args, - }) + }, tool) if err != nil { return nil, err } @@ -145,6 +189,49 @@ func (c *Client) Call(tool string, args map[string]any) (json.RawMessage, error) return json.RawMessage(text), nil } +const defaultRequestTimeout = 2 * time.Minute + +func requestTimeout() time.Duration { + value := os.Getenv("LACHESIS_UI_REQUEST_TIMEOUT") + if value == "" { + return defaultRequestTimeout + } + timeout, err := time.ParseDuration(value) + if err != nil || timeout <= 0 { + return defaultRequestTimeout + } + return timeout +} + +// requestBounded prevents a stalled engine query from leaving the terminal UI +// permanently waiting. Once a request exceeds the bound, the engine is closed so +// queued calls cannot continue against an unresponsive process; the stderr tail is +// retained in the returned error for an actionable diagnosis. +func (c *Client) requestBounded(method string, params any, label string) (json.RawMessage, error) { + result := make(chan struct { + raw json.RawMessage + err error + }, 1) + go func() { + raw, err := c.request(method, params) + result <- struct { + raw json.RawMessage + err error + }{raw: raw, err: err} + }() + timeout := requestTimeout() + timer := time.NewTimer(timeout) + defer timer.Stop() + select { + case r := <-result: + return r.raw, r.err + case <-timer.C: + err := withEngineLogs(fmt.Errorf("%s timed out after %s", label, timeout), c.Logs()) + _ = c.Close() + return nil, err + } +} + // request performs one synchronous JSON-RPC round trip. func (c *Client) request(method string, params any) (json.RawMessage, error) { c.mu.Lock() @@ -171,7 +258,7 @@ func (c *Client) request(method string, params any) (json.RawMessage, error) { line, err := c.stdout.ReadBytes('\n') if err != nil { if len(line) == 0 { - return nil, fmt.Errorf("read %s: %w", method, err) + return nil, withEngineLogs(fmt.Errorf("read %s: %w", method, err), c.Logs()) } } line = trimLine(line) @@ -232,7 +319,7 @@ func (c *Client) Close() error { case err := <-done: return err case <-time.After(2 * time.Second): - _ = c.cmd.Process.Kill() + _ = killProcessTree(c.cmd) return nil } } diff --git a/internal/mcp/engine.go b/internal/mcp/engine.go index a77d9c1..e81d684 100644 --- a/internal/mcp/engine.go +++ b/internal/mcp/engine.go @@ -18,8 +18,14 @@ func DiscoverPython() string { if p := os.Getenv("LACHESIS_PYTHON"); p != "" { return p } - if home, err := os.UserHomeDir(); err == nil { - venv := filepath.Join(home, ".lachesis", "venv", "bin", "python") + installRoot := os.Getenv("LACHESIS_HOME") + if installRoot == "" { + if home, err := os.UserHomeDir(); err == nil { + installRoot = filepath.Join(home, ".lachesis") + } + } + if installRoot != "" { + venv := filepath.Join(installRoot, "venv", "bin", "python") if fileExists(venv) { return venv } @@ -37,6 +43,9 @@ func GraphsDir() string { if d := os.Getenv("LACHESIS_GRAPHS_DIR"); d != "" { return d } + if root := os.Getenv("LACHESIS_HOME"); root != "" { + return filepath.Join(root, "graphs") + } home, err := os.UserHomeDir() if err != nil { return ".lachesis/graphs" @@ -68,6 +77,9 @@ func ListGraphs() []Graph { if !strings.HasSuffix(name, ".kuzu") { continue // skips ".kuzu.enriched" and everything else } + if !e.IsDir() { + continue // ignore partial files and other non-store artifacts + } info, err := e.Info() var mod int64 if err == nil { diff --git a/internal/mcp/engine_test.go b/internal/mcp/engine_test.go new file mode 100644 index 0000000..6932670 --- /dev/null +++ b/internal/mcp/engine_test.go @@ -0,0 +1,100 @@ +package mcp + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestGraphsDirHonorsInstallRoot(t *testing.T) { + t.Setenv("LACHESIS_GRAPHS_DIR", "") + t.Setenv("LACHESIS_HOME", "/tmp/lachesis-install") + if got, want := GraphsDir(), filepath.Join("/tmp/lachesis-install", "graphs"); got != want { + t.Fatalf("GraphsDir() = %q, want %q", got, want) + } +} + +func TestGraphsDirExplicitOverrideWins(t *testing.T) { + t.Setenv("LACHESIS_HOME", "/tmp/lachesis-install") + t.Setenv("LACHESIS_GRAPHS_DIR", "/tmp/custom-graphs") + if got, want := GraphsDir(), "/tmp/custom-graphs"; got != want { + t.Fatalf("GraphsDir() = %q, want %q", got, want) + } +} + +func TestDiscoverPythonHonorsInstallRoot(t *testing.T) { + root := t.TempDir() + venvPython := filepath.Join(root, "venv", "bin", "python") + if err := os.MkdirAll(filepath.Dir(venvPython), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(venvPython, []byte(""), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("LACHESIS_PYTHON", "") + t.Setenv("LACHESIS_HOME", root) + if got := DiscoverPython(); got != venvPython { + t.Fatalf("DiscoverPython() = %q, want %q", got, venvPython) + } +} + +func TestListGraphsSkipsNonStoreArtifacts(t *testing.T) { + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, "valid.kuzu"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "partial.kuzu"), []byte("incomplete"), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("LACHESIS_GRAPHS_DIR", root) + graphs := ListGraphs() + if len(graphs) != 1 || graphs[0].Name != "valid" { + t.Fatalf("ListGraphs() = %#v, want only valid.kuzu", graphs) + } +} + +func TestStartupTimeoutIsConfigurable(t *testing.T) { + t.Setenv("LACHESIS_UI_STARTUP_TIMEOUT", "17s") + if got, want := startupTimeout(), 17*time.Second; got != want { + t.Fatalf("startupTimeout() = %s, want %s", got, want) + } + t.Setenv("LACHESIS_UI_STARTUP_TIMEOUT", "not-a-duration") + if got := startupTimeout(); got != defaultStartupTimeout { + t.Fatalf("invalid timeout = %s, want default %s", got, defaultStartupTimeout) + } +} + +func TestRequestTimeoutIsConfigurable(t *testing.T) { + t.Setenv("LACHESIS_UI_REQUEST_TIMEOUT", "17s") + if got, want := requestTimeout(), 17*time.Second; got != want { + t.Fatalf("requestTimeout() = %s, want %s", got, want) + } + t.Setenv("LACHESIS_UI_REQUEST_TIMEOUT", "not-a-duration") + if got := requestTimeout(); got != defaultRequestTimeout { + t.Fatalf("invalid timeout = %s, want default %s", got, defaultRequestTimeout) + } +} + +func TestWithEngineLogsIncludesRecentDiagnostics(t *testing.T) { + err := withEngineLogs(errors.New("startup failed"), []string{"[lachesis-mcp] starting", "graph missing"}) + if got := err.Error(); got != "startup failed\nengine stderr:\n[lachesis-mcp] starting\ngraph missing" { + t.Fatalf("withEngineLogs() = %q", got) + } +} + +func TestWithEngineLogsBoundsOutput(t *testing.T) { + logs := make([]string, 25) + for i := range logs { + logs[i] = filepath.Base(t.TempDir()) + } + got := withEngineLogs(os.ErrClosed, logs).Error() + if len(got) == 0 { + t.Fatal("withEngineLogs returned an empty error") + } + if count := len(strings.Split(got, "\n")); count > 22 { + t.Fatalf("diagnostic grew beyond bounded stderr tail: %d lines", count) + } +} diff --git a/internal/mcp/process_other.go b/internal/mcp/process_other.go new file mode 100644 index 0000000..df48b28 --- /dev/null +++ b/internal/mcp/process_other.go @@ -0,0 +1,16 @@ +//go:build !(aix || darwin || dragonfly || freebsd || illumos || linux || netbsd || openbsd || solaris) + +package mcp + +import "os/exec" + +// Non-POSIX runners do not expose a portable process-group API here. The parent +// process is still bounded and killed, matching the pre-group behavior. +func configureProcess(_ *exec.Cmd) {} + +func killProcessTree(cmd *exec.Cmd) error { + if cmd == nil || cmd.Process == nil { + return nil + } + return cmd.Process.Kill() +} diff --git a/internal/mcp/process_posix.go b/internal/mcp/process_posix.go new file mode 100644 index 0000000..17eef6f --- /dev/null +++ b/internal/mcp/process_posix.go @@ -0,0 +1,24 @@ +//go:build aix || darwin || dragonfly || freebsd || illumos || linux || netbsd || openbsd || solaris + +package mcp + +import ( + "os/exec" + "syscall" +) + +// configureProcess puts the engine in its own process group so a timeout cannot +// leave a compiler or helper subprocess running after the UI closes the parent. +func configureProcess(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} +} + +func killProcessTree(cmd *exec.Cmd) error { + if cmd == nil || cmd.Process == nil { + return nil + } + if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL); err != nil { + return cmd.Process.Kill() + } + return nil +} diff --git a/internal/mcp/process_posix_test.go b/internal/mcp/process_posix_test.go new file mode 100644 index 0000000..6b7a262 --- /dev/null +++ b/internal/mcp/process_posix_test.go @@ -0,0 +1,16 @@ +//go:build aix || darwin || dragonfly || freebsd || illumos || linux || netbsd || openbsd || solaris + +package mcp + +import ( + "os/exec" + "testing" +) + +func TestConfigureProcessUsesDedicatedGroup(t *testing.T) { + cmd := exec.Command("sh", "-c", "true") + configureProcess(cmd) + if cmd.SysProcAttr == nil || !cmd.SysProcAttr.Setpgid { + t.Fatal("engine command is not configured with a dedicated process group") + } +} diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index e372361..e23c647 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -6,8 +6,10 @@ import ( "strings" ) -// Version is stamped into the client handshake and the --version flag. -const Version = "0.1.0" +// Version is stamped into the client handshake and the --version flag. Release +// builds replace it with the tag version via -ldflags; source builds retain the +// current development default. +var Version = "0.1.0" // ---- shared row shapes (as the engine emits them) ------------------------- diff --git a/main.go b/main.go index 0887230..de1223f 100644 --- a/main.go +++ b/main.go @@ -51,7 +51,8 @@ func main() { if err != nil { fatal(fmt.Sprintf("could not start the engine: %v\n\n"+ "The UI needs the lachesis engine (Python). Install it, or point at it:\n"+ - " pip install lachesis # or your project's install\n"+ + " python -m pip install -e /path/to/lachesis # source checkout\n"+ + " python -m pip install lachesis-cpg # once the release is published\n"+ " lachesis-ui --python /path/to/python\n"+ " LACHESIS_PYTHON=/path/to/python lachesis-ui", err)) } @@ -75,9 +76,13 @@ func resolveGraph(flagVal string, args []string) (path, name string, err error) if aerr != nil { return "", "", aerr } - if _, serr := os.Stat(abs); serr != nil { + info, serr := os.Stat(abs) + if serr != nil { return "", "", fmt.Errorf("graph not found: %s", candidate) } + if !info.IsDir() { + return "", "", fmt.Errorf("graph store is not a directory: %s", candidate) + } return abs, graphNameOf(abs), nil } graphs := mcp.ListGraphs() diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..130cb31 --- /dev/null +++ b/main_test.go @@ -0,0 +1,19 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestResolveGraphRejectsRegularFiles(t *testing.T) { + path := filepath.Join(t.TempDir(), "partial.kuzu") + if err := os.WriteFile(path, []byte("partial"), 0o644); err != nil { + t.Fatal(err) + } + _, _, err := resolveGraph(path, nil) + if err == nil || !strings.Contains(err.Error(), "not a directory") { + t.Fatalf("resolveGraph() error = %v, want a directory diagnostic", err) + } +} diff --git a/scripts/install.sh b/scripts/install.sh index 917a6f4..10f34b3 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -19,20 +19,65 @@ # ~/.lachesis/bin/lachesis-ui the built UI binary # ~/.lachesis/build-graph.sh helper: build a graph from any source tree # -# Re-running is safe: it updates existing checkouts instead of recloning. +# Re-running is safe: it updates existing clean checkouts to the requested refs +# instead of silently retaining an older branch or commit. # # Env overrides: # LACHESIS_HOME install root (default: ~/.lachesis) # PYTHON python to build the venv (default: python3) +# LACHESIS_UI_REF release tag/commit for the go-install fallback (default: v0.1.0) +# LACHESIS_UI_VERSION version stamped into a source-built binary (default: 0.1.0) +# LACHESIS_BUILD_TIMEOUT maximum seconds for one generated graph build (default: 3600) # set -euo pipefail +# The installer is intended for terminals and CI alike. Never leave an unattended +# setup waiting for Git credentials; the public repositories below should fail fast +# when the runner has no network or credentials rather than hanging indefinitely. +export GIT_TERMINAL_PROMPT=0 +# Fail slow or stalled HTTPS transfers instead of leaving an unattended bootstrap +# waiting forever. These are Git's documented low-speed guards and do not affect +# local checkouts or SSH refs. +export GIT_HTTP_LOW_SPEED_LIMIT="${GIT_HTTP_LOW_SPEED_LIMIT:-1000}" +export GIT_HTTP_LOW_SPEED_TIME="${GIT_HTTP_LOW_SPEED_TIME:-60}" +# Keep pip noninteractive and bound its index/download wait during the engine setup. +export PIP_NO_INPUT=1 +export PIP_DISABLE_PIP_VERSION_CHECK=1 +export PIP_DEFAULT_TIMEOUT="${PIP_DEFAULT_TIMEOUT:-60}" + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +if [ "${1:-}" = "--help" ] || [ "${1:-}" = "-h" ]; then + sed -n '2,/^set -euo pipefail$/p' "$0" + cat <<'USAGE' + +Usage: ./scripts/install.sh + +Configure refs with LACHESIS_REF, ATROPOS_REF, and LACHESIS_UI_REF, and the +installation root with LACHESIS_HOME. The script has no positional arguments. +USAGE + exit 0 +fi +if [ "$#" -ne 0 ]; then + die() { printf '\033[31merror:\033[0m %s\n' "$*" >&2; exit 1; } + die "unknown argument '$1' (try --help)" +fi + LACHESIS_HOME="${LACHESIS_HOME:-$HOME/.lachesis}" SRC="$LACHESIS_HOME/src" VENV="$LACHESIS_HOME/venv" BIN="$LACHESIS_HOME/bin" GRAPHS="$LACHESIS_HOME/graphs" PYTHON="${PYTHON:-python3}" +LACHESIS_REF="${LACHESIS_REF:-main}" +ATROPOS_REF="${ATROPOS_REF:-main}" +LACHESIS_UI_REF="${LACHESIS_UI_REF:-v0.1.0}" +DEFAULT_UI_VERSION="0.1.0" +if [ -f "$HERE/VERSION" ]; then + DEFAULT_UI_VERSION="$(tr -d '[:space:]' < "$HERE/VERSION")" +fi +LACHESIS_UI_VERSION="${LACHESIS_UI_VERSION:-$DEFAULT_UI_VERSION}" +LACHESIS_BUILD_TIMEOUT="${LACHESIS_BUILD_TIMEOUT:-3600}" LACHESIS_REPO="https://github.com/UnboundCompute/lachesis.git" ATROPOS_REPO="https://github.com/UnboundCompute/atropos.git" @@ -41,28 +86,103 @@ info() { printf '\033[36m==>\033[0m %s\n' "$*"; } warn() { printf '\033[33m warn:\033[0m %s\n' "$*" >&2; } die() { printf '\033[31merror:\033[0m %s\n' "$*" >&2; exit 1; } +case "$LACHESIS_BUILD_TIMEOUT" in + ''|*[!0-9]*|0) die "LACHESIS_BUILD_TIMEOUT must be a positive integer" ;; +esac + +validate_ref() { + local label="$1" ref="$2" + case "$ref" in + ""|[-]*|*[[:space:]]*) + die "$label must be a single Git ref (no leading '-' or whitespace)" + ;; + esac +} + +validate_ref LACHESIS_REF "$LACHESIS_REF" +validate_ref ATROPOS_REF "$ATROPOS_REF" +validate_ref LACHESIS_UI_REF "$LACHESIS_UI_REF" + +if [ "$LACHESIS_REF" = "main" ]; then + warn "LACHESIS_REF=main is mutable; pin a reviewed engine tag or commit for reproducible installs" +fi +if [ "$ATROPOS_REF" = "main" ]; then + warn "ATROPOS_REF=main is mutable; pin a reviewed catalog tag or commit for reproducible installs" +fi + need() { command -v "$1" >/dev/null 2>&1 || die "missing required tool: $1"; } # ---- preflight ------------------------------------------------------------ need git need "$PYTHON" -command -v go >/dev/null 2>&1 || warn "go not found. Install Go 1.24+ to build the UI (a 'go install' fallback is tried only if you have go)" +"$PYTHON" - <<'PY' +import sys + +if sys.version_info < (3, 10): + raise SystemExit( + f"Python 3.10+ is required (found {sys.version_info.major}.{sys.version_info.minor})" + ) +PY +need go +"$PYTHON" - "$(go version)" <<'PY' +import re +import sys + +match = re.search(r"go(\d+)\.(\d+)(?:\.(\d+))?", sys.argv[1]) +if match is None: + raise SystemExit(f"could not determine Go version from: {sys.argv[1]}") +found = tuple(int(part or 0) for part in match.groups()) +if found < (1, 24, 2): + raise SystemExit( + f"Go 1.24.2+ is required (found {found[0]}.{found[1]}.{found[2]})" + ) +PY mkdir -p "$SRC" "$BIN" "$GRAPHS" +# A second installer can otherwise race a checkout update or pip install and leave +# the shared stack half-written. mkdir is atomic and available on macOS and Linux. +INSTALL_LOCK="$LACHESIS_HOME/.install.lock" +if ! mkdir "$INSTALL_LOCK" 2>/dev/null; then + owner_pid="" + if [ -f "$INSTALL_LOCK/pid" ]; then + read -r owner_pid < "$INSTALL_LOCK/pid" || true + fi + if [ -n "$owner_pid" ] && ! kill -0 "$owner_pid" 2>/dev/null; then + rm -f "$INSTALL_LOCK/pid" + rmdir "$INSTALL_LOCK" 2>/dev/null || true + fi + if ! mkdir "$INSTALL_LOCK" 2>/dev/null; then + die "another Lachesis installation is already running at $LACHESIS_HOME" + fi +fi +printf '%s\n' "$$" > "$INSTALL_LOCK/pid" +trap 'rm -f "$INSTALL_LOCK/pid"; rmdir "$INSTALL_LOCK" 2>/dev/null || true' EXIT + # ---- 1 + 2. clone/update engine and catalog (side by side) ---------------- clone_or_update() { - local url="$1" dir="$2" name="$3" + local url="$1" dir="$2" name="$3" ref="$4" if [ -d "$dir/.git" ]; then info "updating $name" - git -C "$dir" pull --ff-only --quiet || warn "$name: could not fast-forward (local changes?), leaving as is" + if [ -n "$(git -C "$dir" status --porcelain --untracked-files=all)" ]; then + die "$name: local changes found (including untracked files); commit or remove them before changing refs" + fi + git -C "$dir" fetch --depth 1 --quiet origin "$ref" \ + || die "$name: could not fetch ref '$ref'" + git -C "$dir" checkout --detach --quiet FETCH_HEAD \ + || die "$name: could not check out ref '$ref'" else info "cloning $name" - git clone --depth 1 --quiet "$url" "$dir" + git clone --depth 1 --quiet "$url" "$dir" \ + || die "$name: could not clone repository" + git -C "$dir" fetch --depth 1 origin "$ref" \ + || die "$name: could not fetch ref '$ref'" + git -C "$dir" checkout --detach --quiet FETCH_HEAD \ + || die "$name: could not check out ref '$ref'" fi } -clone_or_update "$LACHESIS_REPO" "$SRC/lachesis" "engine (lachesis)" -clone_or_update "$ATROPOS_REPO" "$SRC/atropos" "catalog (atropos)" +clone_or_update "$LACHESIS_REPO" "$SRC/lachesis" "engine (lachesis)" "$LACHESIS_REF" +clone_or_update "$ATROPOS_REPO" "$SRC/atropos" "catalog (atropos)" "$ATROPOS_REF" # ---- 3. engine virtualenv ------------------------------------------------- if [ ! -x "$VENV/bin/python" ]; then @@ -71,37 +191,47 @@ if [ ! -x "$VENV/bin/python" ]; then fi info "installing the engine into the virtualenv" "$VENV/bin/python" -m pip install --quiet --upgrade pip +info "vendoring the pinned TypeScript compiler" +"$PYTHON" "$SRC/lachesis/tools/vendor_typescript.py" +"$PYTHON" "$SRC/lachesis/tools/vendor_typescript.py" --check "$VENV/bin/python" -m pip install --quiet -e "$SRC/lachesis" # ---- 4. graph-build helper ------------------------------------------------ info "writing $LACHESIS_HOME/build-graph.sh" -cat > "$LACHESIS_HOME/build-graph.sh" <<'HELPER' +cat > "$LACHESIS_HOME/build-graph.sh" < [name] -# Builds a lachesis graph and prints the store path (~/.lachesis/graphs/.kuzu). +# Builds a lachesis graph and prints the store path. set -euo pipefail -SRC="${1:?usage: build-graph.sh [name]}" -SRC="$(cd "$SRC" && pwd)" -NAME="${2:-$(basename "$SRC")}" -OUT="$HOME/.lachesis/graphs/$NAME.kuzu" -export ATROPOS_ROOT="${ATROPOS_ROOT:-$HOME/.lachesis/src/atropos}" -"$HOME/.lachesis/venv/bin/lachesis-analyze" "$SRC" "$OUT" --prune --timeout 3600 -echo "$OUT" +SOURCE_DIR="\${1:?usage: build-graph.sh [name]}" +if [ ! -d "\$SOURCE_DIR" ]; then + echo "error: source directory does not exist: \$SOURCE_DIR" >&2 + exit 2 +fi +SOURCE_DIR="\$(cd "\$SOURCE_DIR" && pwd)" +NAME="\${2:-\$(basename "\$SOURCE_DIR")}" +case "\$NAME" in + ""|"."|".."|*/*|*\\*) + echo "error: graph name must be a single path component (no '/' or '\\')" >&2 + exit 2 + ;; +esac +OUT="$GRAPHS/\$NAME.kuzu" +export ATROPOS_ROOT="\${ATROPOS_ROOT:-$SRC/atropos}" +"$VENV/bin/lachesis-analyze" "\$SOURCE_DIR" "\$OUT" --prune --timeout $LACHESIS_BUILD_TIMEOUT +echo "\$OUT" HELPER chmod +x "$LACHESIS_HOME/build-graph.sh" # ---- 5. build the UI ------------------------------------------------------ -HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -if command -v go >/dev/null 2>&1; then - if [ -f "$HERE/main.go" ]; then - info "building lachesis-ui from source checkout" - (cd "$HERE" && go build -o "$BIN/lachesis-ui" .) - else - info "installing lachesis-ui via go install" - GOBIN="$BIN" go install github.com/UnboundCompute/lachesis-ui@latest - fi +if [ -f "$HERE/main.go" ]; then + info "building lachesis-ui from source checkout" + (cd "$HERE" && go build -trimpath \ + -ldflags="-s -w -X github.com/UnboundCompute/lachesis-ui/internal/mcp.Version=$LACHESIS_UI_VERSION" \ + -o "$BIN/lachesis-ui" .) else - warn "skipped building the UI (no go). Install Go and run: go build -o $BIN/lachesis-ui ." + info "installing lachesis-ui via go install" + GOBIN="$BIN" go install "github.com/UnboundCompute/lachesis-ui@$LACHESIS_UI_REF" fi # ---- done -----------------------------------------------------------------