# loomcycle Makefile — dev-loop helpers.
#
# Targets fall into two groups:
#   1. Build / test the runtime ("test", "build", "test-pg").
#   2. Bring up local dependencies for the test fixtures ("pg-up", "pg-down").
#
# CI sets LOOMCYCLE_TEST_PG_DSN to a service-container Postgres and
# skips the pg-up/pg-down steps. Local devs run `make pg-up` once per
# session, then `make test` covers everything (Postgres tests skip
# automatically when the env var is unset).

# Container name + image used by pg-up. Override via env if needed:
#   PG_CONTAINER=my-pg make pg-up
PG_CONTAINER ?= loomcycle-test-pg
PG_IMAGE     ?= postgres:16-alpine
PG_PORT      ?= 5432
PG_DATABASE  ?= loomcycle_test
PG_USER      ?= loomcycle
PG_PASSWORD  ?= loomcycle

PG_DSN := postgres://$(PG_USER):$(PG_PASSWORD)@127.0.0.1:$(PG_PORT)/$(PG_DATABASE)?sslmode=disable

.PHONY: help build build-ui build-all test test-pg pg-up pg-down pg-logs proto proto-deps python-proto python-test memory-eval-mock memory-eval-live

help:
	@echo "loomcycle dev targets:"
	@echo "  build       — go build -o bin/loomcycle ./cmd/loomcycle (the runnable binary)"
	@echo "  build-ui    — npm ci + npm build for web/ (output → internal/webui/dist/)"
	@echo "  build-all   — build-ui + build → ./bin/loomcycle with the latest UI embedded (use this to deploy)"
	@echo "  test        — go test ./... (Postgres tests skip without LOOMCYCLE_TEST_PG_DSN)"
	@echo "  test-pg     — go test ./... with LOOMCYCLE_TEST_PG_DSN set against the local fixture"
	@echo "  memory-eval-mock — the memory consolidation + retrieval eval gate (offline, no key)"
	@echo "  memory-eval-live — the memory extraction eval against a REAL model (needs a provider; MEMEVAL_MODEL=…)"
	@echo "  pg-up       — start an ephemeral Postgres container for the test fixture"
	@echo "  pg-down     — stop + remove the test fixture container"
	@echo "  pg-logs     — tail the test fixture container's logs"
	@echo "  proto       — regenerate Go gRPC stubs from proto/loomcycle.proto"
	@echo "  proto-deps  — install the Go protoc plugins (one-time)"
	@echo ""
	@echo "Local DSN: $(PG_DSN)"

# Produce the runnable binary at ./bin/loomcycle. This MUST be the
# `-o bin/loomcycle ./cmd/loomcycle` form: `go build ./...` is only a
# compile check and writes NO executable (Go discards binaries when
# building multiple packages), so the old target silently left a stale
# ./bin/loomcycle in place — deploys shipped old code. The all-packages
# compile check lives in `go vet ./...` (run in CI alongside this).
build:
	go build -o bin/loomcycle ./cmd/loomcycle

build-ui:
	# Build the React SPA into internal/webui/dist/. The dist/ tree is
	# wiped first so old content-hashed assets don't accumulate, then
	# the .gitkeep placeholder is restored so go:embed always has at
	# least one matching file (a fresh checkout without npm-build still
	# compiles Go).
	#
	# `npm ci` (not `npm install`) so the lock file is treated as
	# authoritative AND is never mutated. The release.yml goreleaser
	# job fails on a dirty working tree; `npm install` would touch
	# web/package-lock.json on any silent drift and break the release.
	rm -rf internal/webui/dist/*
	# @loomcycle/library (RFC AY) is consumed from SOURCE via a Vite alias, so the
	# web build compiles the package's TSX and must resolve its react / js-yaml /
	# @loomcycle/client imports. Those resolve from packages/library/node_modules,
	# which is gitignored (absent on a fresh checkout / in CI) — install it first.
	cd packages/library && npm ci --silent
	# @loomcycle/explorer (RFC AZ) is consumed from SOURCE the same way — install
	# its deps (packages/explorer/node_modules is gitignored) before the web build.
	cd packages/explorer && npm ci --silent
	# @loomcycle/memory-view (RFC BV) is consumed from SOURCE the same way —
	# install its deps (packages/memory-view/node_modules is gitignored) before
	# the web build.
	cd packages/memory-view && npm ci --silent
	cd web && npm ci --silent && npm run build
	touch internal/webui/dist/.gitkeep

build-all: build-ui build

test:
	go test ./...

test-pg:
	@echo "Running tests with LOOMCYCLE_TEST_PG_DSN=$(PG_DSN)"
	LOOMCYCLE_TEST_PG_DSN="$(PG_DSN)" go test ./...

# memory-eval-mock: the memory eval gate — the consolidation pipeline's runtime
# invariants plus the retrieval-quality harness. Offline and hermetic: mock
# provider, stub embedder, in-memory SQLite. No network, no API key, no
# Postgres, seconds not minutes — so it is safe to run per-PR, unlike the
# runtime-* suites (which boot a binary and wait on cron fires) and unlike
# runtime-vector (which needs Postgres + pgvector).
#
# These tests are ALSO covered by plain `go test ./...`, deliberately: a gate
# that only fires behind a make target is not a gate. This target exists so a
# reviewer can run just the memory gate in a couple of seconds when touching
# the consolidation pipeline, the ranker, or dedup.
#
# The two halves live in different packages because they need different
# machinery — see internal/memory/eval/consolidation_fixtures.go for why.
memory-eval-mock:
	@set -e; \
	echo "=== memory eval: retrieval / erasure / dedup ==="; \
	go test ./internal/memory/eval/ -run 'TestConsolidationEval|TestCheckForbidden|TestRun_|TestDeterministicEmbedder|TestLoadJSONL' -count=1; \
	echo "=== memory eval: consolidation pipeline (real loop) ==="; \
	go test ./internal/api/http/ -run 'TestConsolidat' -count=1; \
	echo "=== memory eval: consolidation telemetry ==="; \
	go test ./internal/scheduler/ -run 'TestConsolidationTelemetry' -count=1; \
	echo "=== memory-eval-mock PASSED ==="

# memory-eval-live: the memory extraction eval against a REAL model — the other
# half of the gate. memory-eval-mock proves the consolidation PIPELINE with a
# scripted provider; this scores the extractor's JUDGEMENT: given a transcript,
# does the model write down the right things and refuse the wrong ones.
#
# NOT hermetic and NOT part of `test` or CI: it needs a reachable provider and it
# spends tokens. Run it before/after a change to the extractor prompt, its tier, or
# its model — which is the change the offline gate is blind to.
#
# Scores per ability (extraction / property / temporal / update / abstention) and
# gates on updates + abstention. --baseline compares against the committed numbers
# so a run is measured against a number rather than a memory; add MEMEVAL_UPDATE=1
# to record the result as the new baseline.
#
# MEMEVAL_SHOW_FACTS=1 prints every case's emitted facts, including the ones that
# passed — worth it on a first measurement, where a surprising result is often a
# PASS you want to read rather than a failure. MEMEVAL_EFFORT overrides the
# extractor's configured effort (on Ollama, effort=medium sets think:true, so a
# model that spends its whole reply reasoning is worth retrying at low).
#
#   make memory-eval-live MEMEVAL_MODEL=qwen3.6:27b
#   make memory-eval-live MEMEVAL_PROVIDER=anthropic MEMEVAL_MODEL=claude-haiku-4-5
#   make memory-eval-live MEMEVAL_MODEL=qwen3.6:27b MEMEVAL_SHOW_FACTS=1 MEMEVAL_UPDATE=1
MEMEVAL_PROVIDER ?= ollama-local
MEMEVAL_MODEL    ?=
MEMEVAL_PRESETS  ?= base,memory
MEMEVAL_BASELINE ?= internal/memory/eval/extraction-baseline.json
memory-eval-live:
	@test -n "$(MEMEVAL_MODEL)" || { \
	  echo "set MEMEVAL_MODEL — a score is only meaningful against a named model."; \
	  echo "  e.g. make memory-eval-live MEMEVAL_MODEL=qwen3.6:30b"; exit 2; }
	LOOMCYCLE_PRESETS=$(MEMEVAL_PRESETS) go run ./cmd/loomcycle memory-eval-live \
	  --provider $(MEMEVAL_PROVIDER) --model $(MEMEVAL_MODEL) \
	  --baseline $(MEMEVAL_BASELINE) \
	  $(if $(MEMEVAL_EFFORT),--effort $(MEMEVAL_EFFORT),) \
	  $(if $(MEMEVAL_ONTOLOGY_TERMS),--ontology-terms $(MEMEVAL_ONTOLOGY_TERMS),) \
	  $(if $(MEMEVAL_SHOW_FACTS),--show-facts,) \
	  $(if $(MEMEVAL_UPDATE),--update-baseline $(MEMEVAL_BASELINE),)

# runtime-mock: the fast, deterministic runtime suites (live binary, mock
# provider — no real provider, no API key, no Postgres). Each prints
# PASS/FAIL and exits non-zero on failure. NOT part of `test` / CI: these
# boot the binary and one waits ~60s for a real cron fire. memory-vector is
# excluded here (needs Postgres + pgvector); run it via runtime-vector.
runtime-mock:
	@set -e; for s in schedules webhooks memory-core code-js; do \
		echo "=== runtime/$$s ==="; ./test/runtime/$$s/run.sh; \
	done; echo "=== all runtime-mock suites PASSED ==="

# runtime-codejs: the FUNCTIONAL synthetic code-js provider (RFC J) suite.
# CI-friendly — fully deterministic (no LLM, the provider runs operator JS via
# goja), no API key, no Postgres, no long cron waits — so the CI workflow runs
# this target. Validates the code-js loop+replay end-to-end and the Memory
# meta-tool op surface. The STRESS behaviours (concurrency, the iteration-cap
# exemption, the run-timeout-bounded runaway) live in runtime-stress, which CI
# does NOT run — see that target.
runtime-codejs:
	@set -e; for s in code-js; do \
		echo "=== runtime/$$s ==="; ./test/runtime/$$s/run.sh; \
	done; echo "=== all runtime-codejs suites PASSED ==="

# runtime-stress / runtime-soak: LOCAL-ONLY, on-demand. Load, stress, and
# soak / sustainability suites are deliberately kept OUT of CI — they are
# slower, concurrency- and timing-sensitive, and meant to run on the operator's
# own machine where load characteristics are controlled. CI runs only the fast,
# deterministic functional suites above.
runtime-stress:
	@set -e; for s in code-js-stress; do \
		echo "=== runtime/$$s ==="; ./test/runtime/$$s/run.sh; \
	done; echo "=== all runtime-stress suites PASSED (local-only; not run in CI) ==="

# runtime-vector: the Memory vector + dedup suite against Postgres+pgvector
# with the deterministic stub embedder. Provide LOOMCYCLE_TEST_PG_DSN
# pointing at a Postgres that has the `vector` extension (it SKIPs without).
runtime-vector:
	./test/runtime/memory-vector/run.sh

# runtime-soak: the 30-minute stability test (override duration with
# LOOMCYCLE_SOAK_SECONDS). On-demand only.
runtime-soak:
	./test/runtime/stability/run.sh

# pg-up: bring up an ephemeral Postgres container for the test suite.
# The data volume lives inside the container (no host-bind) so a
# `docker rm` after pg-down leaves no state behind. The exposed port
# is 5432 by default; override PG_PORT if it conflicts.
#
# Idempotent: if the container is already running, this prints a
# notice and exits 0. If it's stopped (after pg-down), `docker start`
# revives it.
pg-up:
	@if docker ps --format '{{.Names}}' | grep -q '^$(PG_CONTAINER)$$'; then \
		echo "$(PG_CONTAINER) already running"; \
	elif docker ps -a --format '{{.Names}}' | grep -q '^$(PG_CONTAINER)$$'; then \
		docker start $(PG_CONTAINER) >/dev/null && echo "$(PG_CONTAINER) restarted"; \
	else \
		docker run -d --name $(PG_CONTAINER) \
			-e POSTGRES_USER=$(PG_USER) \
			-e POSTGRES_PASSWORD=$(PG_PASSWORD) \
			-e POSTGRES_DB=$(PG_DATABASE) \
			-p $(PG_PORT):5432 \
			$(PG_IMAGE) >/dev/null && \
		echo "$(PG_CONTAINER) started on port $(PG_PORT)"; \
	fi
	@echo "Waiting for Postgres to accept connections..."
	@for i in $$(seq 1 30); do \
		if docker exec $(PG_CONTAINER) pg_isready -U $(PG_USER) -d $(PG_DATABASE) >/dev/null 2>&1; then \
			echo "Postgres ready"; \
			echo "Export LOOMCYCLE_TEST_PG_DSN=\"$(PG_DSN)\" to run the suite manually."; \
			exit 0; \
		fi; \
		sleep 1; \
	done; \
	echo "Postgres did not become ready in 30s; check 'make pg-logs'" && exit 1

pg-down:
	@if docker ps -a --format '{{.Names}}' | grep -q '^$(PG_CONTAINER)$$'; then \
		docker rm -f -v $(PG_CONTAINER) >/dev/null && echo "$(PG_CONTAINER) removed"; \
	else \
		echo "$(PG_CONTAINER) not running"; \
	fi

pg-logs:
	docker logs -f $(PG_CONTAINER)

# proto / gRPC codegen.
#
# We commit the generated *.pb.go files into the tree (alongside the
# proto, under internal/api/grpc/loomcyclepb/) so a fresh checkout
# builds without first running protoc. Re-run `make proto` whenever
# proto/loomcycle.proto changes.
PROTO_OUT_DIR := internal/api/grpc/loomcyclepb
proto:
	@if ! command -v protoc >/dev/null 2>&1; then \
		echo "protoc not found; install via your package manager (e.g. brew install protobuf)"; \
		exit 1; \
	fi
	@if ! command -v protoc-gen-go >/dev/null 2>&1; then \
		echo "protoc-gen-go not found; run 'make proto-deps' first"; \
		exit 1; \
	fi
	mkdir -p $(PROTO_OUT_DIR)
	protoc \
		--go_out=$(PROTO_OUT_DIR) \
		--go_opt=paths=source_relative \
		--go-grpc_out=$(PROTO_OUT_DIR) \
		--go-grpc_opt=paths=source_relative \
		--proto_path=proto \
		proto/loomcycle.proto
	@echo "regenerated $(PROTO_OUT_DIR)/*.pb.go"

proto-deps:
	go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
	go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
	@echo ""
	@echo "Add $$(go env GOPATH)/bin to your PATH if it isn't already."

# Python adapter codegen + tests.
#
# python-proto regenerates the Python protobuf stubs into
# adapters/python/loomcycle/_generated/. Both files are committed so
# `pip install loomcycle` doesn't require a working protoc.
#
# python-test runs the adapter's pytest suite. Live-loomcycle
# integration tests skip without LOOMCYCLE_GRPC_ADDR set.
PY_VENV       := adapters/python/.venv
PY_PROTO_OUT  := adapters/python/loomcycle/_generated
python-proto:
	@if [ ! -x "$(PY_VENV)/bin/python" ]; then \
		echo "Python venv not found at $(PY_VENV); create it with:"; \
		echo "  python3 -m venv $(PY_VENV) && \\"; \
		echo "  $(PY_VENV)/bin/pip install -e adapters/python[dev]"; \
		exit 1; \
	fi
	mkdir -p $(PY_PROTO_OUT)
	$(PY_VENV)/bin/python -m grpc_tools.protoc \
		--python_out=$(PY_PROTO_OUT) \
		--grpc_python_out=$(PY_PROTO_OUT) \
		--proto_path=proto \
		proto/loomcycle.proto
	@# grpc_tools generates absolute imports (`import loomcycle_pb2`)
	@# rather than the relative form (`from . import loomcycle_pb2`)
	@# Python packages need. Patch the grpc stub so it's importable
	@# from the loomcycle._generated package without a sys.path hack.
	@sed -i '' 's/^import loomcycle_pb2 as loomcycle__pb2$$/from . import loomcycle_pb2 as loomcycle__pb2/' $(PY_PROTO_OUT)/loomcycle_pb2_grpc.py
	@touch $(PY_PROTO_OUT)/__init__.py
	@echo "regenerated $(PY_PROTO_OUT)/*.py"

python-test:
	$(PY_VENV)/bin/python -m pytest adapters/python/tests -v
