diff --git a/.gitignore b/.gitignore index dd3165b1f..b86d0dcbc 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,10 @@ go/lem # task build:embed output /bin/ +bench/last-run.json + +# generated at build:embed time — never commit compiled metallibs +cli/*.metallib.gz + +# built example binaries — never commit build output (the 783MiB HEAD ballast, #51) +examples/bin/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 000000000..a5e82d63c --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,77 @@ +# CI for go-inference on the git.lthn.sh homelab runner (shell executor, AMD box, +# concurrent = 1 → one job at a time). The junctures come from the shared gates in +# dappcore/devops/build — the gate owns WHEN, this file owns WHAT: +# +# push (gate/push.yml) → build: compile bin/lem for linux/amd64 (engine/hip cgo). +# pr (gate/pr.yml) → lint → test: gofmt+vet, then the portable suite. Test is +# skipped if lint fails; "Pipelines must succeed" gates the merge. +# release (gate/release.yml) → per-backend CLI binaries, on a git tag only. +# graphify (gitlab/graphify.yml) → auxiliary code-graph refresh (dev/main, allow_failure). +include: + - { project: 'dappcore/devops/build', file: '/gate/push.yml', ref: dev } + - { project: 'dappcore/devops/build', file: '/gate/pr.yml', ref: dev } + - { project: 'dappcore/devops/build', file: '/gate/release.yml', ref: dev } + - { project: 'dappcore/devops/build', file: '/gitlab/graphify.yml', ref: dev } + +# Run one pipeline per change: MR pipelines for MRs, tag pipelines for releases, +# branch pipelines otherwise — and never a redundant branch pipeline while an MR +# is open for that branch. +workflow: + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + - if: '$CI_COMMIT_TAG' + - if: '$CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS' + when: never + - if: '$CI_COMMIT_BRANCH' + +variables: + GOTOOLCHAIN: "auto" # box go is 1.22; go.mod's 1.26 directive auto-fetches + GIT_SUBMODULE_STRATEGY: none # a plain Go compile/test needs no vendored C sources + +# --- PUSH: standard flow — does it still build? ---------------------------- +build: + extends: .gate-push + script: + - go version + - make build + +# --- PR gate: lint THEN test (the test stage is skipped if lint fails) ------ +lint: + extends: .gate-lint + script: + - cd go + - test -z "$(gofmt -l .)" + - go vet ./... + +test: + extends: .gate-test + script: + - cd go + - go test -count=1 ./... + +# --- RELEASE: per-backend CLI binaries, on a tag --------------------------- +.release-cli: + artifacts: + name: "$CI_JOB_NAME-$CI_COMMIT_SHORT_SHA" + paths: [ build/bin/ ] + expire_in: 1 week + +lem-cpu-x86: + extends: [.gate-release, .release-cli] + script: [ make lem-cpu-x86 ] + +lem-cpu-aarch64: + extends: [.gate-release, .release-cli] + script: [ make lem-cpu-aarch64 ] + +lem-amd: + extends: [.gate-release, .release-cli] + variables: + GIT_SUBMODULE_STRATEGY: normal # GitLab inits the ROCm sources for the static archives + script: [ make lem-amd ] + +lem-cuda: + extends: [.gate-release, .release-cli] + variables: + GIT_SUBMODULE_STRATEGY: normal + script: [ make lem-cuda ] diff --git a/.graphifyignore b/.graphifyignore new file mode 100644 index 000000000..87f186d53 --- /dev/null +++ b/.graphifyignore @@ -0,0 +1,13 @@ +# .graphifyignore — scope the go-inference knowledge graph to OUR code. +# +# Same syntax as .gitignore (incl. ! negation). graphify already auto-respects +# .gitignore, so /build/, /bin/, .core/, *.test etc. are skipped. This file +# only ADDS exclusions. +# +# external/ is vendored mlx + ROCm C/C++ (git submodules — therefore NOT in +# .gitignore). It's a dependency, not go-inference's own code; indexing it +# roughly doubled the graph with headers like amd_hip_fp6.h / OCLBuffer*.h. +external/ + +# JS deps under the GUI (normally gitignored already; explicit for safety). +gui/node_modules/ diff --git a/CLAUDE.md b/CLAUDE.md index 4e084c709..1b63f8b06 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,7 @@ The sovereign inference engine for the Core Go ecosystem. Module: `dappco.re/go/ 1. **The shared contract** (`go/*.go`) — `TextModel`/`Backend` interfaces, options, registry, discovery. Backends import this; never the reverse. 2. **The engines** (`go/engine/`) — `engine/metal` (package `native`): the **no-cgo Apple GPU engine** (darwin/arm64, objc bridge via `github.com/tmc/apple`, ICB-replayed decode, MTP speculative pairs, paged SDPA, q8 KV); `engine/hip`: AMD ROCm (linux/amd64). Plus `cmd/lem` (the serving/bench binary), OpenAI/Anthropic/Ollama compat handlers, and conversation state (`-state`, no-prompt-replay). -`docs/` at repo root is the manual: `architecture.md`, `backends.md` (registry + **engine runtime levers**), `cmd-lem.md`, `build.md`, and **`handover.md` — read that one first; it is the working handover from the engine-perf campaigns.** +`docs/` at repo root is the manual: `architecture.md`, `backends.md` (registry + **engine runtime levers**), `cmd-lem.md`, `build.md`, `history.md`. Working state (handovers, design drafts, receipts, campaign perf notes) lives with the orchestrating agent, not here. ## Commands @@ -22,7 +22,8 @@ go test -count=1 ... # ALWAYS -count=1 for benchmarks — go test caches # kernel changes (from repo root) task metallib:kernels # 31 .metal files -> build/dist/lib/lthn_kernels.metallib -gzip -9 -c build/dist/lib/lthn_kernels.metallib > go/cmd/lem/lthn_kernels.metallib.gz # then rebuild lem +# metallibs are NEVER committed — cli/*.metallib.gz are generated by `task build:embed` +# (gitignored); the plain build resolves them externally via MLX_METALLIB_PATH # bench shape (model path is POSITIONAL, last) ../bin/lem generate -draft -temp 0 -max-tokens 400 [-context 20480 -prompt-file ] @@ -38,6 +39,17 @@ gzip -9 -c build/dist/lib/lthn_kernels.metallib > go/cmd/lem/lthn_kernels.metall - **No bulk perl/sed refactors** — one site at a time, vet after each. - Branch **dev**; origin `github.com/dAppCore/go-inference` (push non-force). Task board via the session task tools; git log is the history of record. +## Qwen hybrid — the FACTORY is the ONLY route (#18/#50) + +Since 2026-07-19 (`ae095829`) **qwen3_5 / qwen3_5_moe load through the factory** (`model.Assemble` + `arch_session`) with the fused whole-token chain decode (`engine/metal/arch_qwen_fused.go`): every layer (gated-delta / gated-attention, dense or MoE tail) encodes into ONE command buffer with resident state, CB-recorded and replayed per token where servable. It **beat the composed lane on both local hybrids** — 0.8B 217 vs 195 tok/s greedy, 35B-A3B 30 vs 19 — and #50 (2026-07-20) deleted `model/composed` outright: the factory is no longer the default, it is the only route, for every arch. + +- **Routing** (`engine/metal/load.go`): every registered arch through `model.Load`; mamba2/rwkv7 by name to their own loaders. Typed declines name the retired-composed capability gaps: sub-2-bit packs (Bonsai 1-bit — no factory qmv width, #24), the qwen MTP `-draft` pairing (`LoadSpeculativePair` declines hybrids; factory pair route pending), and image turns on the vision-towered 35B (factory serves the TEXT stack; the engine's not-a-vision-model refusal answers). `LTHN_QWEN_FUSED=0` forces the factory's host halves (the correctness reference). `LTHN_QWEN_COMPOSED` is gone with the lane. +- **The submit-ahead decode tails decline recurrent sessions** (`hasRecurrentLayers`): a speculative link past a stop cannot be unwound from a gated-delta recurrence. Serial tail only. +- **Replay bookkeeping**: `composedChainReplay` (historic name — it is the factory chain's recorder) advances the `attnKVDeviceState.n` counters ITSELF — bumping them again host-side double-advances the position and silently corrupts KV slot addressing (coherent-but-wrong text; caught by the live-walk A/B). +- **Activation is per-arch, not per-kernel.** `MoEExpertsQuant` is gemma's **GELU**; the qwen lane is **SiLU** (`MoEExpertsQuantSiLU`). Wrong binding = *coherent-but-wrong* text below the greedy argmax threshold. Gate on the byte-level test (`TestMoEExpertsQuantSiLU`), never on "the output reads fine". +- **A/B recipe** (from a worktree, GPU free): `task build` → `bin/lem`; `MLX_METALLIB_PATH=build/dist/lib/mlx.metallib bin/lem generate -temp 0 -max-tokens N `; greedy output is the correctness oracle. Run default vs `LTHN_QWEN_FUSED=0`. +- **#50 residue** (see `go/docs/composed-strip-inventory.md` for the full classification): the `composed_*`-named files in engine/metal ARE the factory chain machinery (rename = follow-up); `examples/pkg/packed-moe` retired with the composed API it demonstrated (a factory packed-MoE example is the follow-up); `examples/pkg/hybrid-quant` serves through the factory unchanged. Next gap to close: mlx-lm ~110 tok/s on the 35B vs our 30 — a unified-engine campaign. + ## Stability Rules (root contract) Changes to `go/*.go` interfaces affect every consumer simultaneously. diff --git a/Makefile b/Makefile index 0d544cd9e..f89a6860a 100644 --- a/Makefile +++ b/Makefile @@ -55,9 +55,13 @@ AMD_GOARCH ?= amd64 CUDA_GOARCH ?= amd64 CPU_X86_GOARCH ?= amd64 CPU_AARCH64_GOARCH ?= arm64 +CPU_AARCH64_CC ?= aarch64-linux-gnu-gcc +CPU_AARCH64_CXX ?= aarch64-linux-gnu-g++ AMD_CGO_ENABLED ?= 1 CUDA_CGO_ENABLED ?= 1 -CPU_CGO_ENABLED ?= 0 +# DuckDB (the go-store/chathistory driver) requires cgo — like engine/hip, +# the linux lanes build with cgo on. Only the Apple metal engine is no-cgo. +CPU_CGO_ENABLED ?= 1 RELEASE_BINS := $(CLI_NAME)-amd $(CLI_NAME)-cuda $(CLI_NAME)-cpu-x86 $(CLI_NAME)-cpu-aarch64 RELEASE_ARCHIVES := $(addsuffix -linux.tar.gz,$(RELEASE_BINS)) RELEASE_SIDECARS = $(AMD_KERNEL_MODULE_NAME) $(CUDA_KERNEL_MODULE_NAME) $(CPU_X86_KERNEL_MODULE_NAME) $(CPU_AARCH64_KERNEL_MODULE_NAME) @@ -112,10 +116,13 @@ HIP_CPU_INCLUDE ?= /opt/hip-cpu/include HIP_CPU_CXX ?= g++ HIP_CPU_AARCH64_CXX ?= aarch64-linux-gnu-g++ HIP_CPU_STD ?= c++20 +HIP_PRODUCTION_GGUF ?= +HIP_PRODUCTION_HSACO ?= $(AMD_KERNEL_MODULE) +HIP_PRODUCTION_MOE ?= 0 .PHONY: all help build build-cli lem-rocm lthn-rocm named-binaries release-binaries release-dependency-guard release-artifacts dist static-hip-binaries rocr-cmake-shims hsa-static-archive hip-static-archive require-static-hip-archive hip-link-info lem-amd lem-cuda lem-cpu-x86 lem-cpu-aarch64 lthn-amd lthn-cuda lthn-cpu-x86 lthn-cpu-aarch64 test test-cli test-all clean \ hip hip-amd hip-nvidia hip-cpu hip-cpu-x86_64 hip-cpu-aarch64 \ - test-hip-amd test-hip-nvidia test-hip-cpu test-hip-cpu-runtime test-hip-cpu-kernel-runtime test-zluda-cuda \ + test-hip-amd test-hip-nvidia test-hip-cpu test-hip-cpu-runtime test-hip-cpu-kernel-runtime test-zluda-cuda test-hip-production \ compile-matrix test-matrix all: build @@ -131,6 +138,7 @@ help: ' named-binaries build all named release binaries' \ ' release-artifacts build archives and checksums under $(DIST_DIR)' \ ' test run the Go module test suite' \ + ' test-hip-production run the AMD Gemma4 release receipt (set HIP_PRODUCTION_GGUF)' \ ' test-matrix run the host suite plus every hip toolchain smoke, skipping what is absent' \ ' clean remove $(BUILD_DIR)' @@ -230,11 +238,12 @@ rocr-cmake-shims: ' set_target_properties(llvm-objcopy PROPERTIES IMPORTED_LOCATION "$(ROCR_LLVM_OBJCOPY)")' \ 'endif()' > "$(ROCR_CMAKE_SHIM_DIR_ABS)/llvm/LLVMConfig.cmake" -hsa-static-archive: rocr-cmake-shims - @test -d "$(ROCR_RUNTIME_SOURCE_DIR_ABS)" || { echo "missing ROCr runtime source submodule: $(ROCR_RUNTIME_SOURCE_DIR)"; exit 1; } +hsa-static-archive: + @test -f "$(ROCR_RUNTIME_SOURCE_DIR_ABS)/CMakeLists.txt" || { echo "ROCr runtime source submodule is not initialized: $(ROCR_RUNTIME_SOURCE_DIR)"; exit 1; } @test -n "$(DRM_AMDGPU_STATIC_ARCHIVE)" || { echo "missing static libdrm_amdgpu.a; install libdrm-amdgpu-dev"; exit 1; } @test -n "$(DRM_STATIC_ARCHIVE)" || { echo "missing static libdrm.a; install libdrm-dev"; exit 1; } @test -n "$(ELF_STATIC_ARCHIVE)" || { echo "missing static libelf.a; install libelf-dev"; exit 1; } + $(MAKE) --no-print-directory rocr-cmake-shims $(CMAKE) -S "$(ROCR_RUNTIME_SOURCE_DIR_ABS)" -B "$(ROCR_RUNTIME_BUILD_DIR_ABS)" -G "$(CMAKE_GENERATOR)" \ -DBUILD_SHARED_LIBS=OFF \ -DClang_DIR="$(ROCR_CMAKE_SHIM_DIR_ABS)/clang" \ @@ -247,8 +256,8 @@ hsa-static-archive: rocr-cmake-shims @test -s "$(ROCR_HSAKMT_STATIC_ARCHIVE)" || { echo "expected static HSAKMT archive was not produced: $(ROCR_HSAKMT_STATIC_ARCHIVE)"; exit 1; } hip-static-archive: - @test -d "$(HIP_API_SOURCE_DIR_ABS)" || { echo "missing HIP API source submodule: $(HIP_API_SOURCE_DIR)"; exit 1; } - @test -d "$(HIP_RUNTIME_SOURCE_DIR_ABS)" || { echo "missing HIP runtime source submodule: $(HIP_RUNTIME_SOURCE_DIR)"; exit 1; } + @test -d "$(HIP_API_SOURCE_DIR_ABS)/include" || { echo "HIP API source submodule not checked out: $(HIP_API_SOURCE_DIR) (ROCm/HIP is the HIP_COMMON_DIR headers repo — it has no CMakeLists.txt)"; exit 1; } + @test -f "$(HIP_RUNTIME_SOURCE_DIR_ABS)/CMakeLists.txt" || { echo "HIP runtime source submodule is not initialized: $(HIP_RUNTIME_SOURCE_DIR)"; exit 1; } $(CMAKE) -S "$(HIP_RUNTIME_SOURCE_DIR_ABS)" -B "$(HIP_RUNTIME_BUILD_DIR_ABS)" -G "$(CMAKE_GENERATOR)" \ -DCLR_BUILD_HIP=ON \ -DCLR_BUILD_OCL=OFF \ @@ -294,7 +303,7 @@ lem-cpu-x86: hip-cpu-x86_64 lem-cpu-aarch64: hip-cpu-aarch64 mkdir -p "$(BIN_DIR_ABS)" - CGO_ENABLED=$(CPU_CGO_ENABLED) GOOS=$(TARGET_GOOS) GOARCH=$(CPU_AARCH64_GOARCH) $(GO) -C "$(CLI_SUBTREE)" build -ldflags "$(CLI_GO_LDFLAGS)" -o "$(BIN_DIR_ABS)/$(CLI_NAME)-cpu-aarch64" "$(CLI_CMD)" + CGO_ENABLED=$(CPU_CGO_ENABLED) CC=$(CPU_AARCH64_CC) CXX=$(CPU_AARCH64_CXX) GOOS=$(TARGET_GOOS) GOARCH=$(CPU_AARCH64_GOARCH) $(GO) -C "$(CLI_SUBTREE)" build -ldflags "$(CLI_GO_LDFLAGS)" -o "$(BIN_DIR_ABS)/$(CLI_NAME)-cpu-aarch64" "$(CLI_CMD)" test: $(GO) -C "$(GO_SUBTREE)" test ./... -count=1 @@ -344,6 +353,34 @@ test-hip-cpu-kernel-runtime: test-zluda-cuda: GO_ROCM_RUN_ZLUDA_CUDA_TESTS=1 CUDA_PATH="$(CUDA_PATH)" CUDA_HOME="$(CUDA_HOME)" $(GO) -C "$(GO_SUBTREE)" test ./engine/hip -run TestHIPKernelSource_ZLUDACUDARuntimeSmoke_Good -count=1 +test-hip-production: + @test -n "$(strip $(HIP_PRODUCTION_GGUF))" || { echo "HIP_PRODUCTION_GGUF must name a local Gemma4 GGUF"; exit 1; } + @test -f "$(HIP_PRODUCTION_GGUF)" || { echo "HIP production GGUF not found: $(HIP_PRODUCTION_GGUF)"; exit 1; } + $(MAKE) --no-print-directory hip-amd + @test -f "$(HIP_PRODUCTION_HSACO)" || { echo "HIP production HSACO not found: $(HIP_PRODUCTION_HSACO)"; exit 1; } + $(GO) -C "$(GO_SUBTREE)" test ./engine/hip -count=1 -run '^TestScheduler_' + @set -o pipefail; \ + gguf="$$(realpath "$(HIP_PRODUCTION_GGUF)")"; \ + hsaco="$$(realpath "$(HIP_PRODUCTION_HSACO)")"; \ + tests='TestNativeDecodeSmokeKernelStatus_Good|TestHIPGemma4ExactStateContinuityHardware_Good|TestHIPLaneSetE2BHardwareMatchesSingleLanes_Good'; \ + if [ "$(HIP_PRODUCTION_MOE)" = "1" ]; then \ + tests="$$tests|TestHIPLaneSet26BMoEHardwareMatchesSingleLanes_Good"; \ + fi; \ + log="$$(mktemp)"; \ + trap 'rm -f "$$log"' EXIT; \ + HIP_VISIBLE_DEVICES="$${HIP_VISIBLE_DEVICES:-0}" \ + GO_ROCM_RUN_MODEL_TESTS=1 \ + GO_ROCM_RUN_MOE_LANE_TESTS="$(HIP_PRODUCTION_MOE)" \ + GO_ROCM_MODEL_PATH="$$gguf" \ + GO_ROCM_KERNEL_HSACO="$$hsaco" \ + $(GO) -C "$(GO_SUBTREE)" test ./engine/hip -count=1 -v -run "^($$tests)$$" | tee "$$log"; \ + status="$${PIPESTATUS[0]}"; \ + if grep -q -- '--- SKIP:' "$$log"; then \ + echo "HIP production receipt skipped a required hardware test"; \ + exit 1; \ + fi; \ + exit "$$status" + # test-matrix aggregates the host suite plus every hip toolchain smoke behind # one command. Each leg probes its own toolchain first and skips with a # one-line reason when it is absent, so the verb runs unattended on any box diff --git a/Taskfile.yml b/Taskfile.yml index 5692b099e..93b22f6f1 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -152,6 +152,12 @@ tasks: cmds: - go test -tags metal_runtime -count=1 ./... + bench:grid: + desc: "Run the locked gemma4 benchmark grid (bench/gemma4.json) via the lem bench verb. Needs build/dist/lib/*.metallib — run task metallib first." + dir: cli + cmds: + - MLX_METALLIB_PATH={{.ROOT_DIR}}/build/dist/lib/mlx.metallib go run -tags metal_runtime . bench --config {{.ROOT_DIR}}/bench/gemma4.json --json {{.ROOT_DIR}}/bench/last-run.json + cover: desc: "Portable coverage -> coverage.out (the profile codecov.yml reads; 95% target). Prints the total on the last line." dir: go diff --git a/bench/README.md b/bench/README.md new file mode 100644 index 000000000..6862caf02 --- /dev/null +++ b/bench/README.md @@ -0,0 +1,34 @@ + + +# bench/ — the reproducible benchmark grids + +`lem bench --config bench/gemma4.json --json out.json` runs the locked house +grid: per model × lane (plain, mtp), one untimed warmup then one timed greedy +tg-512, decode tok/s from the engine's own metrics. Model refs are HF cache +repo names, so the same config reproduces the grid on any box sharing the +cache convention (the metal Mac, the hip AMD box) — edit the JSON to shift +focus, never the code. + +Notes baked into gemma4.json: +- Drafters are EXPLICIT (auto-detection ambiguity broke the shell sweep this + verb replaces) and quant-matched where possible — the 2026-07-16 matrix + measured E4B's matched QAT draft at +47% vs +11% for the same draft + cross-paired onto the non-QAT base. e4b-4bit therefore runs plain-only. +- The 26B rows keep their mtp lane deliberately: the −70% MoE-verify loss is + #372's open tracking number, and the grid should show it until it's fixed. +- The default prompt (counting to 800) is the house tg-N workload: maximally + predictable output, so MTP acceptance reads at its CEILING — right for + comparability with the reference tables, wrong for estimating real-workload + gains (creative text measured 8-20% acceptance on the same pair, i.e. MTP + roughly break-even there). Bench a workload by shifting the prompt in a + config copy. +- QAT rows decode SLOWER than plain 4bit by design, not by engine defect: the + mlx-community `qat-4bit` checkpoints are mixed-precision — every + `mlp.{gate,up,down}_proj` carries a per-tensor 8-bit override (config.json + `quantization`), attention/embed/head stay 4-bit, group size 64 throughout. + Decode is weight-bandwidth-bound, so the extra bytes ARE the slowdown: + E2B 4.33 vs 3.55 GB (+22%) → measured −16%, E4B 6.80 vs 5.15 GB (+32%) → + −21%, 31B 28.8 vs 18.4 GB (+57%) → −32% — each at or slightly better than + the pure byte-ratio prediction (−18/−24/−36%), which is the receipt that the + q8 tensors ride the fast qmv path. Pick QAT for quality, plain 4bit for + speed; a grid row comparing them measures checkpoint mass, not the engine. diff --git a/bench/gemma4.json b/bench/gemma4.json new file mode 100644 index 000000000..089528225 --- /dev/null +++ b/bench/gemma4.json @@ -0,0 +1,16 @@ +{ + "prompt": "Write the integers from 1 to 800 separated by single spaces. Output only the numbers, nothing else.", + "tokens": 512, + "warmup": 16, + "runs": [ + {"name": "e2b-4bit", "model": "mlx-community/gemma-4-e2b-it-4bit", "draft": "mlx-community/gemma-4-E2B-it-assistant-bf16"}, + {"name": "e2b-qat4", "model": "mlx-community/gemma-4-E2B-it-qat-4bit", "draft": "mlx-community/gemma-4-E2B-it-assistant-bf16"}, + {"name": "e4b-4bit", "model": "mlx-community/gemma-4-e4b-it-4bit"}, + {"name": "e4b-qat4", "model": "mlx-community/gemma-4-E4B-it-qat-4bit", "draft": "mlx-community/gemma-4-E4B-it-qat-assistant-4bit"}, + {"name": "12b-4bit", "model": "mlx-community/gemma-4-12B-it-4bit", "draft": "mlx-community/gemma-4-12B-it-assistant-bf16"}, + {"name": "26b-4bit", "model": "mlx-community/gemma-4-26b-a4b-it-4bit", "draft": "mlx-community/gemma-4-26B-A4B-it-qat-assistant-4bit"}, + {"name": "26b-qat4", "model": "mlx-community/gemma-4-26B-A4B-it-qat-4bit","draft": "mlx-community/gemma-4-26B-A4B-it-qat-assistant-4bit"}, + {"name": "31b-4bit", "model": "mlx-community/gemma-4-31B-it-4bit", "draft": "mlx-community/gemma-4-31B-it-assistant-bf16"}, + {"name": "31b-qat4", "model": "mlx-community/gemma-4-31B-it-qat-4bit", "draft": "mlx-community/gemma-4-31B-it-assistant-bf16"} + ] +} diff --git a/cli/bench.go b/cli/bench.go new file mode 100644 index 000000000..01af8e5da --- /dev/null +++ b/cli/bench.go @@ -0,0 +1,210 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package main + +import ( + "context" + "flag" + "io" + + core "dappco.re/go" + "dappco.re/go/inference" + "dappco.re/go/inference/eval/bench" +) + +// runBenchCommand parses the bench flags and drives bench.RunMatrix — the reproducible benchmark +// grid: models as DATA (a bench.json runs array, or positional refs), the timing discipline in +// eval/bench, this file only the composition root (it owns the engine loaders). The same verb + the +// same config reproduce the grid on every box the binary builds for, which is what makes numbers +// comparable across builds and machines (bench/gemma4.json is the locked house grid). +func runBenchCommand(ctx context.Context, args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet(cliCommandName("bench"), flag.ContinueOnError) + fs.SetOutput(stderr) + configPath := fs.String("config", "", "bench.json matrix config (runs array); positional model refs are used instead when absent") + tokens := fs.Int("tokens", 512, "tokens per timed generation (the N of tg-N)") + warmup := fs.Int("warmup", 16, "untimed warmup generation's token budget") + prompt := fs.String("prompt", "", "generation prompt (empty = the harness default)") + draft := fs.String("draft", "", "MTP drafter ref for positional models (adds the mtp lane); explicit by design — no auto-detection") + draftBlock := fs.Int("draft-block", 0, "MTP draft block; 0 = engine default") + jsonOut := fs.String("json", "", "write the full MatrixReport JSON to this file") + fs.Usage = func() { + name := cliName() + core.WriteString(stderr, core.Sprintf("Usage: %s bench [flags] [model-ref ...]\n", name)) + core.WriteString(stderr, "\n") + core.WriteString(stderr, "Run the benchmark matrix: per model x lane (plain, mtp), one untimed warmup\n") + core.WriteString(stderr, "then one timed greedy generation, reporting the engine's own decode tok/s.\n") + core.WriteString(stderr, "Model refs are snapshot paths or HF cache repo names (org/name).\n") + core.WriteString(stderr, "\n") + core.WriteString(stderr, "Flags:\n") + printFlagBlock(stderr, fs) + core.WriteString(stderr, "\n") + core.WriteString(stderr, "Examples:\n") + core.WriteString(stderr, core.Sprintf(" %s bench --config bench/gemma4.json --json bench-out.json\n", name)) + core.WriteString(stderr, " # the locked gemma4 grid, table + machine-readable report\n") + core.WriteString(stderr, core.Sprintf(" %s bench mlx-community/gemma-4-e2b-it-4bit --draft mlx-community/gemma-4-E2B-it-assistant-bf16\n", name)) + core.WriteString(stderr, " # one model, plain + mtp lanes\n") + } + if err := fs.Parse(args); err != nil { + if core.Is(err, flag.ErrHelp) { + return 0 + } + return 2 + } + + var cfg bench.MatrixConfig + switch { + case *configPath != "": + read := core.ReadFile(*configPath) + if !read.OK { + core.Print(stderr, "%s bench: read --config %s: %s", cliName(), *configPath, read.Error()) + return 1 + } + data := read.Bytes() + if len(data) == 0 { + core.Print(stderr, "%s bench: --config %s is empty", cliName(), *configPath) + return 1 + } + parsed, err := bench.LoadMatrixConfig(data) + if err != nil { + core.Print(stderr, "%s bench: %v", cliName(), err) + return 1 + } + cfg = parsed + case fs.NArg() > 0: + for _, ref := range fs.Args() { + cfg.Runs = append(cfg.Runs, bench.MatrixRun{Model: ref, Draft: *draft, DraftBlock: *draftBlock}) + } + default: + core.WriteString(stderr, core.Sprintf("%s bench: pass --config or at least one model ref\n", cliName())) + fs.Usage() + return 2 + } + // Flag overrides apply on top of the config's own values only when explicitly set. + if *tokens != 512 || cfg.Tokens == 0 { + cfg.Tokens = *tokens + } + if *warmup != 16 || cfg.Warmup == 0 { + cfg.Warmup = *warmup + } + if *prompt != "" { + cfg.Prompt = *prompt + } + + report, err := bench.RunMatrix(ctx, cfg, benchMatrixLoad, stdout) + if err != nil { + core.Print(stderr, "%s bench: %v", cliName(), err) + return 1 + } + if *jsonOut != "" { + data := core.JSONMarshalIndent(report, "", " ") + if !data.OK { + core.Print(stderr, "%s bench: encode report: %s", cliName(), data.Error()) + return 1 + } + if w := core.WriteFile(*jsonOut, data.Bytes(), 0o644); !w.OK { + core.Print(stderr, "%s bench: write --json %s: %s", cliName(), *jsonOut, w.Error()) + return 1 + } + core.WriteString(stdout, core.Sprintf("report written to %s\n", *jsonOut)) + } + for _, row := range report.Rows { + if row.Err != "" { + return 1 // the table already showed the holes; a partial grid exits non-zero + } + } + return 0 +} + +// benchMatrixLoad is the composition root's loader: the plain lane loads through the registered +// backend (inference.LoadModel); the mtp lane loads target+drafter as one speculative TextModel via +// the engine's pair loader (nil off darwin/arm64 — the row then reports the missing lane honestly). +func benchMatrixLoad(_ context.Context, modelPath, draftPath string, draftBlock int) (bench.MatrixModel, error) { + if draftPath != "" { + if speculativeLoader == nil { + return nil, core.NewError("this engine exposes no speculative path (mtp lane unavailable)") + } + m, err := speculativeLoader(modelPath, draftPath, draftBlock) + if err != nil { + return nil, err + } + return &benchTextModel{m: m}, nil + } + r := inference.LoadModel(modelPath) + if !r.OK { + return nil, core.NewError(r.Error()) + } + m, ok := r.Value.(inference.TextModel) + if !ok { + return nil, core.NewError("loaded model is not a TextModel") + } + return &benchTextModel{m: m}, nil +} + +// benchTextModel adapts inference.TextModel to bench.MatrixModel: greedy no-think drains, the +// engine's own GenerateMetrics mapped onto the bench shape, and the speculative counters when the +// pair engaged. +type benchTextModel struct{ m inference.TextModel } + +func (b *benchTextModel) Drain(ctx context.Context, prompt string, maxTokens int) error { + think := false + // One CHAT-FRAMED turn (not a raw completion): drafters are trained on chat-framed text, so + // a raw prompt is out-of-domain and the verify rejects most proposals (measured: 8%% + // acceptance raw vs healthy framed — the adaptive controller then backs off and the mtp lane + // reads as plain-minus-overhead). Chat is also the serving shape the grid exists to compare. + // Fully greedy: temperature, top_p, top_k AND min_p all pinned to 0 — any unset field is + // filled from the checkpoint's declared sampling defaults, and the pair's dispatch + // (Temperature > 0 || MinP > 0) would route to the sampled verify lane. + for range b.m.Chat(ctx, []inference.Message{{Role: "user", Content: prompt}}, + inference.WithMaxTokens(maxTokens), + inference.WithTemperature(0), + inference.WithTopP(0), + inference.WithTopK(0), + inference.WithMinP(0), + inference.WithEnableThinking(&think)) { + } + if p, ok := b.m.(interface{ Err() core.Result }); ok { + if r := p.Err(); !r.OK { + return core.NewError(r.Error()) + } + } + return nil +} + +func (b *benchTextModel) Metrics() bench.GenerationMetrics { + mt := b.m.Metrics() + return bench.GenerationMetrics{ + PromptTokens: mt.PromptTokens, + GeneratedTokens: mt.GeneratedTokens, + PrefillDuration: mt.PrefillDuration, + DecodeDuration: mt.DecodeDuration, + TotalDuration: mt.TotalDuration, + PrefillTokensPerSec: mt.PrefillTokensPerSec, + DecodeTokensPerSec: mt.DecodeTokensPerSec, + PeakMemoryBytes: mt.PeakMemoryBytes, + } +} + +func (b *benchTextModel) Close() error { + if r := b.m.Close(); !r.OK { + return core.NewError(r.Error()) + } + return nil +} + +// SpeculativeSummary exposes the pair's acceptance counters (bench.MatrixSpeculative) when the +// last generation ran the speculative lane. +func (b *benchTextModel) SpeculativeSummary() (bench.MatrixSpec, bool) { + p, ok := b.m.(inference.SpeculativeMetricsProvider) + if !ok { + return bench.MatrixSpec{}, false + } + sm := p.SpeculativeMetrics() + if sm.ProposedTokens == 0 { + return bench.MatrixSpec{}, false + } + return bench.MatrixSpec{ + ProposedTokens: sm.ProposedTokens, + AcceptedTokens: sm.AcceptedTokens, + AcceptanceRate: sm.AcceptanceRate, + }, true +} diff --git a/cli/capture.go b/cli/capture.go new file mode 100644 index 000000000..c724798f1 --- /dev/null +++ b/cli/capture.go @@ -0,0 +1,367 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package main + +import ( + "bufio" + "context" + "io" + "iter" + "time" + + core "dappco.re/go" + "dappco.re/go/inference" + "dappco.re/go/inference/cli/tui" + "dappco.re/go/inference/dataset" + "dappco.re/go/inference/serving" +) + +// capture.go wires the two `lem serve --capture ` / `lem ssd --dataset +// ` taps (Task 7 of docs/superpowers/plans/2026-07-19-lem-dataset-loop.md) +// on top of go/dataset's exported ingest primitives — nothing here reaches into +// go/serving or go/train internals, since neither exposes a completion hook of +// its own (ServeConfig carries no OnComplete-shaped field; RunSSDCommand +// returns only an error). Both taps stay entirely CLI-side: +// +// - serve: a inference.TextModel decorator injected through +// serving.ServeConfig.Loader, mirroring go/serving/welfare_guard.go's +// TextModel-decorator shape one layer up (see wrapCapture's doc for why +// the SchedulerModel variant exists — the exact bug class welfare_guard.go +// already documents and fixes the same way). +// - ssd: read back the capture sidecar train.RunSSDCommand already writes +// (/ssd-captures.jsonl — the filename cli/ssd.go's own +// --checkpoint-dir help text already documents) once the run succeeds, and +// land each row as a KindTrace Item. + +// modelFingerprint derives the ModelFingerprint the dataset design's +// provenance rule requires ("a score always names what produced it — never +// re-score an old response against a later model state") from what cmd/lem +// already has in hand at load time: the resolved model directory path. +// Hashing the full weights on every load is not viable (checkpoints run +// multi-GB); the path is the identity this repo's own conventions already +// treat as stable — a meaningfully different model state gets a NEW +// directory (see quant.go's defaultOutDir) rather than overwriting one in +// place. Both taps use this same derivation so a row captured either way +// names the same fingerprint for the same checkpoint. The "path:" prefix +// names the fingerprint's own provenance honestly, the same way judge-tier +// score kinds are namespaced ("judge:") — this is a path-derived +// identity, not a content hash. +func modelFingerprint(path string) string { + trimmed := core.Trim(path) + if trimmed == "" { + return "" + } + if r := core.PathAbs(trimmed); r.OK { + if abs := r.String(); abs != "" { + return core.Concat("path:", abs) + } + } + return core.Concat("path:", trimmed) +} + +// resolveCaptureDataset opens the shared dataset store and resolves slug to +// its Dataset — the preflight both capture taps need before they can tee +// anything. The store is returned OPEN; the caller closes it, including on a +// resolution failure (this function closes it itself in that one case, so a +// store opened then immediately failing to resolve never leaks the DuckDB +// handle into the caller's error path). +func resolveCaptureDataset(slug string) (tui.DatasetStore, dataset.Dataset, error) { + opened := tui.OpenDatasetStore() + if !opened.OK { + return nil, dataset.Dataset{}, opened.Err() + } + store, ok := opened.Value.(tui.DatasetStore) + if !ok { + return nil, dataset.Dataset{}, core.NewError("dataset store: unexpected OpenDatasetStore result type") + } + dsResult := store.DatasetBySlug(slug) + if !dsResult.OK { + _ = store.Close() + return nil, dataset.Dataset{}, core.E("main.resolveCaptureDataset", + core.Sprintf("dataset %q not found — create it first with `lem data create %s`", slug, slug), dsResult.Err()) + } + ds, ok := dsResult.Value.(dataset.Dataset) + if !ok { + _ = store.Close() + return nil, dataset.Dataset{}, core.NewError("dataset store: unexpected DatasetBySlug result type") + } + return store, ds, nil +} + +// ---- serve tap ---- + +// buildCaptureLoader resolves --capture into a wired serving.ModelLoader plus +// the dataset store it captures into, or returns (nil, nil, nil) when +// captureSlug is empty — "OFF without the flag (the approved privacy +// default)": no store is ever opened, nothing under ~/.lem is ever touched, +// when capture is not requested. The caller is responsible for closing the +// returned store (nil-safe) once serving stops. +func buildCaptureLoader(captureSlug string, log io.Writer) (serving.ModelLoader, tui.DatasetStore, error) { + slug := core.Trim(captureSlug) + if slug == "" { + return nil, nil, nil + } + store, ds, err := resolveCaptureDataset(slug) + if err != nil { + return nil, nil, err + } + return captureModelLoader(store, ds.ID, log), store, nil +} + +// captureModelLoader builds the serving.ModelLoader --capture installs: +// resolve through the registered backend exactly as +// go/serving's own unexported defaultTextModelLoader does (inference.LoadModel +// is that function's one exported seam), then wrap the result with the +// capture decorator. Mirrors defaultTextModelLoader's error handling so a +// captured load fails exactly the same way an uncaptured one would. +func captureModelLoader(store dataset.Store, datasetID string, log io.Writer) serving.ModelLoader { + return func(path string, opts ...inference.LoadOption) (inference.TextModel, error) { + result := inference.LoadModel(path, opts...) + if !result.OK { + if err, ok := result.Value.(error); ok { + return nil, err + } + return nil, core.E("main.captureModelLoader", "registered backend failed to load model", nil) + } + model, ok := result.Value.(inference.TextModel) + if !ok || model == nil { + return nil, core.E("main.captureModelLoader", "registered backend returned non-TextModel value", nil) + } + return wrapCapture(model, store, datasetID, modelFingerprint(path), log), nil + } +} + +// captureTextModel decorates an inference.TextModel with a completed-turn tee +// into a dataset. Only Chat is overridden — the compat mux funnels every +// OpenAI/Anthropic/Ollama CHAT route through TextModel.Chat +// (go/serving/welfare_guard.go's welfareTextModel makes the identical scope +// call, in its own doc comment); the legacy text-completions route +// (model.Generate) is not captured, the same accepted scope boundary the +// welfare guard already carries. +type captureTextModel struct { + inference.TextModel + store dataset.Store + datasetID string + fingerprint string + log io.Writer +} + +// wrapCapture decorates model with the capture tap. A model that routes +// through a request scheduler (inference.SchedulerModel — the -scheduler +// serve modes) is wrapped as a captureSchedulerModel instead: the compat mux +// prefers inference.SchedulerModel.Schedule over Chat +// (go/serving/compat/mux.go), and inference.As walks Unwrap to find it — so a +// plain captureTextModel would be unwrapped straight past and never see a +// scheduled request. This is the exact bug class +// go/serving/welfare_guard.go's own doc comment documents (and fixes the +// same way) for the welfare guard; capture would silently miss every +// scheduled turn without this. +func wrapCapture(model inference.TextModel, store dataset.Store, datasetID, fingerprint string, log io.Writer) inference.TextModel { + c := &captureTextModel{TextModel: model, store: store, datasetID: datasetID, fingerprint: fingerprint, log: log} + if sched, ok := inference.As[inference.SchedulerModel](model); ok { + return &captureSchedulerModel{captureTextModel: c, inner: sched} + } + return c +} + +// Chat tees the completed turn once the stream drains — the tokens reach the +// client first, capture is a side effect of draining, never a delay on the +// hot path. +func (m *captureTextModel) Chat(ctx context.Context, messages []inference.Message, opts ...inference.GenerateOption) iter.Seq[inference.Token] { + seq := m.TextModel.Chat(ctx, messages, opts...) + prompt := reduceMessagesToPrompt(messages) + if core.Trim(prompt) == "" { + return seq + } + return func(yield func(inference.Token) bool) { + var reply core.Builder + for tok := range seq { + reply.WriteString(tok.Text) + if !yield(tok) { + return + } + } + m.captureCompleted(prompt, reply.String()) + } +} + +// captureCompleted tees one completed turn into the dataset. Errors are +// logged and swallowed — never returned, never panicked: "capture failures +// log-and-continue — serving must never break because the dataset write +// failed" is a hard rule for a live server. +func (m *captureTextModel) captureCompleted(prompt, response string) { + if core.Trim(response) == "" { + return + } + r := dataset.IngestPair(m.store, prompt, response, dataset.IngestRequest{ + DatasetID: m.datasetID, Source: dataset.SourceCaptureServe, ModelFingerprint: m.fingerprint, + }) + if !r.OK { + core.Print(m.log, "serve: capture write failed (serving continues): %s", r.Error()) + } +} + +// Unwrap exposes the wrapped model so the serving layer can still reach +// optional capabilities this tap does not itself re-declare (embeddings, +// rerank, vision, audio) — without it, a captured model would silently lose +// those routes purely because it got wrapped. Mirrors welfareTextModel.Unwrap +// exactly. +func (m *captureTextModel) Unwrap() inference.TextModel { return m.TextModel } + +// captureSchedulerModel is captureTextModel extended with the +// inference.SchedulerModel surface — see wrapCapture's doc for why this type +// exists. Mirrors go/serving/welfare_guard.go's welfareSchedulerModel. +type captureSchedulerModel struct { + *captureTextModel + inner inference.SchedulerModel +} + +var _ inference.SchedulerModel = (*captureSchedulerModel)(nil) + +// Schedule tees the completed turn through the scheduled path — the +// ScheduledToken twin of Chat, folding the streamed reply and capturing once +// the inner stream drains. Mirrors welfareSchedulerModel.Schedule's forwarding +// shape. +func (m *captureSchedulerModel) Schedule(ctx context.Context, req inference.ScheduledRequest) (inference.RequestHandle, <-chan inference.ScheduledToken, error) { + handle, stream, err := m.inner.Schedule(ctx, req) + if err != nil { + return handle, stream, err + } + prompt := reduceMessagesToPrompt(req.Messages) + if core.Trim(prompt) == "" { + return handle, stream, err + } + out := make(chan inference.ScheduledToken, cap(stream)) + go func() { + defer close(out) + var reply core.Builder + for tok := range stream { + reply.WriteString(tok.Token.Text) + out <- tok + } + m.captureCompleted(prompt, reply.String()) + }() + return handle, out, nil +} + +// reduceMessagesToPrompt flattens a chat request's messages into the flat +// prompt string dataset.IngestPair's KindPair shape wants: every non-empty +// turn's content, newline-joined in order. Mirrors the same content-only (no +// role prefix) reduction go/dataset.MessagesContent.LastExchange applies when +// it turns a full conversation into a pair, so a captured item's prompt text +// has the same shape a dataset-side messages-to-pair reduction would +// produce. +func reduceMessagesToPrompt(messages []inference.Message) string { + var sb core.Builder + for _, message := range messages { + if core.Trim(message.Content) == "" { + continue + } + sb.WriteString(message.Content) + sb.WriteString("\n") + } + return sb.String() +} + +// ---- ssd tap ---- + +// ssdCaptureSidecarName is the capture sidecar filename train.RunSSDCommand +// writes under --checkpoint-dir whenever CheckpointDir is non-empty (its own +// internal default, applied unconditionally at the command layer — see +// go/train/command.go/ssd.go); cli/ssd.go's own --checkpoint-dir flag help +// text already documents this exact name, so this constant matches a +// published, stable convention rather than a private implementation detail. +const ssdCaptureSidecarName = "ssd-captures.jsonl" + +// ingestSSDTrace reads the capture sidecar a completed `lem ssd +// --checkpoint-dir` run wrote and lands each row as a KindTrace Item in the +// named dataset. Errors are returned to the caller rather than logged here — +// cli/ssd.go decides how loudly to treat a tap failure (log-and-continue: an +// hours-long sampling run's checkpoint is already safe on disk regardless of +// whether this last step succeeds). +func ingestSSDTrace(checkpointDir, slug, modelPath string, log io.Writer) error { + store, ds, err := resolveCaptureDataset(slug) + if err != nil { + return err + } + defer store.Close() + + sidecarPath := core.PathJoin(checkpointDir, ssdCaptureSidecarName) + opened := core.Open(sidecarPath) + if !opened.OK { + return core.E("main.ingestSSDTrace", core.Concat("open capture sidecar ", sidecarPath), opened.Err()) + } + file, ok := opened.Value.(*core.OSFile) + if !ok { + return core.E("main.ingestSSDTrace", "unexpected capture sidecar file result", nil) + } + defer file.Close() + + fingerprint := modelFingerprint(modelPath) + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 64*1024), 4*1024*1024) + ingested, deduped, skipped := 0, 0, 0 + row := 0 + for scanner.Scan() { + row++ + line := core.Trim(scanner.Text()) + if line == "" { + continue + } + _, wasDuplicate, itemErr := ingestTraceItem(store, ds.ID, []byte(line), dataset.SourceSSD, core.Sprintf("%d", row), fingerprint) + if itemErr != nil { + skipped++ + continue + } + if wasDuplicate { + deduped++ + } else { + ingested++ + } + } + if scanErr := scanner.Err(); scanErr != nil { + return core.E("main.ingestSSDTrace", "scan capture sidecar", scanErr) + } + core.Print(log, "ssd: dataset %q — %d trace items ingested, %d deduped, %d skipped", slug, ingested, deduped, skipped) + return nil +} + +// ingestTraceItem lands one KindTrace item, deduping by content hash within +// the dataset — the one dedupe-then-append shape go/dataset's own unexported +// ingestContent primitive applies to every OTHER kind, reimplemented here +// only for KindTrace, which go/dataset exports no dedicated ingest helper +// for (the design's ingest paths are pair/messages/capture-row/chathistory; +// SSD traces land through the Store contract directly). A KindTrace item is +// never welfare-screened either way — go/dataset/screen.go's screenItem has +// no KindTrace case (an opaque trace carries no user-authored text) — so +// skipping that step here mirrors go/dataset's own behaviour exactly, not a +// shortcut around it. +func ingestTraceItem(store dataset.Store, datasetID string, content []byte, source dataset.ItemSource, sourceRef, fingerprint string) (dataset.Item, bool, error) { + hashResult := dataset.ContentHash(dataset.KindTrace, content) + if !hashResult.OK { + return dataset.Item{}, false, hashResult.Err() + } + hash := hashResult.String() + + existingResult := store.Items(dataset.ItemFilter{DatasetID: datasetID, ContentHash: hash, IncludeArchived: true}) + if !existingResult.OK { + return dataset.Item{}, false, existingResult.Err() + } + if existing, ok := existingResult.Value.([]dataset.Item); ok && len(existing) > 0 { + return existing[0], true, nil + } + + item := dataset.Item{ + ID: dataset.NewID(), DatasetID: datasetID, Kind: dataset.KindTrace, Content: content, + Source: source, SourceRef: sourceRef, ModelFingerprint: fingerprint, ContentHash: hash, CreatedAt: time.Now(), + } + appendResult := store.ItemAppend(item) + if !appendResult.OK { + return dataset.Item{}, false, appendResult.Err() + } + appended, ok := appendResult.Value.(dataset.Item) + if !ok { + return dataset.Item{}, false, core.NewError("dataset: unexpected ItemAppend result type") + } + return appended, false, nil +} diff --git a/cli/capture_test.go b/cli/capture_test.go new file mode 100644 index 000000000..27771a390 --- /dev/null +++ b/cli/capture_test.go @@ -0,0 +1,444 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package main + +import ( + "bytes" + "context" + "io" + "iter" + "os" + "path/filepath" + "testing" + "time" + + core "dappco.re/go" + "dappco.re/go/inference" + "dappco.re/go/inference/dataset" +) + +// ---- fakes ---- + +// captureFakeModel is a minimal inference.TextModel double — mirrors +// go/serving/welfare_guard_test.go's welfareFakeModel shape (the same +// TextModel-decorator wrapping this file's captureTextModel is modelled on), +// reimplemented here since it is unexported in another package. Chat records +// every call and replays convoTokens. +type captureFakeModel struct { + convoTokens []string + convoCalls [][]inference.Message +} + +func fakeTokenSeq(texts ...string) iter.Seq[inference.Token] { + return func(yield func(inference.Token) bool) { + for i, text := range texts { + if !yield(inference.Token{ID: int32(i + 1), Text: text}) { + return + } + } + } +} + +func (f *captureFakeModel) Chat(_ context.Context, messages []inference.Message, _ ...inference.GenerateOption) iter.Seq[inference.Token] { + f.convoCalls = append(f.convoCalls, append([]inference.Message(nil), messages...)) + return fakeTokenSeq(f.convoTokens...) +} +func (f *captureFakeModel) Generate(context.Context, string, ...inference.GenerateOption) iter.Seq[inference.Token] { + return fakeTokenSeq(f.convoTokens...) +} +func (f *captureFakeModel) Classify(context.Context, []string, ...inference.GenerateOption) core.Result { + return core.Ok(nil) +} +func (f *captureFakeModel) BatchGenerate(context.Context, []string, ...inference.GenerateOption) core.Result { + return core.Ok(nil) +} +func (f *captureFakeModel) ModelType() string { return "capture-fake" } +func (f *captureFakeModel) Info() inference.ModelInfo { return inference.ModelInfo{} } +func (f *captureFakeModel) Metrics() inference.GenerateMetrics { return inference.GenerateMetrics{} } +func (f *captureFakeModel) Err() core.Result { return core.Ok(nil) } +func (f *captureFakeModel) Close() core.Result { return core.Ok(nil) } + +var _ inference.TextModel = (*captureFakeModel)(nil) + +// captureFakeSchedulerModel adds inference.SchedulerModel to captureFakeModel +// — wrapCapture must pick the captureSchedulerModel variant for a model +// shaped like this one, exactly as go/serving/welfare_guard.go's own +// SchedulerModel-detection wrap does. +type captureFakeSchedulerModel struct { + *captureFakeModel + scheduleCalls []inference.ScheduledRequest +} + +func (f *captureFakeSchedulerModel) Schedule(_ context.Context, req inference.ScheduledRequest) (inference.RequestHandle, <-chan inference.ScheduledToken, error) { + f.scheduleCalls = append(f.scheduleCalls, req) + out := make(chan inference.ScheduledToken, len(f.convoTokens)) + for i, text := range f.convoTokens { + out <- inference.ScheduledToken{RequestID: req.ID, Token: inference.Token{ID: int32(i + 1), Text: text}} + } + close(out) + return inference.RequestHandle{ID: req.ID}, out, nil +} + +var _ inference.SchedulerModel = (*captureFakeSchedulerModel)(nil) + +// failingStore wraps a dataset.Store and makes ItemAppend always fail — the +// fake "a failing store leaves serving working" proves capture failures +// never propagate into the token stream. +type failingStore struct { + dataset.Store +} + +func (failingStore) ItemAppend(dataset.Item) core.Result { + return core.Fail(core.NewError("simulated store failure")) +} + +func newSeededMemoryStore(t *testing.T, datasetID string) *dataset.MemoryStore { + t.Helper() + store := dataset.NewMemoryStore() + if r := store.DatasetCreate(dataset.Dataset{ID: datasetID, Slug: "capture-fake-ds", Title: "capture fake", CreatedAt: time.Now()}); !r.OK { + t.Fatalf("seed dataset: %v", r.Value) + } + return store +} + +func drainTokens(seq iter.Seq[inference.Token]) []string { + var texts []string + for tok := range seq { + texts = append(texts, tok.Text) + } + return texts +} + +// ---- modelFingerprint ---- + +func TestModelFingerprint(t *testing.T) { + t.Run("Good/absolute path", func(t *testing.T) { + got := modelFingerprint("/models/gemma-4-e2b-it-4bit") + if got != "path:/models/gemma-4-e2b-it-4bit" { + t.Errorf("modelFingerprint = %q", got) + } + }) + t.Run("Good/relative path resolves absolute", func(t *testing.T) { + got := modelFingerprint("relative/model/dir") + if !core.HasPrefix(got, "path:") || !core.PathIsAbs(got[len("path:"):]) { + t.Errorf("modelFingerprint(relative) = %q, want an absolute path: prefix", got) + } + }) + t.Run("Bad/empty path", func(t *testing.T) { + if got := modelFingerprint(""); got != "" { + t.Errorf("modelFingerprint(\"\") = %q, want empty", got) + } + }) + t.Run("Ugly/same path is stable", func(t *testing.T) { + a := modelFingerprint("/models/x") + b := modelFingerprint("/models/x") + if a != b { + t.Errorf("modelFingerprint not stable: %q != %q", a, b) + } + }) +} + +// ---- wrapCapture / captureTextModel.Chat ---- + +func TestWrapCapture_Chat_Good(t *testing.T) { + const datasetID = "ds-good" + store := newSeededMemoryStore(t, datasetID) + fake := &captureFakeModel{convoTokens: []string{"hel", "lo "}} + wrapped := wrapCapture(fake, store, datasetID, "path:/models/x", io.Discard) + + texts := drainTokens(wrapped.Chat(context.Background(), []inference.Message{{Role: "user", Content: "hi"}})) + if got := core.Join("", texts...); got != "hello " { + t.Fatalf("streamed tokens = %q, want %q (must pass through unchanged)", got, "hello ") + } + + items := core.MustCast[[]dataset.Item](store.Items(dataset.ItemFilter{DatasetID: datasetID})) + if len(items) != 1 { + t.Fatalf("items landed = %d, want 1", len(items)) + } + item := items[0] + if item.Kind != dataset.KindPair { + t.Errorf("Kind = %s, want pair", item.Kind) + } + if item.Source != dataset.SourceCaptureServe { + t.Errorf("Source = %s, want %s", item.Source, dataset.SourceCaptureServe) + } + if item.ModelFingerprint != "path:/models/x" { + t.Errorf("ModelFingerprint = %q, want path:/models/x", item.ModelFingerprint) + } + var pc dataset.PairContent + if r := core.JSONUnmarshal(item.Content, &pc); !r.OK { + t.Fatalf("decode content: %v", r.Value) + } + if pc.Response != "hello " { + t.Errorf("captured response = %q, want %q", pc.Response, "hello ") + } + if !core.Contains(pc.Prompt, "hi") { + t.Errorf("captured prompt = %q, want it to contain the user turn", pc.Prompt) + } +} + +func TestWrapCapture_Chat_EmptyMessages(t *testing.T) { + const datasetID = "ds-empty" + store := newSeededMemoryStore(t, datasetID) + fake := &captureFakeModel{convoTokens: []string{"still streams"}} + wrapped := wrapCapture(fake, store, datasetID, "path:/models/x", io.Discard) + + texts := drainTokens(wrapped.Chat(context.Background(), nil)) + if got := core.Join("", texts...); got != "still streams" { + t.Fatalf("streamed tokens = %q, want pass-through even with no messages", got) + } + items := core.MustCast[[]dataset.Item](store.Items(dataset.ItemFilter{DatasetID: datasetID})) + if len(items) != 0 { + t.Fatalf("items landed = %d, want 0 (no user content to capture)", len(items)) + } +} + +// TestWrapCapture_Chat_FailingStore is the "a failing store (fake) leaves +// serving working" proof: every token still reaches the caller even though +// every dataset write fails. +func TestWrapCapture_Chat_FailingStore(t *testing.T) { + const datasetID = "ds-failing" + inner := newSeededMemoryStore(t, datasetID) + store := failingStore{Store: inner} + fake := &captureFakeModel{convoTokens: []string{"serving ", "still ", "works"}} + + var log bytes.Buffer + wrapped := wrapCapture(fake, store, datasetID, "path:/models/x", &log) + + texts := drainTokens(wrapped.Chat(context.Background(), []inference.Message{{Role: "user", Content: "hi"}})) + if got := core.Join("", texts...); got != "serving still works" { + t.Fatalf("streamed tokens = %q, want the full reply despite the store failing", got) + } + if !core.Contains(log.String(), "capture write failed") { + t.Errorf("log = %q, want a capture-failed notice", log.String()) + } +} + +func TestWrapCapture_Unwrap(t *testing.T) { + const datasetID = "ds-unwrap" + store := newSeededMemoryStore(t, datasetID) + fake := &captureFakeModel{} + wrapped := wrapCapture(fake, store, datasetID, "fp", io.Discard) + + unwrappable, ok := wrapped.(interface{ Unwrap() inference.TextModel }) + if !ok { + t.Fatal("wrapped model does not implement Unwrap") + } + if unwrappable.Unwrap() != inference.TextModel(fake) { + t.Error("Unwrap did not return the inner model") + } +} + +// TestWrapCapture_SchedulerModel proves a scheduled model is wrapped so the +// tap still fires — the exact bug class go/serving/welfare_guard.go's own +// doc comment documents: inference.As walks Unwrap to reach +// SchedulerModel.Schedule, so a plain (non-Schedule) wrapper would be +// bypassed entirely for every scheduled request. +func TestWrapCapture_SchedulerModel(t *testing.T) { + const datasetID = "ds-sched" + store := newSeededMemoryStore(t, datasetID) + fake := &captureFakeSchedulerModel{captureFakeModel: &captureFakeModel{convoTokens: []string{"sched", "uled"}}} + + wrapped := wrapCapture(fake, store, datasetID, "path:/models/sched", io.Discard) + sched, ok := inference.As[inference.SchedulerModel](wrapped) + if !ok { + t.Fatal("wrapped model does not resolve as a SchedulerModel — the scheduled path would silently skip capture") + } + + _, stream, err := sched.Schedule(context.Background(), inference.ScheduledRequest{ + ID: "req-1", Messages: []inference.Message{{Role: "user", Content: "hi"}}, + }) + if err != nil { + t.Fatalf("Schedule: %v", err) + } + var got core.Builder + for tok := range stream { + got.WriteString(tok.Token.Text) + } + if got.String() != "scheduled" { + t.Fatalf("streamed tokens = %q, want %q", got.String(), "scheduled") + } + + items := core.MustCast[[]dataset.Item](store.Items(dataset.ItemFilter{DatasetID: datasetID})) + if len(items) != 1 { + t.Fatalf("items landed = %d, want 1", len(items)) + } + if items[0].Source != dataset.SourceCaptureServe { + t.Errorf("Source = %s, want %s", items[0].Source, dataset.SourceCaptureServe) + } +} + +// ---- resolveCaptureDataset / buildCaptureLoader (real store, HOME-redirected) ---- + +func TestResolveCaptureDataset(t *testing.T) { + dataTestHome(t) + t.Run("Good", func(t *testing.T) { + mustCreate(t, "resolve-me") + store, ds, err := resolveCaptureDataset("resolve-me") + if err != nil { + t.Fatalf("resolveCaptureDataset: %v", err) + } + defer store.Close() + if ds.Slug != "resolve-me" { + t.Errorf("Slug = %q, want resolve-me", ds.Slug) + } + }) + t.Run("Bad/unknown slug", func(t *testing.T) { + store, _, err := resolveCaptureDataset("does-not-exist-at-all") + if err == nil { + t.Fatal("resolveCaptureDataset over an unknown slug succeeded, want an error") + } + if store != nil { + t.Error("resolveCaptureDataset returned a non-nil store alongside an error") + } + }) +} + +// TestBuildCaptureLoader_DefaultOff proves --capture's empty value never +// opens the dataset store at all — "OFF without the flag (the approved +// privacy default)" — checked structurally: datasets.duckdb must not exist +// on disk after the call. +func TestBuildCaptureLoader_DefaultOff(t *testing.T) { + home := dataTestHome(t) + loader, store, err := buildCaptureLoader("", io.Discard) + if err != nil || loader != nil || store != nil { + t.Fatalf("buildCaptureLoader(\"\") = (%v, %v, %v), want (nil, nil, nil)", loader, store, err) + } + if _, statErr := os.Stat(filepath.Join(home, ".lem", "datasets.duckdb")); !os.IsNotExist(statErr) { + t.Fatalf("datasets.duckdb exists after a capture-off call: stat err = %v", statErr) + } +} + +func TestBuildCaptureLoader_Good(t *testing.T) { + dataTestHome(t) + mustCreate(t, "capture-target") + loader, store, err := buildCaptureLoader("capture-target", io.Discard) + if err != nil { + t.Fatalf("buildCaptureLoader: %v", err) + } + defer store.Close() + if loader == nil { + t.Fatal("loader is nil for a resolved --capture slug") + } + if store == nil { + t.Fatal("store is nil for a resolved --capture slug") + } +} + +func TestBuildCaptureLoader_Bad(t *testing.T) { + dataTestHome(t) + loader, store, err := buildCaptureLoader("no-such-dataset", io.Discard) + if err == nil { + t.Fatal("buildCaptureLoader over a missing dataset succeeded, want an error") + } + if loader != nil || store != nil { + t.Fatalf("buildCaptureLoader on failure = (%v, %v), want (nil, nil)", loader, store) + } +} + +// ---- ingestTraceItem / ingestSSDTrace ---- + +func TestIngestTraceItem(t *testing.T) { + const datasetID = "ds-trace" + store := newSeededMemoryStore(t, datasetID) + content := []byte(`{"sample":"trace-row"}`) + + t.Run("Good", func(t *testing.T) { + item, deduped, err := ingestTraceItem(store, datasetID, content, dataset.SourceSSD, "1", "path:/models/base") + if err != nil { + t.Fatalf("ingestTraceItem: %v", err) + } + if deduped { + t.Error("first ingest reported deduped") + } + if item.Kind != dataset.KindTrace { + t.Errorf("Kind = %s, want trace", item.Kind) + } + if item.ModelFingerprint != "path:/models/base" { + t.Errorf("ModelFingerprint = %q", item.ModelFingerprint) + } + }) + + t.Run("Good/dedupes identical content", func(t *testing.T) { + _, deduped, err := ingestTraceItem(store, datasetID, content, dataset.SourceSSD, "2", "path:/models/base") + if err != nil { + t.Fatalf("ingestTraceItem (repeat): %v", err) + } + if !deduped { + t.Error("repeat ingest of identical content was not reported as deduped") + } + }) + + t.Run("Bad/non-JSON content is rejected", func(t *testing.T) { + _, _, err := ingestTraceItem(store, datasetID, []byte("not json"), dataset.SourceSSD, "3", "path:/models/base") + if err == nil { + t.Fatal("ingestTraceItem accepted non-JSON trace content") + } + }) +} + +func writeSSDCaptureFixture(t *testing.T, dir string, lines ...string) { + t.Helper() + var buf bytes.Buffer + for _, line := range lines { + buf.WriteString(line) + buf.WriteByte('\n') + } + if err := os.WriteFile(filepath.Join(dir, ssdCaptureSidecarName), buf.Bytes(), 0o644); err != nil { + t.Fatalf("write ssd capture fixture: %v", err) + } +} + +func TestIngestSSDTrace_Good(t *testing.T) { + dataTestHome(t) + mustCreate(t, "ssd-trace-ds") + dir := t.TempDir() + writeSSDCaptureFixture(t, dir, `{"step":0,"prompt":"p1","text":"r1"}`, `{"step":1,"prompt":"p2","text":"r2"}`) + + var log bytes.Buffer + if err := ingestSSDTrace(dir, "ssd-trace-ds", "/models/ssd-base", &log); err != nil { + t.Fatalf("ingestSSDTrace: %v", err) + } + if !core.Contains(log.String(), "2 trace items ingested") { + t.Errorf("log = %q, want a 2-ingested summary", log.String()) + } + + store, closeStore := openTestStore(t) + defer closeStore() + ds, err := resolveDatasetSlug(store, "ssd-trace-ds") + if err != nil { + t.Fatalf("resolve: %v", err) + } + items := core.MustCast[[]dataset.Item](store.Items(dataset.ItemFilter{DatasetID: ds.ID})) + if len(items) != 2 { + t.Fatalf("items landed = %d, want 2", len(items)) + } + for _, item := range items { + if item.Kind != dataset.KindTrace { + t.Errorf("Kind = %s, want trace", item.Kind) + } + if item.Source != dataset.SourceSSD { + t.Errorf("Source = %s, want %s", item.Source, dataset.SourceSSD) + } + if !core.HasPrefix(item.ModelFingerprint, "path:") { + t.Errorf("ModelFingerprint = %q, want a path: prefix", item.ModelFingerprint) + } + } +} + +func TestIngestSSDTrace_Bad(t *testing.T) { + dataTestHome(t) + t.Run("unknown dataset", func(t *testing.T) { + dir := t.TempDir() + writeSSDCaptureFixture(t, dir, `{"step":0,"prompt":"p","text":"r"}`) + if err := ingestSSDTrace(dir, "does-not-exist", "/models/x", io.Discard); err == nil { + t.Fatal("ingestSSDTrace against an unknown dataset succeeded") + } + }) + t.Run("missing sidecar file", func(t *testing.T) { + mustCreate(t, "ssd-trace-missing-sidecar") + if err := ingestSSDTrace(t.TempDir(), "ssd-trace-missing-sidecar", "/models/x", io.Discard); err == nil { + t.Fatal("ingestSSDTrace against a missing sidecar succeeded") + } + }) +} diff --git a/cli/data.go b/cli/data.go new file mode 100644 index 000000000..f08e0c689 --- /dev/null +++ b/cli/data.go @@ -0,0 +1,908 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package main + +import ( + "context" + "flag" + "io" + "time" + + core "dappco.re/go" + "dappco.re/go/inference/cli/tui" + "dappco.re/go/inference/dataset" + "dappco.re/go/inference/serving/chathistory" + coreio "dappco.re/go/io" +) + +// runDataCommand wires go/dataset's root domain package (Dataset/Item/Score/ +// Review/Export, the Store contract, ingest normalisation, heuristic + +// judge-tier scoring, export writers) as the `lem data` verb family: create, +// list, stats, import, score, export, and archive datasets under +// ~/.lem/datasets.duckdb, plus `review` — a pointer at the TUI Data panel +// (Task 8, not this verb's job). Every verb opens the store through +// tui.OpenDatasetStore, never hand-rolling the ~/.lem path itself. +// +// lem data create evening-vents --title "Evening vents" +// lem data import evening-vents --jsonl captures.jsonl +// lem data score evening-vents --auto-approve 'lek>=80' +// lem data score evening-vents --kind judge:quality --model ~/models/judge-4b +// lem data export evening-vents --format sft-jsonl --out train.jsonl +func runDataCommand(ctx context.Context, args []string, stdout, stderr io.Writer) int { + if len(args) == 0 { + printDataUsage(stderr) + return 2 + } + switch args[0] { + case "create": + return runDataCreate(args[1:], stdout, stderr) + case "list": + return runDataList(args[1:], stdout, stderr) + case "stats": + return runDataStats(args[1:], stdout, stderr) + case "import": + return runDataImport(args[1:], stdout, stderr) + case "score": + return runDataScore(ctx, args[1:], stdout, stderr) + case "export": + return runDataExport(args[1:], stdout, stderr) + case "archive": + return runDataArchive(args[1:], stdout, stderr) + case "review": + return runDataReview(ctx, args[1:], stdout, stderr) + case "-h", "--help", "help": + printDataUsage(stdout) + return 0 + default: + core.Print(stderr, "%s data: unknown subcommand %q", cliName(), args[0]) + printDataUsage(stderr) + return 2 + } +} + +func printDataUsage(w io.Writer) { + name := cliName() + core.WriteString(w, core.Sprintf("Usage: %s data [flags]\n", name)) + core.WriteString(w, "\n") + core.WriteString(w, "The lem-native training-data loop: capture -> score -> review -> export,\n") + core.WriteString(w, "backed by ~/.lem/datasets.duckdb.\n") + core.WriteString(w, "\n") + core.WriteString(w, "Subcommands\n") + core.WriteString(w, " create create a new dataset\n") + core.WriteString(w, " list list datasets\n") + core.WriteString(w, " stats item counts by kind / source / review status\n") + core.WriteString(w, " import ingest rows from --jsonl or --chats\n") + core.WriteString(w, " score run the heuristic lek scorer or a judge: template, optionally auto-review by threshold\n") + core.WriteString(w, " export write JSONL + a manifest receipt for training\n") + core.WriteString(w, " archive flag a dataset archived (never a hard delete)\n") + core.WriteString(w, " review [slug] how to open the TUI Data panel\n") + core.WriteString(w, "\n") + core.WriteString(w, core.Sprintf("Run \"%s data --help\" for sub-action flags.\n", name)) +} + +// dataSubUsage returns a Usage function in the same synopsis + description + +// flags shape every other lem verb's sub-actions use (see packSubUsage). +func dataSubUsage(fs *flag.FlagSet, w io.Writer, synopsis, desc string) func() { + return func() { + core.WriteString(w, core.Sprintf("Usage: %s %s\n", cliName(), synopsis)) + core.WriteString(w, "\n") + if desc != "" { + core.WriteString(w, desc) + core.WriteString(w, "\n\n") + } + core.WriteString(w, "Flags:\n") + printFlagBlock(w, fs) + } +} + +// ---- shared helpers ---- + +// openDataStore opens the shared dataset store or prints an honest failure +// and returns the exit code callers should return immediately. A nil store +// on success never happens; callers check `if store == nil`. +func openDataStore(stderr io.Writer) (tui.DatasetStore, int) { + opened := tui.OpenDatasetStore() + if !opened.OK { + core.Print(stderr, "%s data: %s", cliName(), opened.Error()) + return nil, 1 + } + store, ok := opened.Value.(tui.DatasetStore) + if !ok { + core.Print(stderr, "%s data: unexpected dataset store result", cliName()) + return nil, 1 + } + return store, 0 +} + +// resolveDatasetSlug looks up a dataset by slug, translating the Store's +// core.Result into a plain error for the verb functions' Go-idiom error +// handling. +func resolveDatasetSlug(store dataset.Store, slug string) (dataset.Dataset, error) { + r := store.DatasetBySlug(slug) + if !r.OK { + return dataset.Dataset{}, r.Err() + } + ds, ok := r.Value.(dataset.Dataset) + if !ok { + return dataset.Dataset{}, core.NewError("dataset: unexpected DatasetBySlug result type") + } + return ds, nil +} + +// printJSON marshals v compactly and writes it as one line — the shape every +// `--json` read verb emits for scripting. +func printJSON(w io.Writer, v any) { + data := core.JSONMarshal(v) + if !data.OK { + core.Print(w, "error: %s", data.Error()) + return + } + core.WriteString(w, core.AsString(data.Bytes())) + core.WriteString(w, "\n") +} + +// splitFilterClause splits a "key=value" --filter clause on the first bare +// '='. A ScoreExpression clause (>=, <=, ==, !=, >, <) never matches: "==" +// and "!=" are excluded by looking at the characters either side of the +// split point, and a bare '>' / '<' clause contains no '=' at all. +func splitFilterClause(clause string) (key, value string, ok bool) { + idx := core.Index(clause, "=") + if idx < 0 { + return "", "", false + } + // ">=" / "<=" / "!=" all have one of these immediately before the '='; + // "==" has a second '=' immediately after it (caught below) — between + // the two checks, every two-char comparison operator is excluded. + if idx > 0 { + switch clause[idx-1] { + case '>', '<', '!': + return "", "", false + } + } + if idx+1 < len(clause) && clause[idx+1] == '=' { + return "", "", false + } + return core.Trim(clause[:idx]), core.Trim(clause[idx+1:]), true +} + +// parseItemFilter parses --filter's tiny explicit grammar: comma-separated +// clauses, each either "=" (status / kind / source / archived) +// or a bare score expression in dataset.ParseScoreExpression's grammar (e.g. +// "lek>=80", reused rather than reinvented — Task 3 already built and tested +// it). An empty filterExpr is the zero ItemFilter beyond DatasetID — every +// non-archived item. +func parseItemFilter(datasetID, filterExpr string) (dataset.ItemFilter, error) { + filter := dataset.ItemFilter{DatasetID: datasetID} + filterExpr = core.Trim(filterExpr) + if filterExpr == "" { + return filter, nil + } + for _, clause := range core.Split(filterExpr, ",") { + clause = core.Trim(clause) + if clause == "" { + continue + } + if key, value, ok := splitFilterClause(clause); ok { + switch key { + case "status": + filter.Status = dataset.ReviewStatus(value) + case "kind": + filter.Kind = dataset.ItemKind(value) + case "source": + filter.Source = dataset.ItemSource(value) + case "archived": + filter.IncludeArchived = value == "true" + default: + return filter, core.E("main.parseItemFilter", core.Sprintf("unknown filter field %q", key), nil) + } + continue + } + exprResult := dataset.ParseScoreExpression(clause) + if !exprResult.OK { + return filter, core.E("main.parseItemFilter", core.Sprintf("unrecognised filter clause %q", clause), exprResult.Err()) + } + parsed := exprResult.Value.(dataset.ScoreExpression) + filter.Score = &parsed + } + return filter, nil +} + +// ---- create ---- + +func runDataCreate(args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet(cliCommandName("data create"), flag.ContinueOnError) + fs.SetOutput(stderr) + title := fs.String("title", "", "human title (default: the slug)") + purpose := fs.String("purpose", "", "one-line purpose note") + jsonOut := fs.Bool("json", false, "print the created dataset as JSON") + fs.Usage = dataSubUsage(fs, stderr, "data create [flags] ", + "Create a new named dataset under ~/.lem/datasets.duckdb. slug must be\n"+ + "lowercase alphanumeric-and-hyphen, e.g. \"evening-vents\".") + slug, err := parseWithPositional(fs, args) + if err != nil { + if core.Is(err, flag.ErrHelp) { + return 0 + } + if slug == "" { + core.Print(stderr, "%s data create: expected exactly one ", cliName()) + } else { + core.Print(stderr, "%s data create: %s", cliName(), err.Error()) + } + fs.Usage() + return 2 + } + + store, code := openDataStore(stderr) + if store == nil { + return code + } + defer store.Close() + + titleValue := core.Trim(*title) + if titleValue == "" { + titleValue = slug + } + r := store.DatasetCreate(dataset.Dataset{ + ID: dataset.NewID(), Slug: slug, Title: titleValue, Purpose: *purpose, CreatedAt: time.Now(), + }) + if !r.OK { + core.Print(stderr, "%s data create: %s", cliName(), r.Error()) + return 1 + } + created := r.Value.(dataset.Dataset) + if *jsonOut { + printJSON(stdout, created) + return 0 + } + core.Print(stdout, "created dataset %q (%s)", created.Slug, created.ID) + return 0 +} + +// ---- list ---- + +func runDataList(args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet(cliCommandName("data list"), flag.ContinueOnError) + fs.SetOutput(stderr) + archived := fs.Bool("archived", false, "include archived datasets") + jsonOut := fs.Bool("json", false, "print datasets as JSON") + fs.Usage = dataSubUsage(fs, stderr, "data list [flags]", "List every dataset under ~/.lem/datasets.duckdb.") + if err := fs.Parse(args); err != nil { + if core.Is(err, flag.ErrHelp) { + return 0 + } + return 2 + } + if fs.NArg() != 0 { + core.Print(stderr, "%s data list: unexpected argument %q", cliName(), fs.Arg(0)) + fs.Usage() + return 2 + } + + store, code := openDataStore(stderr) + if store == nil { + return code + } + defer store.Close() + + r := store.Datasets(*archived) + if !r.OK { + core.Print(stderr, "%s data list: %s", cliName(), r.Error()) + return 1 + } + list := r.Value.([]dataset.Dataset) + if *jsonOut { + printJSON(stdout, list) + return 0 + } + if len(list) == 0 { + core.Print(stdout, "no datasets yet — create one with `%s data create `", cliName()) + return 0 + } + for _, d := range list { + state := "" + if d.Archived { + state = " [archived]" + } + core.Print(stdout, "%-24s %-32s %s%s", d.Slug, d.Title, d.CreatedAt.Format(time.RFC3339), state) + } + return 0 +} + +// ---- stats ---- + +// dataStats is the `data stats` report shape — a CLI-local view type (not a +// go/dataset domain type), so it is free to carry the json tags a scripting +// consumer wants. +type dataStats struct { + Dataset string `json:"dataset"` + Total int `json:"total"` + ByKind map[string]int `json:"by_kind"` + BySource map[string]int `json:"by_source"` + ByStatus map[string]int `json:"by_status"` +} + +func runDataStats(args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet(cliCommandName("data stats"), flag.ContinueOnError) + fs.SetOutput(stderr) + archived := fs.Bool("archived", false, "include archived items in the counts") + jsonOut := fs.Bool("json", false, "print the stats as JSON") + fs.Usage = dataSubUsage(fs, stderr, "data stats [flags] ", "Item counts by kind, source, and latest review status.") + slug, err := parseWithPositional(fs, args) + if err != nil { + if core.Is(err, flag.ErrHelp) { + return 0 + } + if slug == "" { + core.Print(stderr, "%s data stats: expected exactly one ", cliName()) + } else { + core.Print(stderr, "%s data stats: %s", cliName(), err.Error()) + } + fs.Usage() + return 2 + } + + store, code := openDataStore(stderr) + if store == nil { + return code + } + defer store.Close() + ds, derr := resolveDatasetSlug(store, slug) + if derr != nil { + core.Print(stderr, "%s data stats: %s", cliName(), derr.Error()) + return 1 + } + + itemsResult := store.Items(dataset.ItemFilter{DatasetID: ds.ID, IncludeArchived: *archived}) + if !itemsResult.OK { + core.Print(stderr, "%s data stats: %s", cliName(), itemsResult.Error()) + return 1 + } + items := itemsResult.Value.([]dataset.Item) + + stats := dataStats{Dataset: slug, Total: len(items), ByKind: map[string]int{}, BySource: map[string]int{}, ByStatus: map[string]int{}} + for _, item := range items { + stats.ByKind[string(item.Kind)]++ + stats.BySource[string(item.Source)]++ + status := string(dataset.StatusPending) + if reviewResult := store.ReviewLatest(item.ID); reviewResult.OK { + if rv, ok := reviewResult.Value.(dataset.Review); ok { + status = string(rv.Status) + } + } + stats.ByStatus[status]++ + } + + if *jsonOut { + printJSON(stdout, stats) + return 0 + } + core.Print(stdout, "dataset %q — %d items", slug, stats.Total) + for kind, n := range stats.ByKind { + core.Print(stdout, " kind=%-10s %d", kind, n) + } + for source, n := range stats.BySource { + core.Print(stdout, " source=%-16s %d", source, n) + } + for status, n := range stats.ByStatus { + core.Print(stdout, " status=%-12s %d", status, n) + } + return 0 +} + +// ---- import ---- + +func runDataImport(args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet(cliCommandName("data import"), flag.ContinueOnError) + fs.SetOutput(stderr) + jsonlPath := fs.String("jsonl", "", "ingest a JSONL file — {messages}, {prompt,response}, or CaptureRow rows") + fingerprint := fs.String("fingerprint", "", "model fingerprint to stamp on --jsonl rows carrying no provenance of their own (default: empty, i.e. human/imported text)") + chatsUser := fs.String("chats", "", "ingest a user's chathistory (~/Lethean/lem/users//chats.duckdb)") + session := fs.String("session", "", "with --chats, import only this one conversation id (default: every conversation)") + jsonOut := fs.Bool("json", false, "print the import report as JSON") + fs.Usage = dataSubUsage(fs, stderr, "data import [flags] ", + "Ingest rows into a dataset — exactly one of --jsonl or --chats. Every\n"+ + "ingest path runs the welfare screen at the door and dedupes by content\n"+ + "hash within the dataset (a duplicate row is a counted no-op, not an error).") + slug, err := parseWithPositional(fs, args) + if err != nil { + if core.Is(err, flag.ErrHelp) { + return 0 + } + if slug == "" { + core.Print(stderr, "%s data import: expected exactly one ", cliName()) + } else { + core.Print(stderr, "%s data import: %s", cliName(), err.Error()) + } + fs.Usage() + return 2 + } + + jsonlSet := core.Trim(*jsonlPath) != "" + chatsSet := core.Trim(*chatsUser) != "" + if jsonlSet == chatsSet { + core.Print(stderr, "%s data import: exactly one of --jsonl or --chats is required", cliName()) + fs.Usage() + return 2 + } + if core.Trim(*session) != "" && !chatsSet { + core.Print(stderr, "%s data import: --session requires --chats", cliName()) + return 2 + } + + store, code := openDataStore(stderr) + if store == nil { + return code + } + defer store.Close() + ds, derr := resolveDatasetSlug(store, slug) + if derr != nil { + core.Print(stderr, "%s data import: %s", cliName(), derr.Error()) + return 1 + } + + if jsonlSet { + return runDataImportJSONL(store, ds, *jsonlPath, *fingerprint, *jsonOut, stdout, stderr) + } + return runDataImportChats(store, ds, *chatsUser, *session, *jsonOut, stdout, stderr) +} + +func runDataImportJSONL(store dataset.Store, ds dataset.Dataset, path, fingerprint string, jsonOut bool, stdout, stderr io.Writer) int { + opened := core.Open(path) + if !opened.OK { + core.Print(stderr, "%s data import: open %s: %s", cliName(), path, opened.Error()) + return 1 + } + file, ok := opened.Value.(*core.OSFile) + if !ok { + core.Print(stderr, "%s data import: unexpected file result for %s", cliName(), path) + return 1 + } + defer file.Close() + + r := dataset.IngestJSONL(store, ds.ID, file, dataset.IngestOptions{ModelFingerprint: fingerprint}) + if !r.OK { + core.Print(stderr, "%s data import: %s", cliName(), r.Error()) + return 1 + } + report := r.Value.(dataset.IngestReport) + printImportReport(stdout, report, jsonOut) + if len(report.Skipped) > 0 { + return 1 + } + return 0 +} + +func runDataImportChats(store dataset.Store, ds dataset.Dataset, userID, sessionID string, jsonOut bool, stdout, stderr io.Writer) int { + homeResult := core.UserHomeDir() + if !homeResult.OK { + core.Print(stderr, "%s data import: resolve user home: %s", cliName(), homeResult.Error()) + return 1 + } + home := homeResult.String() + if core.Trim(home) == "" { + core.Print(stderr, "%s data import: user home is empty", cliName()) + return 1 + } + // Storage convention documented in go/serving/chathistory's package doc: + // one .duckdb per user at ~/Lethean/lem/users//chats.duckdb — + // a different application root from ~/.lem (the dataset/TUI root), so + // this path is derived here rather than through tui's path contract. + chatsPath := core.Path(home, "Lethean", "lem", "users", userID, "chats.duckdb") + if statResult := core.Stat(chatsPath); !statResult.OK { + core.Print(stderr, "%s data import: no chat history found for user %q at %s", cliName(), userID, chatsPath) + return 1 + } + + h, err := chathistory.Open(userID, chatsPath) + if err != nil { + core.Print(stderr, "%s data import: open chathistory: %s", cliName(), err.Error()) + return 1 + } + defer h.Close() + + // chathistory.RecentConversations has no "give me everything" sentinel — + // a limit this large is, in practice, "every conversation this user has". + const allConversations = 1_000_000 + summaries, err := h.RecentConversations(allConversations) + if err != nil { + core.Print(stderr, "%s data import: list conversations: %s", cliName(), err.Error()) + return 1 + } + if core.Trim(sessionID) != "" { + filtered := summaries[:0] + for _, c := range summaries { + if c.ID == sessionID { + filtered = append(filtered, c) + break + } + } + if len(filtered) == 0 { + core.Print(stderr, "%s data import: session %q not found for user %q", cliName(), sessionID, userID) + return 1 + } + summaries = filtered + } + + sessions := make([]dataset.ChatSession, 0, len(summaries)) + for _, c := range summaries { + turns, terr := h.LoadTurns(c.ID) + if terr != nil { + core.Print(stderr, "%s data import: warning: skipping session %s: %s", cliName(), c.ID, terr.Error()) + continue + } + chatTurns := make([]dataset.ChatTurn, len(turns)) + for i, t := range turns { + chatTurns[i] = dataset.ChatTurn{Role: t.Role, Content: t.Content, Ordinal: t.Ordinal} + } + sessions = append(sessions, dataset.ChatSession{ID: c.ID, Title: c.Title, ModelID: c.ModelID, StartedAt: c.StartedAt, Turns: chatTurns}) + } + + r := dataset.IngestChatSessions(store, ds.ID, sessions) + if !r.OK { + core.Print(stderr, "%s data import: %s", cliName(), r.Error()) + return 1 + } + report := r.Value.(dataset.IngestReport) + printImportReport(stdout, report, jsonOut) + if len(report.Skipped) > 0 { + return 1 + } + return 0 +} + +func printImportReport(w io.Writer, report dataset.IngestReport, jsonOut bool) { + if jsonOut { + printJSON(w, report) + return + } + core.Print(w, "import: ingested=%d deduped=%d quarantined=%d skipped=%d", report.Ingested, report.Deduped, report.Quarantined, len(report.Skipped)) + for _, skip := range report.Skipped { + core.Print(w, " row %d: %s", skip.Row, skip.Reason) + } +} + +// ---- score ---- + +// dataScoreReport is the `data score` report shape — CLI-local, json-tagged. +type dataScoreReport struct { + Dataset string `json:"dataset"` + Scored int `json:"scored"` + Failed int `json:"failed"` + AutoApproved int `json:"auto_approved"` + AutoRejected int `json:"auto_rejected"` +} + +func runDataScore(ctx context.Context, args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet(cliCommandName("data score"), flag.ContinueOnError) + fs.SetOutput(stderr) + kind := fs.String("kind", "lek", "score kind: 'lek' (the heuristic tier) or judge: (a named judge template — requires --model)") + modelPath := fs.String("model", "", "judge model checkpoint path (required for --kind judge:; unused for lek)") + filterExpr := fs.String("filter", "", "which items to score — comma-separated status=/kind=/source=/archived= clauses and/or a score expression (default: every non-archived item)") + autoApprove := fs.String("auto-approve", "", "auto-approve items whose fresh score satisfies this expression, e.g. lek>=80 (explicit only, never implicit)") + autoReject := fs.String("auto-reject", "", "auto-reject items whose fresh score satisfies this expression (checked before --auto-approve)") + jsonOut := fs.Bool("json", false, "print the score report as JSON") + fs.Usage = dataSubUsage(fs, stderr, "data score [flags] ", + "Score every matching item with the heuristic lek tier (lek.ScorePair) or a\n"+ + "named judge template (--kind judge: --model ),\n"+ + "optionally auto-reviewing by an explicit threshold expression.") + slug, err := parseWithPositional(fs, args) + if err != nil { + if core.Is(err, flag.ErrHelp) { + return 0 + } + if slug == "" { + core.Print(stderr, "%s data score: expected exactly one ", cliName()) + } else { + core.Print(stderr, "%s data score: %s", cliName(), err.Error()) + } + fs.Usage() + return 2 + } + + scoreKind := dataset.ScoreKind(*kind) + isJudge := scoreKind.IsJudge() + if !isJudge && scoreKind != dataset.ScoreKindLEK { + core.Print(stderr, "%s data score: unknown --kind %q (want 'lek' or 'judge:')", cliName(), *kind) + return 2 + } + if isJudge && core.Trim(*modelPath) == "" { + core.Print(stderr, "%s data score: --kind %s requires --model ", cliName(), *kind) + return 2 + } + + var approveExpr, rejectExpr *dataset.ScoreExpression + if core.Trim(*autoApprove) != "" { + exprResult := dataset.ParseScoreExpression(*autoApprove) + if !exprResult.OK { + core.Print(stderr, "%s data score: --auto-approve: %s", cliName(), exprResult.Error()) + return 2 + } + parsed := exprResult.Value.(dataset.ScoreExpression) + approveExpr = &parsed + } + if core.Trim(*autoReject) != "" { + exprResult := dataset.ParseScoreExpression(*autoReject) + if !exprResult.OK { + core.Print(stderr, "%s data score: --auto-reject: %s", cliName(), exprResult.Error()) + return 2 + } + parsed := exprResult.Value.(dataset.ScoreExpression) + rejectExpr = &parsed + } + + store, code := openDataStore(stderr) + if store == nil { + return code + } + defer store.Close() + ds, derr := resolveDatasetSlug(store, slug) + if derr != nil { + core.Print(stderr, "%s data score: %s", cliName(), derr.Error()) + return 1 + } + + filter, ferr := parseItemFilter(ds.ID, *filterExpr) + if ferr != nil { + core.Print(stderr, "%s data score: --filter: %s", cliName(), ferr.Error()) + return 2 + } + + itemsResult := store.Items(filter) + if !itemsResult.OK { + core.Print(stderr, "%s data score: %s", cliName(), itemsResult.Error()) + return 1 + } + items := itemsResult.Value.([]dataset.Item) + + // The judge model is loaded ONCE, only when there is actually something + // to score — never per item, and never merely because --model was set + // on an empty result set. + var driver dataset.JudgeDispatcher + if isJudge && len(items) > 0 { + d, closeJudge, jerr := newJudgeDispatcher(*modelPath, judgeDefaultMaxTokens) + if jerr != nil { + core.Print(stderr, "%s data score: %s", cliName(), jerr.Error()) + return 1 + } + defer closeJudge() + driver = d + } + + report := dataScoreReport{Dataset: slug} + judgeName := scoreKind.JudgeName() + for _, item := range items { + var scores []dataset.Score + if isJudge { + scoreResult := dataset.ScoreJudge(ctx, store, driver, item, judgeName) + if !scoreResult.OK { + // Malformed judge output (a non-bare-number or out-of-range + // reply) is a loud per-item error, never a silent 0 — print + // it and keep scoring the rest of the batch. + report.Failed++ + core.Print(stderr, "%s data score: item %s: %s", cliName(), item.ID, scoreResult.Error()) + continue + } + score, ok := scoreResult.Value.(dataset.Score) + if !ok { + report.Failed++ + continue + } + scores = []dataset.Score{score} + } else { + scoreResult := dataset.ScoreHeuristicAppend(store, item) + if !scoreResult.OK { + report.Failed++ + continue + } + var ok bool + scores, ok = scoreResult.Value.([]dataset.Score) + if !ok { + continue + } + } + report.Scored++ + if approveExpr == nil && rejectExpr == nil { + continue + } + thresholdResult := dataset.ApplyAutoThreshold(store, item, scores, approveExpr, rejectExpr) + if !thresholdResult.OK { + continue + } + outcome, ok := thresholdResult.Value.(dataset.AutoThresholdResult) + if !ok || !outcome.Applied { + continue + } + switch outcome.Review.Status { + case dataset.StatusApproved: + report.AutoApproved++ + case dataset.StatusRejected: + report.AutoRejected++ + } + } + + if *jsonOut { + printJSON(stdout, report) + } else { + core.Print(stdout, "score: dataset %q — scored=%d failed=%d auto_approved=%d auto_rejected=%d", + slug, report.Scored, report.Failed, report.AutoApproved, report.AutoRejected) + } + if report.Failed > 0 { + return 1 + } + return 0 +} + +// ---- export ---- + +func runDataExport(args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet(cliCommandName("data export"), flag.ContinueOnError) + fs.SetOutput(stderr) + format := fs.String("format", "", "export format: sft-jsonl, pairs-jsonl, or capture-jsonl (required)") + out := fs.String("out", "", "output JSONL path (required; a sidecar .manifest.json is written alongside)") + filterExpr := fs.String("filter", "", "which items to export — comma-separated clauses (default: status=approved; exporting anything else requires an explicit filter)") + jsonOut := fs.Bool("json", false, "print the export manifest as JSON") + fs.Usage = dataSubUsage(fs, stderr, "data export [flags] ", + "Export a dataset to JSONL with a manifest receipt (counts, filter, per-item\n"+ + "content hashes, the manifest hash) a training run can name exactly what it saw.") + slug, err := parseWithPositional(fs, args) + if err != nil { + if core.Is(err, flag.ErrHelp) { + return 0 + } + if slug == "" { + core.Print(stderr, "%s data export: expected exactly one ", cliName()) + } else { + core.Print(stderr, "%s data export: %s", cliName(), err.Error()) + } + fs.Usage() + return 2 + } + if core.Trim(*format) == "" || core.Trim(*out) == "" { + core.Print(stderr, "%s data export: --format and --out are required", cliName()) + fs.Usage() + return 2 + } + + store, code := openDataStore(stderr) + if store == nil { + return code + } + defer store.Close() + ds, derr := resolveDatasetSlug(store, slug) + if derr != nil { + core.Print(stderr, "%s data export: %s", cliName(), derr.Error()) + return 1 + } + + filter, ferr := parseItemFilter(ds.ID, *filterExpr) + if ferr != nil { + core.Print(stderr, "%s data export: --filter: %s", cliName(), ferr.Error()) + return 2 + } + + exportResult := dataset.ExportDataset(store, coreio.Local, dataset.ExportRequest{ + DatasetID: ds.ID, Format: dataset.ExportFormat(*format), Filter: filter, OutputPath: *out, + }) + if !exportResult.OK { + core.Print(stderr, "%s data export: %s", cliName(), exportResult.Error()) + return 1 + } + + manifest, merr := readExportManifest(*out) + if merr != nil { + core.Print(stderr, "%s data export: wrote %s but could not read back the manifest: %s", cliName(), *out, merr.Error()) + return 1 + } + if *jsonOut { + printJSON(stdout, manifest) + } else { + core.Print(stdout, "export: dataset %q -> %s (%s)", slug, *out, *format) + core.Print(stdout, " items=%d skipped=%d filter=%q manifest=%s", manifest.ItemCount, manifest.SkippedCount, manifest.FilterDescription, manifest.ManifestHash) + } + if manifest.SkippedCount > 0 { + return 1 + } + return 0 +} + +// readExportManifest reads back the sidecar dataset.ExportDataset just wrote +// so the verb can report the honest written/skipped counts the library +// itself only persists to disk, not through ExportDataset's Result. +func readExportManifest(outputPath string) (dataset.ExportManifest, error) { + content, err := coreio.Local.Read(core.Concat(outputPath, ".manifest.json")) + if err != nil { + return dataset.ExportManifest{}, err + } + var manifest dataset.ExportManifest + if r := core.JSONUnmarshalString(content, &manifest); !r.OK { + return dataset.ExportManifest{}, r.Err() + } + return manifest, nil +} + +// ---- archive ---- + +func runDataArchive(args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet(cliCommandName("data archive"), flag.ContinueOnError) + fs.SetOutput(stderr) + jsonOut := fs.Bool("json", false, "print the archived dataset as JSON") + fs.Usage = dataSubUsage(fs, stderr, "data archive [flags] ", "Flag a dataset archived. Never a hard delete — its items remain queryable with --archived.") + slug, err := parseWithPositional(fs, args) + if err != nil { + if core.Is(err, flag.ErrHelp) { + return 0 + } + if slug == "" { + core.Print(stderr, "%s data archive: expected exactly one ", cliName()) + } else { + core.Print(stderr, "%s data archive: %s", cliName(), err.Error()) + } + fs.Usage() + return 2 + } + + store, code := openDataStore(stderr) + if store == nil { + return code + } + defer store.Close() + ds, derr := resolveDatasetSlug(store, slug) + if derr != nil { + core.Print(stderr, "%s data archive: %s", cliName(), derr.Error()) + return 1 + } + + r := store.DatasetArchive(ds.ID) + if !r.OK { + core.Print(stderr, "%s data archive: %s", cliName(), r.Error()) + return 1 + } + archived := r.Value.(dataset.Dataset) + if *jsonOut { + printJSON(stdout, archived) + return 0 + } + core.Print(stdout, "archived dataset %q", slug) + return 0 +} + +// ---- review ---- + +// runDataReviewTUI is tui.RunDataReview by default — a package-level seam +// tests substitute so runDataReview's own logic (slug/help parsing, the +// headless-fallback message) can be proven without ever constructing a +// real Bubble Tea program: tea.NewProgram's WithAltScreen opens /dev/tty +// directly for raw terminal control regardless of the stdout/stderr +// io.Writer passed in, so calling the real tui.RunDataReview from an +// automated test would hang — or, on a box with a real controlling +// terminal, open an actual interactive session — exactly the hazard +// runWithWorkspace's --check mode exists to avoid for `lem tui` itself +// (see cli/tui/tui_test.go; every existing TUI test drives only that +// headless path). +var runDataReviewTUI = tui.RunDataReview + +// runDataReview opens the TUI focused on the Data panel (Task 8, tracker +// #43) — optionally pre-filtered to one dataset slug. runDataReviewTUI +// drives the real interactive Bubble Tea program; when it cannot start +// (e.g. no TTY — the same "tui: " failure Run itself can hit) this +// keeps a human-usable fallback message pointing at the headless verbs, +// rather than leaving the caller with only Bubble Tea's raw stderr line. +func runDataReview(ctx context.Context, args []string, stdout, stderr io.Writer) int { + slug := "" + if len(args) > 0 { + switch args[0] { + case "-h", "--help", "help": + core.WriteString(stdout, core.Sprintf("Usage: %s data review [slug]\n\n", cliName())) + core.WriteString(stdout, "Open the TUI focused on the Data panel. With a slug, the panel opens\n") + core.WriteString(stdout, "pre-filtered to that one dataset.\n") + return 0 + default: + slug = args[0] + } + } + code := runDataReviewTUI(ctx, slug, stdout, stderr) + if code != 0 { + core.Print(stdout, "the TUI could not start — for now: `%s data list` / `%s data stats ` inspect a dataset headlessly.", cliName(), cliName()) + } + return code +} diff --git a/cli/data_test.go b/cli/data_test.go new file mode 100644 index 000000000..9b2eb5ef9 --- /dev/null +++ b/cli/data_test.go @@ -0,0 +1,723 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package main + +import ( + "bytes" + "context" + "io" + "os" + "path/filepath" + "testing" + + core "dappco.re/go" + "dappco.re/go/inference/dataset" + "dappco.re/go/inference/serving/chathistory" +) + +// dataTestHome redirects $HOME to a fresh t.TempDir() so every `lem data` +// verb under test resolves its own isolated ~/.lem/datasets.duckdb — the +// same seam cli/serve_test.go already uses for the admin-token path (there +// is no --home flag surface). +func dataTestHome(t *testing.T) string { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + return home +} + +// runData is a small shim over runDataCommand that returns the exit code and +// the split stdout/stderr text, so test bodies stay one-liners. +func runData(args ...string) (int, string, string) { + var stdout, stderr bytes.Buffer + code := runDataCommand(context.Background(), args, &stdout, &stderr) + return code, stdout.String(), stderr.String() +} + +// TestRunDataCommand_Dispatch covers the router itself: no args and an +// unknown subcommand both fail with usage; -h/--help/help all succeed. +func TestRunDataCommand_Dispatch(t *testing.T) { + dataTestHome(t) + for _, tc := range []struct { + name string + args []string + wantCode int + }{ + {"no args", nil, 2}, + {"unknown subcommand", []string{"frobnicate"}, 2}, + {"help word", []string{"help"}, 0}, + {"help flag", []string{"--help"}, 0}, + {"help short", []string{"-h"}, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + code, _, stderr := runData(tc.args...) + if code != tc.wantCode { + t.Fatalf("exit %d, want %d; stderr=%s", code, tc.wantCode, stderr) + } + }) + } +} + +// ---- create ---- + +func TestRunDataCreate_Good(t *testing.T) { + dataTestHome(t) + t.Run("explicit title", func(t *testing.T) { + code, stdout, stderr := runData("create", "evening-vents", "--title", "Evening vents", "--purpose", "life stuff") + if code != 0 { + t.Fatalf("exit %d; stderr=%s", code, stderr) + } + if !core.Contains(stdout, "evening-vents") { + t.Errorf("stdout missing slug: %q", stdout) + } + }) + t.Run("title defaults to slug", func(t *testing.T) { + code, _, stderr := runData("create", "no-title-given", "--json") + if code != 0 { + t.Fatalf("exit %d; stderr=%s", code, stderr) + } + store, closeStore := openTestStore(t) + defer closeStore() + ds, err := resolveDatasetSlug(store, "no-title-given") + if err != nil { + t.Fatalf("resolve: %v", err) + } + if ds.Title != "no-title-given" { + t.Errorf("Title = %q, want the slug", ds.Title) + } + }) + t.Run("flags before or after the slug", func(t *testing.T) { + code, _, stderr := runData("create", "--title", "Flags First", "flags-first") + if code != 0 { + t.Fatalf("exit %d; stderr=%s", code, stderr) + } + }) +} + +func TestRunDataCreate_Bad(t *testing.T) { + dataTestHome(t) + if code, _, _ := runData("create", "dupe-me"); code != 0 { + t.Fatalf("seed create failed") + } + for _, tc := range []struct { + name string + args []string + wantCode int + }{ + {"no positional", []string{"create"}, 2}, + {"invalid slug", []string{"create", "Not A Valid Slug!"}, 1}, + {"duplicate slug", []string{"create", "dupe-me"}, 1}, + {"unknown flag", []string{"create", "x", "--nonsense"}, 2}, + } { + t.Run(tc.name, func(t *testing.T) { + code, _, stderr := runData(tc.args...) + if code != tc.wantCode { + t.Fatalf("exit %d, want %d; stderr=%s", code, tc.wantCode, stderr) + } + }) + } +} + +// ---- list ---- + +func TestRunDataList_Good(t *testing.T) { + dataTestHome(t) + t.Run("empty store", func(t *testing.T) { + code, stdout, stderr := runData("list") + if code != 0 { + t.Fatalf("exit %d; stderr=%s", code, stderr) + } + if !core.Contains(stdout, "no datasets yet") { + t.Errorf("stdout = %q, want the empty-store notice", stdout) + } + }) + mustCreate(t, "alpha") + mustCreate(t, "beta") + t.Run("table output", func(t *testing.T) { + code, stdout, stderr := runData("list") + if code != 0 { + t.Fatalf("exit %d; stderr=%s", code, stderr) + } + if !core.Contains(stdout, "alpha") || !core.Contains(stdout, "beta") { + t.Errorf("stdout missing datasets: %q", stdout) + } + }) + t.Run("json output", func(t *testing.T) { + code, stdout, stderr := runData("list", "--json") + if code != 0 { + t.Fatalf("exit %d; stderr=%s", code, stderr) + } + var got []dataset.Dataset + if r := core.JSONUnmarshalString(stdout, &got); !r.OK { + t.Fatalf("decode json list: %v", r.Value) + } + if len(got) != 2 { + t.Errorf("json list len = %d, want 2", len(got)) + } + }) + t.Run("archived excluded then included", func(t *testing.T) { + if code, _, _ := runData("archive", "alpha"); code != 0 { + t.Fatalf("archive setup failed") + } + _, stdout, _ := runData("list") + if core.Contains(stdout, "alpha") { + t.Errorf("archived dataset leaked into default list: %q", stdout) + } + _, stdoutAll, _ := runData("list", "--archived") + if !core.Contains(stdoutAll, "alpha") { + t.Errorf("--archived did not include the archived dataset: %q", stdoutAll) + } + }) +} + +func TestRunDataList_Bad(t *testing.T) { + dataTestHome(t) + if code, _, _ := runData("list", "unexpected-arg"); code != 2 { + t.Fatalf("exit %d, want 2", code) + } +} + +// ---- stats ---- + +func TestRunDataStats_Good(t *testing.T) { + dataTestHome(t) + mustCreate(t, "stats-ds") + jsonlPath := writeJSONLFixture(t, `{"prompt":"hi","response":"hello"}`, `{"prompt":"how are you","response":"well thanks"}`) + if code, _, stderr := runData("import", "stats-ds", "--jsonl", jsonlPath); code != 0 { + t.Fatalf("import setup failed: %s", stderr) + } + + code, stdout, stderr := runData("stats", "stats-ds", "--json") + if code != 0 { + t.Fatalf("exit %d; stderr=%s", code, stderr) + } + var got dataStats + if r := core.JSONUnmarshalString(stdout, &got); !r.OK { + t.Fatalf("decode stats json: %v", r.Value) + } + if got.Total != 2 { + t.Errorf("Total = %d, want 2", got.Total) + } + if got.ByKind["pair"] != 2 { + t.Errorf("ByKind[pair] = %d, want 2", got.ByKind["pair"]) + } + if got.ByStatus["pending"] != 2 { + t.Errorf("ByStatus[pending] = %d, want 2", got.ByStatus["pending"]) + } +} + +func TestRunDataStats_Bad(t *testing.T) { + dataTestHome(t) + for _, tc := range []struct { + name string + args []string + wantCode int + }{ + {"no positional", []string{"stats"}, 2}, + {"unknown slug", []string{"stats", "does-not-exist"}, 1}, + } { + t.Run(tc.name, func(t *testing.T) { + if code, _, _ := runData(tc.args...); code != tc.wantCode { + t.Fatalf("exit %d, want %d", code, tc.wantCode) + } + }) + } +} + +// ---- import ---- + +func TestRunDataImport_Good(t *testing.T) { + dataTestHome(t) + mustCreate(t, "import-ds") + + t.Run("jsonl happy path", func(t *testing.T) { + path := writeJSONLFixture(t, `{"prompt":"hi","response":"hello"}`, `{"messages":[{"role":"user","content":"hey"},{"role":"assistant","content":"hi there"}]}`) + code, stdout, stderr := runData("import", "import-ds", "--jsonl", path) + if code != 0 { + t.Fatalf("exit %d; stderr=%s", code, stderr) + } + if !core.Contains(stdout, "ingested=2") { + t.Errorf("stdout = %q, want ingested=2", stdout) + } + }) + + t.Run("re-import dedupes, still exits 0", func(t *testing.T) { + path := writeJSONLFixture(t, `{"prompt":"hi","response":"hello"}`) + code, stdout, stderr := runData("import", "import-ds", "--jsonl", path) + if code != 0 { + t.Fatalf("exit %d; stderr=%s", code, stderr) + } + if !core.Contains(stdout, "deduped=1") { + t.Errorf("stdout = %q, want deduped=1", stdout) + } + }) + + t.Run("malformed rows are counted skips and exit non-zero", func(t *testing.T) { + path := writeJSONLFixture(t, `{"prompt":"new one","response":"ok"}`, `not valid json`, `{"prompt":"","response":"x"}`) + code, stdout, stderr := runData("import", "import-ds", "--jsonl", path) + if code != 1 { + t.Fatalf("exit %d, want 1 (truthful failure); stderr=%s", code, stderr) + } + if !core.Contains(stdout, "ingested=1") || !core.Contains(stdout, "skipped=2") { + t.Errorf("stdout = %q, want ingested=1 skipped=2", stdout) + } + }) + + t.Run("chats happy path", func(t *testing.T) { + home := dataTestHome(t) + mustCreate(t, "chat-import-ds") + buildChatsFixture(t, home, "snider") + code, stdout, stderr := runData("import", "chat-import-ds", "--chats", "snider") + if code != 0 { + t.Fatalf("exit %d; stderr=%s", code, stderr) + } + if !core.Contains(stdout, "ingested=1") { + t.Errorf("stdout = %q, want ingested=1", stdout) + } + }) +} + +func TestRunDataImport_Bad(t *testing.T) { + dataTestHome(t) + mustCreate(t, "import-bad-ds") + jsonlPath := writeJSONLFixture(t, `{"prompt":"hi","response":"hello"}`) + for _, tc := range []struct { + name string + args []string + wantCode int + }{ + {"no positional", []string{"import"}, 2}, + {"neither jsonl nor chats", []string{"import", "import-bad-ds"}, 2}, + {"both jsonl and chats", []string{"import", "import-bad-ds", "--jsonl", jsonlPath, "--chats", "snider"}, 2}, + {"session without chats", []string{"import", "import-bad-ds", "--jsonl", jsonlPath, "--session", "x"}, 2}, + {"unknown dataset", []string{"import", "does-not-exist", "--jsonl", jsonlPath}, 1}, + {"nonexistent jsonl file", []string{"import", "import-bad-ds", "--jsonl", "/no/such/file.jsonl"}, 1}, + {"chats user with no history", []string{"import", "import-bad-ds", "--chats", "nobody-ever-chatted"}, 1}, + } { + t.Run(tc.name, func(t *testing.T) { + code, _, stderr := runData(tc.args...) + if code != tc.wantCode { + t.Fatalf("exit %d, want %d; stderr=%s", code, tc.wantCode, stderr) + } + }) + } +} + +// ---- score ---- + +func TestRunDataScore_Good(t *testing.T) { + dataTestHome(t) + mustCreate(t, "score-ds") + path := writeJSONLFixture(t, `{"prompt":"hi","response":"hello there, how can I help today?"}`) + if code, _, stderr := runData("import", "score-ds", "--jsonl", path); code != 0 { + t.Fatalf("import setup failed: %s", stderr) + } + + t.Run("plain score", func(t *testing.T) { + code, stdout, stderr := runData("score", "score-ds") + if code != 0 { + t.Fatalf("exit %d; stderr=%s", code, stderr) + } + if !core.Contains(stdout, "scored=1") { + t.Errorf("stdout = %q, want scored=1", stdout) + } + }) + + t.Run("auto-approve wiring (always-true threshold)", func(t *testing.T) { + code, stdout, stderr := runData("score", "score-ds", "--auto-approve", "lek>=0", "--json") + if code != 0 { + t.Fatalf("exit %d; stderr=%s", code, stderr) + } + var got dataScoreReport + if r := core.JSONUnmarshalString(stdout, &got); !r.OK { + t.Fatalf("decode score json: %v", r.Value) + } + if got.AutoApproved != 1 { + t.Errorf("AutoApproved = %d, want 1", got.AutoApproved) + } + store, closeStore := openTestStore(t) + defer closeStore() + ds, err := resolveDatasetSlug(store, "score-ds") + if err != nil { + t.Fatalf("resolve: %v", err) + } + items := core.MustCast[[]dataset.Item](store.Items(dataset.ItemFilter{DatasetID: ds.ID})) + review := core.MustCast[dataset.Review](store.ReviewLatest(items[0].ID)) + if review.Status != dataset.StatusApproved { + t.Errorf("review status = %s, want approved", review.Status) + } + }) + + t.Run("auto-reject never fires on an impossible threshold", func(t *testing.T) { + code, stdout, stderr := runData("score", "score-ds", "--auto-reject", "lek>=1000000") + if code != 0 { + t.Fatalf("exit %d; stderr=%s", code, stderr) + } + if !core.Contains(stdout, "auto_rejected=0") { + t.Errorf("stdout = %q, want auto_rejected=0", stdout) + } + }) +} + +func TestRunDataScore_Bad(t *testing.T) { + dataTestHome(t) + mustCreate(t, "score-bad-ds") + for _, tc := range []struct { + name string + args []string + wantCode int + }{ + {"no positional", []string{"score"}, 2}, + {"unknown kind", []string{"score", "score-bad-ds", "--kind", "nonsense"}, 2}, + {"judge kind without --model", []string{"score", "score-bad-ds", "--kind", "judge:helpfulness"}, 2}, + {"bad auto-approve expression", []string{"score", "score-bad-ds", "--auto-approve", "not-an-expression"}, 2}, + {"bad filter expression", []string{"score", "score-bad-ds", "--filter", "nonsense-field=x"}, 2}, + {"unknown dataset", []string{"score", "does-not-exist"}, 1}, + } { + t.Run(tc.name, func(t *testing.T) { + code, _, stderr := runData(tc.args...) + if code != tc.wantCode { + t.Fatalf("exit %d, want %d; stderr=%s", code, tc.wantCode, stderr) + } + }) + } +} + +// TestRunDataScore_Judge covers the judge: verb wiring itself — flag +// validation and model-load failure. A real judge verdict end-to-end +// (template resolve -> render -> generate -> parse -> store) is covered +// against a stub model in TestNewJudgeDispatcherFromModel_Good; a real GPU +// model run is the orchestrator's merge gate, not a unit test's job. +func TestRunDataScore_Judge(t *testing.T) { + dataTestHome(t) + mustCreate(t, "judge-empty-ds") + mustCreate(t, "judge-item-ds") + path := writeJSONLFixture(t, `{"prompt":"hi","response":"hello there"}`) + if code, _, stderr := runData("import", "judge-item-ds", "--jsonl", path); code != 0 { + t.Fatalf("import setup failed: %s", stderr) + } + + t.Run("no items means no load is attempted, even with a bogus --model", func(t *testing.T) { + code, stdout, stderr := runData("score", "judge-empty-ds", "--kind", "judge:quality", "--model", "/does/not/exist") + if code != 0 { + t.Fatalf("exit %d; stderr=%s", code, stderr) + } + if !core.Contains(stdout, "scored=0") { + t.Errorf("stdout = %q, want scored=0", stdout) + } + }) + + t.Run("a real item forces the load, and a bogus --model fails honestly", func(t *testing.T) { + code, _, stderr := runData("score", "judge-item-ds", "--kind", "judge:quality", "--model", "/does/not/exist") + if code != 1 { + t.Fatalf("exit %d, want 1; stderr=%s", code, stderr) + } + if core.Trim(stderr) == "" { + t.Errorf("stderr is empty, want an honest load-failure message") + } + }) +} + +// ---- export ---- + +func TestRunDataExport_Good(t *testing.T) { + dataTestHome(t) + mustCreate(t, "export-ds") + path := writeJSONLFixture(t, `{"prompt":"hi","response":"hello"}`) + if code, _, stderr := runData("import", "export-ds", "--jsonl", path); code != 0 { + t.Fatalf("import setup failed: %s", stderr) + } + + t.Run("default filter (status=approved) exports nothing before review", func(t *testing.T) { + out := filepath.Join(t.TempDir(), "unreviewed.jsonl") + code, stdout, stderr := runData("export", "export-ds", "--format", "pairs-jsonl", "--out", out) + if code != 0 { + t.Fatalf("exit %d; stderr=%s", code, stderr) + } + if !core.Contains(stdout, "items=0") { + t.Errorf("stdout = %q, want items=0 (nothing approved yet)", stdout) + } + }) + + if code, _, stderr := runData("score", "export-ds", "--auto-approve", "lek>=0"); code != 0 { + t.Fatalf("score setup failed: %s", stderr) + } + + t.Run("sft-jsonl after approval", func(t *testing.T) { + out := filepath.Join(t.TempDir(), "train.jsonl") + code, stdout, stderr := runData("export", "export-ds", "--format", "sft-jsonl", "--out", out, "--json") + if code != 0 { + t.Fatalf("exit %d; stderr=%s", code, stderr) + } + var manifest dataset.ExportManifest + if r := core.JSONUnmarshalString(stdout, &manifest); !r.OK { + t.Fatalf("decode manifest json: %v", r.Value) + } + if manifest.ItemCount != 1 { + t.Errorf("ItemCount = %d, want 1", manifest.ItemCount) + } + if _, err := os.Stat(out); err != nil { + t.Errorf("export file missing: %v", err) + } + if _, err := os.Stat(out + ".manifest.json"); err != nil { + t.Errorf("manifest sidecar missing: %v", err) + } + }) + + t.Run("explicit filter overrides the default", func(t *testing.T) { + out := filepath.Join(t.TempDir(), "all.jsonl") + code, stdout, stderr := runData("export", "export-ds", "--format", "capture-jsonl", "--out", out, "--filter", "kind=pair") + if code != 0 { + t.Fatalf("exit %d; stderr=%s", code, stderr) + } + if !core.Contains(stdout, "items=1") { + t.Errorf("stdout = %q, want items=1", stdout) + } + }) +} + +func TestRunDataExport_Bad(t *testing.T) { + dataTestHome(t) + mustCreate(t, "export-bad-ds") + out := filepath.Join(t.TempDir(), "out.jsonl") + for _, tc := range []struct { + name string + args []string + wantCode int + }{ + {"no positional", []string{"export"}, 2}, + {"missing format and out", []string{"export", "export-bad-ds"}, 2}, + {"missing out", []string{"export", "export-bad-ds", "--format", "pairs-jsonl"}, 2}, + {"unknown format", []string{"export", "export-bad-ds", "--format", "nonsense", "--out", out}, 1}, + {"unknown dataset", []string{"export", "does-not-exist", "--format", "pairs-jsonl", "--out", out}, 1}, + } { + t.Run(tc.name, func(t *testing.T) { + code, _, stderr := runData(tc.args...) + if code != tc.wantCode { + t.Fatalf("exit %d, want %d; stderr=%s", code, tc.wantCode, stderr) + } + }) + } +} + +// ---- archive ---- + +func TestRunDataArchive_Good(t *testing.T) { + dataTestHome(t) + mustCreate(t, "archive-ds") + code, stdout, stderr := runData("archive", "archive-ds", "--json") + if code != 0 { + t.Fatalf("exit %d; stderr=%s", code, stderr) + } + var got dataset.Dataset + if r := core.JSONUnmarshalString(stdout, &got); !r.OK { + t.Fatalf("decode archived json: %v", r.Value) + } + if !got.Archived { + t.Error("Archived = false, want true") + } +} + +func TestRunDataArchive_Bad(t *testing.T) { + dataTestHome(t) + for _, tc := range []struct { + name string + args []string + wantCode int + }{ + {"no positional", []string{"archive"}, 2}, + {"unknown dataset", []string{"archive", "does-not-exist"}, 1}, + } { + t.Run(tc.name, func(t *testing.T) { + code, _, stderr := runData(tc.args...) + if code != tc.wantCode { + t.Fatalf("exit %d, want %d; stderr=%s", code, tc.wantCode, stderr) + } + }) + } +} + +// ---- review ---- + +// TestRunDataReview_Good drives runDataReview's own logic (slug parsing, +// the conditional headless-fallback message) through the injected +// runDataReviewTUI seam — never a real tui.RunDataReview/tea.NewProgram +// call, which would hang (or open a real interactive session) whenever +// the test process has a controlling terminal; see runDataReviewTUI's +// doc comment in data.go. +func TestRunDataReview_Good(t *testing.T) { + original := runDataReviewTUI + defer func() { runDataReviewTUI = original }() + + var gotCtx context.Context + var gotSlug string + runDataReviewTUI = func(ctx context.Context, slug string, stdout, stderr io.Writer) int { + gotCtx, gotSlug = ctx, slug + core.WriteString(stderr, "tui: could not open a new TTY (stubbed)\n") + return 1 + } + code, stdout, stderr := runData("review", "evening-vents") + if code != 1 { + t.Fatalf("exit %d, want 1; stderr=%s", code, stderr) + } + if gotCtx == nil || gotSlug != "evening-vents" { + t.Fatalf("runDataReviewTUI args = ctx=%v slug=%q", gotCtx, gotSlug) + } + if !core.Contains(stdout, "data list") || !core.Contains(stdout, "data stats") { + t.Errorf("stdout = %q, want the headless-verb fallback pointer on a TUI start failure", stdout) + } + + runDataReviewTUI = func(context.Context, string, io.Writer, io.Writer) int { return 0 } + code, stdout, _ = runData("review") + if code != 0 { + t.Fatalf("exit %d, want 0 on a clean TUI exit", code) + } + if core.Contains(stdout, "could not start") { + t.Errorf("stdout = %q printed the fallback pointer despite a clean (code 0) TUI exit", stdout) + } +} + +func TestRunDataReview_Help(t *testing.T) { + code, stdout, stderr := runData("review", "--help") + if code != 0 { + t.Fatalf("exit %d; stderr=%s", code, stderr) + } + if !core.Contains(stdout, "Usage:") { + t.Errorf("stdout = %q, want a usage banner", stdout) + } +} + +// ---- parseItemFilter / splitFilterClause ---- + +func TestParseItemFilter(t *testing.T) { + for _, tc := range []struct { + name string + expr string + want dataset.ItemFilter + wantErr bool + }{ + {"empty", "", dataset.ItemFilter{DatasetID: "ds1"}, false}, + {"status", "status=approved", dataset.ItemFilter{DatasetID: "ds1", Status: dataset.StatusApproved}, false}, + {"kind", "kind=pair", dataset.ItemFilter{DatasetID: "ds1", Kind: dataset.KindPair}, false}, + {"source", "source=ssd", dataset.ItemFilter{DatasetID: "ds1", Source: dataset.SourceSSD}, false}, + {"archived", "archived=true", dataset.ItemFilter{DatasetID: "ds1", IncludeArchived: true}, false}, + {"score expression", "lek>=80", dataset.ItemFilter{DatasetID: "ds1", Score: &dataset.ScoreExpression{Kind: dataset.ScoreKindLEK, Op: dataset.OpGTE, Threshold: 80}}, false}, + {"combined", "status=approved,lek>=80", dataset.ItemFilter{DatasetID: "ds1", Status: dataset.StatusApproved, Score: &dataset.ScoreExpression{Kind: dataset.ScoreKindLEK, Op: dataset.OpGTE, Threshold: 80}}, false}, + {"unknown field", "nonsense=x", dataset.ItemFilter{}, true}, + {"malformed clause", "totally-broken-clause-!!", dataset.ItemFilter{}, true}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := parseItemFilter("ds1", tc.expr) + if tc.wantErr { + if err == nil { + t.Fatalf("parseItemFilter(%q) = %+v, nil, want an error", tc.expr, got) + } + return + } + if err != nil { + t.Fatalf("parseItemFilter(%q) unexpected error: %v", tc.expr, err) + } + if got.DatasetID != tc.want.DatasetID || got.Status != tc.want.Status || got.Kind != tc.want.Kind || + got.Source != tc.want.Source || got.IncludeArchived != tc.want.IncludeArchived { + t.Fatalf("parseItemFilter(%q) = %+v, want %+v", tc.expr, got, tc.want) + } + if (got.Score == nil) != (tc.want.Score == nil) { + t.Fatalf("parseItemFilter(%q) Score presence mismatch: got %+v, want %+v", tc.expr, got.Score, tc.want.Score) + } + if got.Score != nil && *got.Score != *tc.want.Score { + t.Fatalf("parseItemFilter(%q) Score = %+v, want %+v", tc.expr, *got.Score, *tc.want.Score) + } + }) + } +} + +func TestSplitFilterClause(t *testing.T) { + for _, tc := range []struct { + name string + clause string + wantKey string + wantValue string + wantOK bool + }{ + {"simple", "status=approved", "status", "approved", true}, + {"spaced", " status = approved ", "status", "approved", true}, + {"double-equals rejected", "lek==80", "", "", false}, + {"not-equals rejected", "lek!=80", "", "", false}, + {"no equals", "lek>=80", "", "", false}, + {"gt no equals at all", "lek>80", "", "", false}, + } { + t.Run(tc.name, func(t *testing.T) { + key, value, ok := splitFilterClause(tc.clause) + if ok != tc.wantOK || key != tc.wantKey || value != tc.wantValue { + t.Fatalf("splitFilterClause(%q) = (%q, %q, %v), want (%q, %q, %v)", tc.clause, key, value, ok, tc.wantKey, tc.wantValue, tc.wantOK) + } + }) + } +} + +// ---- test fixtures ---- + +// openTestStore opens the dataset store under the currently-redirected HOME +// (see dataTestHome) — assertion-side access to state a verb call already +// wrote, mirroring what the verbs themselves do via tui.OpenDatasetStore. +func openTestStore(t *testing.T) (dataset.Store, func()) { + t.Helper() + store, code := openDataStore(os.Stderr) + if store == nil { + t.Fatalf("openDataStore failed, code=%d", code) + } + return store, func() { _ = store.Close() } +} + +// mustCreate creates a dataset with the given slug via the real verb path, +// failing the test on any error — the common setup step nearly every other +// verb's tests build on. +func mustCreate(t *testing.T, slug string) { + t.Helper() + if code, _, stderr := runData("create", slug); code != 0 { + t.Fatalf("mustCreate(%q) failed: exit %d; stderr=%s", slug, code, stderr) + } +} + +// writeJSONLFixture writes lines (already-JSON-encoded row strings) as a +// newline-delimited file in a fresh temp dir and returns its path. +func writeJSONLFixture(t *testing.T, lines ...string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "fixture.jsonl") + var buf bytes.Buffer + for _, line := range lines { + buf.WriteString(line) + buf.WriteByte('\n') + } + if err := os.WriteFile(path, buf.Bytes(), 0o644); err != nil { + t.Fatalf("write jsonl fixture: %v", err) + } + return path +} + +// buildChatsFixture writes one two-turn conversation into +// /Lethean/lem/users//chats.duckdb — the exact convention +// runDataImportChats resolves — so --chats import tests have real chat +// history to read. +func buildChatsFixture(t *testing.T, home, user string) { + t.Helper() + path := core.Path(home, "Lethean", "lem", "users", user, "chats.duckdb") + h, err := chathistory.Open(user, path) + if err != nil { + t.Fatalf("open chathistory fixture: %v", err) + } + defer h.Close() + convID, err := h.StartConversation(chathistory.NewConversation{Title: "fixture", ModelID: "lemer-lite"}) + if err != nil { + t.Fatalf("start conversation: %v", err) + } + if _, err := h.WriteTurn(convID, chathistory.NewTurn{Role: "user", Content: "hello there"}); err != nil { + t.Fatalf("write user turn: %v", err) + } + if _, err := h.WriteTurn(convID, chathistory.NewTurn{Role: "assistant", Content: "hi, how can I help?"}); err != nil { + t.Fatalf("write assistant turn: %v", err) + } + if err := h.EndConversation(convID); err != nil { + t.Fatalf("end conversation: %v", err) + } +} diff --git a/cli/generate.go b/cli/generate.go index 9c06aceb6..3bb667e10 100644 --- a/cli/generate.go +++ b/cli/generate.go @@ -45,7 +45,7 @@ func runGenerateCommand(ctx context.Context, args []string, stdout, stderr io.Wr temp := fs.Float64("temp", 1.0, "sampling temperature (0 = greedy/argmax — fastest, fair vs llama-bench)") think := fs.Bool("think", false, "enable the thinking channel (off keeps the decode rate clean)") contextLen := fs.Int("context", 0, "context length override (0 = model default)") - kvCacheMode := fs.String("kv-cache", "", "KV cache mode override (engine-reported; the no-cgo metal engine runs its built-in cache only — other values are noted and ignored)") + kvCacheMode := fs.String("kv-cache", "", "live KV cache mode: native (default) or turboquant[:4|:3.5|:3|:2] — TurboQuant codes on global attention layers; unknown/unservable modes fail the load loudly") pipeline := fs.Bool("pipeline", true, "one-ahead pipelined decode (the engine default; false forces the chained serial loop, for A/B traces)") kvStorage := fs.String("kv-storage", "", "KV snapshot encoding for --state sleeps (native, q8, float32; empty = native) — inert without --state") tracePhases := fs.Bool("trace", false, "print the per-token decode time budget — GPU wait vs host-serial work") @@ -94,8 +94,8 @@ func runGenerateCommand(ctx context.Context, args []string, stdout, stderr io.Wr core.Print(stderr, "%s generate: read --prompt-file %s: %s", cliName(), *promptFile, read.Error()) return 1 } - bytes, ok := read.Value.([]byte) - if !ok || len(bytes) == 0 { + bytes := read.Bytes() + if len(bytes) == 0 { core.Print(stderr, "%s generate: --prompt-file %s is empty", cliName(), *promptFile) return 1 } diff --git a/cli/go.mod b/cli/go.mod index a704afe64..1e9963bee 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -5,61 +5,93 @@ go 1.26.2 // The lem CLI (and its sibling commands) as their own module: go/ stays a // pure library; cli/ consumes it. Inside the repository the root go.work // overrides this require with the live ./go tree; standalone builds resolve -// the released module (tag go/v0.12.0). -require dappco.re/go/inference v0.13.0 +// the released module (tag go/v0.14.0). +require dappco.re/go/inference v0.14.0 require ( - dappco.re/go v0.11.0 + dappco.re/go v0.12.0 dappco.re/go/api v0.19.0 - dappco.re/go/io v0.15.0 + dappco.re/go/config v0.18.0 + dappco.re/go/container v0.11.0 + dappco.re/go/io v0.15.1 + dappco.re/go/orm v0.1.1 + dappco.re/go/process v0.16.1 + dappco.re/go/store v0.14.1 github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 - github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/glamour v1.0.0 + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 + github.com/charmbracelet/x/ansi v0.11.6 + github.com/google/uuid v1.6.0 ) require ( + charm.land/bubbles/v2 v2.0.0 // indirect + charm.land/bubbletea/v2 v2.0.2 // indirect + charm.land/glamour/v2 v2.0.0 // indirect + charm.land/lipgloss/v2 v2.0.2 // indirect + charm.land/log/v2 v2.0.0 // indirect + charm.land/wish/v2 v2.0.0 // indirect dappco.re/go/cgo v0.11.2 // indirect + dappco.re/go/html v0.11.0 // direct: cli/tui renders .ctml (tabs.go) dappco.re/go/i18n v0.12.1 // indirect dappco.re/go/log v0.13.1 // indirect - dappco.re/go/process v0.16.1 // indirect dappco.re/go/ratelimit v0.12.1 // indirect - dappco.re/go/store v0.14.0 // indirect forge.lthn.ai/Snider/Enchantrix v0.0.5 // indirect github.com/99designs/gqlgen v0.17.88 // indirect github.com/KyleBanks/depth v1.2.1 // indirect github.com/ProtonMail/go-crypto v1.3.0 // indirect github.com/agnivade/levenshtein v1.2.1 // indirect + github.com/alecthomas/chroma/v2 v2.23.1 // indirect github.com/andybalholm/brotli v1.2.0 // indirect - github.com/apache/arrow-go/v18 v18.4.1 // indirect + github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect + github.com/apache/arrow-go/v18 v18.5.1 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/git-module v1.8.4-0.20250826192401-1f81c5471e53 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymerick/douceur v0.2.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect github.com/bytedance/gopkg v0.1.4 // indirect github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/caarlos0/duration v0.0.0-20240108180406-5d492514f3c7 // indirect + github.com/caarlos0/env/v11 v11.4.0 // indirect github.com/casbin/casbin/v2 v2.135.0 // indirect github.com/casbin/govaluate v1.10.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/charmbracelet/colorprofile v0.4.1 // indirect - github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/git-lfs-transfer v0.1.1-0.20240708204110-bacbfdb68d92 // indirect + github.com/charmbracelet/keygen v0.5.4 // indirect + github.com/charmbracelet/soft-serve v0.11.6 // indirect + github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260223171050-89c142e4aa73 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/conpty v0.1.1 // indirect + github.com/charmbracelet/x/errors v0.0.0-20251110184232-6ab307057ac7 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/term v0.2.2 // indirect - github.com/clipperhouse/displaywidth v0.9.0 // indirect - github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/cloudflare/circl v1.6.3 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/coreos/go-oidc/v3 v3.17.0 // indirect - github.com/duckdb/duckdb-go-bindings v0.1.21 // indirect - github.com/duckdb/duckdb-go-bindings/darwin-amd64 v0.1.21 // indirect - github.com/duckdb/duckdb-go-bindings/darwin-arm64 v0.1.21 // indirect - github.com/duckdb/duckdb-go-bindings/linux-amd64 v0.1.21 // indirect - github.com/duckdb/duckdb-go-bindings/linux-arm64 v0.1.21 // indirect - github.com/duckdb/duckdb-go-bindings/windows-amd64 v0.1.21 // indirect + github.com/creack/pty v1.1.24 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/duckdb/duckdb-go-bindings v0.10504.0 // indirect + github.com/duckdb/duckdb-go-bindings/lib/darwin-amd64 v0.10504.0 // indirect + github.com/duckdb/duckdb-go-bindings/lib/darwin-arm64 v0.10504.0 // indirect + github.com/duckdb/duckdb-go-bindings/lib/linux-amd64 v0.10504.0 // indirect + github.com/duckdb/duckdb-go-bindings/lib/linux-arm64 v0.10504.0 // indirect + github.com/duckdb/duckdb-go-bindings/lib/windows-amd64 v0.10504.0 // indirect + github.com/duckdb/duckdb-go/v2 v2.10504.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/ebitengine/purego v0.10.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gin-contrib/authz v1.0.6 // indirect github.com/gin-contrib/cors v1.7.6 // indirect @@ -75,7 +107,12 @@ require ( github.com/gin-contrib/static v1.1.5 // indirect github.com/gin-contrib/timeout v1.1.0 // indirect github.com/gin-gonic/gin v1.12.0 // indirect + github.com/git-lfs/pktline v0.0.0-20230103162542-ca444d533ef1 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-git/v5 v5.17.0 // indirect + github.com/go-jose/go-jose/v3 v3.0.4 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect + github.com/go-logfmt/logfmt v0.6.1 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.22.5 // indirect @@ -92,46 +129,66 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/gobwas/glob v0.2.3 // indirect github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-yaml v1.19.2 // indirect - github.com/google/flatbuffers v25.2.10+incompatible // indirect - github.com/google/uuid v1.6.0 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/google/flatbuffers v25.12.19+incompatible // indirect + github.com/google/go-querystring v1.2.0 // indirect github.com/gorilla/context v1.1.2 // indirect + github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/sessions v1.4.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/influxdata/influxdb-client-go/v2 v2.14.0 // indirect github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 // indirect + github.com/jmoiron/sqlx v1.4.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.5 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/lib/pq v1.11.2 // indirect + github.com/lrstanley/bubblezone/v2 v2.0.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect - github.com/marcboeker/go-duckdb/arrowmapping v0.0.21 // indirect - github.com/marcboeker/go-duckdb/mapping v0.0.21 // indirect - github.com/marcboeker/go-duckdb/v2 v2.4.3 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.21 // indirect + github.com/mcuadros/go-version v0.0.0-20190830083331-035f6764e8d2 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.16.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/oapi-codegen/runtime v1.0.0 // indirect github.com/parquet-go/bitpack v1.0.0 // indirect github.com/parquet-go/jsonlite v1.0.0 // indirect github.com/parquet-go/parquet-go v0.29.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect - github.com/pierrec/lz4/v4 v4.1.22 // indirect + github.com/pierrec/lz4/v4 v4.1.25 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect + github.com/sergi/go-diff v1.4.0 // indirect github.com/sosodev/duration v1.4.0 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/spf13/viper v1.21.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect github.com/swaggo/files v1.0.1 // indirect github.com/swaggo/gin-swagger v1.6.1 // indirect github.com/swaggo/swag v1.16.6 // indirect @@ -140,7 +197,12 @@ require ( github.com/twpayne/go-geom v1.6.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect github.com/vektah/gqlparser/v2 v2.5.32 // indirect + github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + github.com/xeipuuv/gojsonschema v1.2.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yuin/goldmark v1.7.13 // indirect + github.com/yuin/goldmark-emoji v1.0.6 // indirect github.com/zeebo/xxh3 v1.1.0 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect @@ -149,6 +211,7 @@ require ( go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/sdk v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.25.0 // indirect golang.org/x/crypto v0.50.0 // indirect @@ -159,10 +222,12 @@ require ( golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.43.0 // indirect golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa // indirect + golang.org/x/term v0.42.0 // indirect golang.org/x/text v0.37.0 // indirect golang.org/x/tools v0.44.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.70.0 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/cli/go.sum b/cli/go.sum index 0dcbdbcdc..6b93e6f46 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -1,23 +1,45 @@ -dappco.re/go v0.11.0 h1:ag+nt9p9vP+J+GKxLu+o+pBDRADlVVWKs34zPZhXMJ4= -dappco.re/go v0.11.0/go.mod h1:xapr7fLK4/9Pu2iSCr4qZuIuatmtx1j56zS/oPDbGyQ= +charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s= +charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI= +charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= +charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= +charm.land/glamour/v2 v2.0.0 h1:IDBoqLEy7Hdpb9VOXN+khLP/XSxtJy1VsHuW/yF87+U= +charm.land/glamour/v2 v2.0.0/go.mod h1:kjq9WB0s8vuUYZNYey2jp4Lgd9f4cKdzAw88FZtpj/w= +charm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs= +charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= +charm.land/log/v2 v2.0.0 h1:SY3Cey7ipx86/MBXQHwsguOT6X1exT94mmJRdzTNs+s= +charm.land/log/v2 v2.0.0/go.mod h1:c3cZSRqm20qUVVAR1WmS/7ab8bgha3C6G7DjPcaVZz0= +charm.land/wish/v2 v2.0.0 h1:0vryoDz6G1SdJNIWSkExy88dLAs7H/w0x9y/cay1vno= +charm.land/wish/v2 v2.0.0/go.mod h1:B42DmuVdvQxz215H9aCsbrXVSuAInAqkHAnmwg0nKs8= +dappco.re/go v0.12.0 h1:lotJop5nvhrIQ1J2L5IP37H72ihnQnIjNzFT0iMEfx0= +dappco.re/go v0.12.0/go.mod h1:xapr7fLK4/9Pu2iSCr4qZuIuatmtx1j56zS/oPDbGyQ= dappco.re/go/api v0.19.0 h1:vhkSRaUpDEz7Jm8tWbwWHvz1f4+TiO82JK8Pytv/sEk= dappco.re/go/api v0.19.0/go.mod h1:Q0IEARpCTgQjOU6eGZTG2jq0WLI4GJ+eCt+Ys3n/hxg= dappco.re/go/cgo v0.11.2 h1:2CM3JcPLtPMG5vmWvvpEw7SLSKLKBL6uvJ39ZhPmws8= dappco.re/go/cgo v0.11.2/go.mod h1:StRPsPiEFusoV5rWsdS0eLRz8Wrai/sMlNBZ/4d52RI= +dappco.re/go/config v0.18.0 h1:D31NSq8vE/J3Z1lM0tsOLzToJ7MMxkk1SFdFqOv3Hg4= +dappco.re/go/config v0.18.0/go.mod h1:43oQx/mHqo+H2zzHcSVlZ5Lq1NF0aVhXlWKWt4XXXqc= +dappco.re/go/container v0.11.0 h1:XbZS3VdMoxHIn1EQTnU44kJSB/H95rVrZ6ceujAUPSE= +dappco.re/go/container v0.11.0/go.mod h1:DTA3lcbMG3bfve8hlDls1UNxRgGAZELCGuuM5XKSpgg= +dappco.re/go/html v0.11.0 h1:Qau6MinQATThPmMamnzyaSV06mr10QzDdAcvkIqwlBk= +dappco.re/go/html v0.11.0/go.mod h1:vhB4+z5cz6WYRqG7c/sDAOxm7Esc8GHEVnOpoN48+r4= dappco.re/go/i18n v0.12.1 h1:Pm1DF9I0O8bckTr815OrnR+G+7EvbQhP2Vk4ItC0mB4= dappco.re/go/i18n v0.12.1/go.mod h1:AYx1QkPfW+qvzLCfJnuOlc4J+grwlgFzoHnoke4TAYg= -dappco.re/go/inference v0.13.0 h1:EofDdOsOoPGzhmIIehG83uadJFhTkjMhVwkx/8wz9H0= -dappco.re/go/inference v0.13.0/go.mod h1:ck2XEIzN2/lw4fS/aao2G9FEoHgV3w5h+X2nP1SUufo= -dappco.re/go/io v0.15.0 h1:heq9uuOs0SvLqq8QjEAmiZuCamYWGbqVaJsFwwMnkDg= -dappco.re/go/io v0.15.0/go.mod h1:01CUltzAA5sIGIgfB0xMvIfbhA7l4RfufVuvY2T/Smk= +dappco.re/go/inference v0.14.0 h1:3Lddj4W8T7/Mg8iTmvS+hCUkUqYYhRPYGe6i/ILF1+o= +dappco.re/go/inference v0.14.0/go.mod h1:Np59daaiJPVP/gI/7os4euqMCCqnt4irvLdex6xy8ks= +dappco.re/go/io v0.15.1 h1:HLrUG+uGVQgLfxeJRwMvf8VhTWe2CnAJWM4RBvs8HHo= +dappco.re/go/io v0.15.1/go.mod h1:I2kYHxtgactjcyih9TYQADA8jLE3NjfZHbwCVwQ3KyE= dappco.re/go/log v0.13.1 h1:sfrB1hrxODsCo0A8psIvBqDkQD2mpoQ8S5S72zO2KlI= dappco.re/go/log v0.13.1/go.mod h1:FBMMEdmIvbXfvv3qOdW6acFnwMFgTw56csevEIELi/c= +dappco.re/go/orm v0.1.1 h1:HYXXk0R0l3wqVS1eVNKvU/C11tK2ER7IgVR9pV1LhfI= +dappco.re/go/orm v0.1.1/go.mod h1:vm1/W25fXxEav6E5i1XmBvExdgLXpLcna0LWXRWHUBY= dappco.re/go/process v0.16.1 h1:CMsiDrY7cMKd8B9UtUh0WIG9FkuNJ2wlz2x80KS20p4= dappco.re/go/process v0.16.1/go.mod h1:GgES0/Y75iRdrxbWizxyipf7fdz4W4LB+2Qp5Yu9Owk= dappco.re/go/ratelimit v0.12.1 h1:3NTFrsNGfuyZYWsKXTOyJbKSZiN2f6/9abeAbvaRV7w= dappco.re/go/ratelimit v0.12.1/go.mod h1:5Vdbbyyh99N3tMMqYVbTj8/Bg/jg3f31fscWsAKonuk= -dappco.re/go/store v0.14.0 h1:fjeKQKBn9lslTYr7tmtrzRHHxOT8vbT5nybBxZPaXOA= -dappco.re/go/store v0.14.0/go.mod h1:fSvfRyAJLH+OagscxPNBHu8Qip2ivzRp46wB68yN7ZY= +dappco.re/go/store v0.14.1 h1:om6d865punooegHQFZ6tnZoFOp/Ec0xh0iW/kKx/pWc= +dappco.re/go/store v0.14.1/go.mod h1:ys8oTbYaB5wgxWQXcI48b0Mjm0aOld9PGJnqlU7vNs0= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= forge.lthn.ai/Snider/Borg v0.3.1 h1:gfC1ZTpLoZai07oOWJiVeQ8+qJYK8A795tgVGJHbVL8= forge.lthn.ai/Snider/Borg v0.3.1/go.mod h1:Z7DJD0yHXsxSyM7Mjl6/g4gH1NBsIz44Bf5AFlV76Wg= forge.lthn.ai/Snider/Enchantrix v0.0.5 h1:Yam0z+3AOvCUCHAMP68Ty8qHr2e4MMs7j2FjMM2JWc8= @@ -39,6 +61,8 @@ github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KO github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY= +github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= @@ -47,8 +71,10 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= -github.com/apache/arrow-go/v18 v18.4.1 h1:q/jVkBWCJOB9reDgaIZIdruLQUb1kbkvOnOFezVH1C4= -github.com/apache/arrow-go/v18 v18.4.1/go.mod h1:tLyFubsAl17bvFdUAy24bsSvA/6ww95Iqi67fTpGu3E= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/apache/arrow-go/v18 v18.5.1 h1:yaQ6zxMGgf9YCYw4/oaeOU3AULySDlAYDOcnr4LdHdI= +github.com/apache/arrow-go/v18 v18.5.1/go.mod h1:OCCJsmdq8AsRm8FkBSSmYTwL/s4zHW9CqxeBxEytkNE= github.com/apache/thrift v0.22.0 h1:r7mTJdj51TMDe6RtcmNdQxgn9XcyfGDOzegMDRg47uc= github.com/apache/thrift v0.22.0/go.mod h1:1e7J/O1Ae6ZQMTYdy9xa3w9k+XHWPfRvdPyJeynQ+/g= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= @@ -79,10 +105,16 @@ github.com/aws/aws-sdk-go-v2/service/s3 v1.97.1 h1:csi9NLpFZXb9fxY7rS1xVzgPRGMt7 github.com/aws/aws-sdk-go-v2/service/s3 v1.97.1/go.mod h1:qXVal5H0ChqXP63t6jze5LmFalc7+ZE7wOdLtZ0LCP0= github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aymanbagabas/git-module v1.8.4-0.20250826192401-1f81c5471e53 h1:KfKp+gVsQtuM9qb8Putvkx1jjAWqlvI1vdv5x9hdFoQ= +github.com/aymanbagabas/git-module v1.8.4-0.20250826192401-1f81c5471e53/go.mod h1:d4gQ7/3/S2sPq4NnKdtAgUOVr6XtLpWFtxyVV5/+76U= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= -github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= @@ -93,6 +125,10 @@ github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uS github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/caarlos0/duration v0.0.0-20240108180406-5d492514f3c7 h1:kJP/C2eL9DCKrCOlX6lPVmAUAb6U4u9xllgws1kP9ds= +github.com/caarlos0/duration v0.0.0-20240108180406-5d492514f3c7/go.mod h1:mSkwb/eZEwOJJJ4tqAKiuhLIPe0e9+FKhlU0oMCpbf8= +github.com/caarlos0/env/v11 v11.4.0 h1:Kcb6t5kIIr4XkoQC9AF2j+8E1Jsrl3Wz/hhm1LtoGAc= +github.com/caarlos0/env/v11 v11.4.0/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk= github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18= github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= @@ -104,22 +140,42 @@ github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5f github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= -github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= -github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= -github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/git-lfs-transfer v0.1.1-0.20240708204110-bacbfdb68d92 h1:KtQlsiHfY3K4AoIEh0yUE/wCLHteZ9EzV1hKmx+p7U8= +github.com/charmbracelet/git-lfs-transfer v0.1.1-0.20240708204110-bacbfdb68d92/go.mod h1:UrXUCm3xLQkq15fu7qlXHUMlrhdlXHoi13KH2Dfiits= +github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08= +github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo= +github.com/charmbracelet/keygen v0.5.4 h1:XQYgf6UEaTGgQSSmiPpIQ78WfseNQp4Pz8N/c1OsrdA= +github.com/charmbracelet/keygen v0.5.4/go.mod h1:t4oBRr41bvK7FaJsAaAQhhkUuHslzFXVjOBwA55CZNM= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= +github.com/charmbracelet/soft-serve v0.11.6 h1:Io5KQOS0sO4Tp79k2dnKrZhGnV2qaU0gpJgtPeox3Tc= +github.com/charmbracelet/soft-serve v0.11.6/go.mod h1:RR8CcMVyA9pZC6Jbl8eWYuMM0VLyEX5Du3jWzntEXaw= +github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309 h1:dCVbCRRtg9+tsfiTXTp0WupDlHruAXyp+YoxGVofHHc= +github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309/go.mod h1:R9cISUs5kAH4Cq/rguNbSwcR+slE5Dfm8FEs//uoIGE= +github.com/charmbracelet/ultraviolet v0.0.0-20260223171050-89c142e4aa73 h1:Af/L28Xh+pddhouT/6lJ7IAIYfu5tWJOB0iqt+mXsYM= +github.com/charmbracelet/ultraviolet v0.0.0-20260223171050-89c142e4aa73/go.mod h1:E6/0abq9uG2SnM8IbLB9Y5SW09uIgfaFETk8aRzgXUQ= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= -github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= -github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs= +github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk= +github.com/charmbracelet/x/errors v0.0.0-20251110184232-6ab307057ac7 h1:4EG8pCHK5fa8dIxv97VHC8hdkJAz6QNm1WB9BuD/WhY= +github.com/charmbracelet/x/errors v0.0.0-20251110184232-6ab307057ac7/go.mod h1:O2BTD/aMVQDmrvqroIO3fB6zXUuU07ZpVt21QTmZjRg= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= -github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= -github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= -github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= -github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= @@ -128,30 +184,41 @@ github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo= github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= -github.com/duckdb/duckdb-go-bindings v0.1.21 h1:bOb/MXNT4PN5JBZ7wpNg6hrj9+cuDjWDa4ee9UdbVyI= -github.com/duckdb/duckdb-go-bindings v0.1.21/go.mod h1:pBnfviMzANT/9hi4bg+zW4ykRZZPCXlVuvBWEcZofkc= -github.com/duckdb/duckdb-go-bindings/darwin-amd64 v0.1.21 h1:Sjjhf2F/zCjPF53c2VXOSKk0PzieMriSoyr5wfvr9d8= -github.com/duckdb/duckdb-go-bindings/darwin-amd64 v0.1.21/go.mod h1:Ezo7IbAfB8NP7CqPIN8XEHKUg5xdRRQhcPPlCXImXYA= -github.com/duckdb/duckdb-go-bindings/darwin-arm64 v0.1.21 h1:IUk0FFUB6dpWLhlN9hY1mmdPX7Hkn3QpyrAmn8pmS8g= -github.com/duckdb/duckdb-go-bindings/darwin-arm64 v0.1.21/go.mod h1:eS7m/mLnPQgVF4za1+xTyorKRBuK0/BA44Oy6DgrGXI= -github.com/duckdb/duckdb-go-bindings/linux-amd64 v0.1.21 h1:Qpc7ZE3n6Nwz30KTvaAwI6nGkXjXmMxBTdFpC8zDEYI= -github.com/duckdb/duckdb-go-bindings/linux-amd64 v0.1.21/go.mod h1:1GOuk1PixiESxLaCGFhag+oFi7aP+9W8byymRAvunBk= -github.com/duckdb/duckdb-go-bindings/linux-arm64 v0.1.21 h1:eX2DhobAZOgjXkh8lPnKAyrxj8gXd2nm+K71f6KV/mo= -github.com/duckdb/duckdb-go-bindings/linux-arm64 v0.1.21/go.mod h1:o7crKMpT2eOIi5/FY6HPqaXcvieeLSqdXXaXbruGX7w= -github.com/duckdb/duckdb-go-bindings/windows-amd64 v0.1.21 h1:hhziFnGV7mpA+v5J5G2JnYQ+UWCCP3NQ+OTvxFX10D8= -github.com/duckdb/duckdb-go-bindings/windows-amd64 v0.1.21/go.mod h1:IlOhJdVKUJCAPj3QsDszUo8DVdvp1nBFp4TUJVdw99s= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/duckdb/duckdb-go-bindings v0.10504.0 h1:XKOXNRetaJnMvMIDqi6etZOmh/nlEHM7saaC5UxU17I= +github.com/duckdb/duckdb-go-bindings v0.10504.0/go.mod h1:M2eB9+zGq+O4opimtLL5nWwkp8vdJQJNbFW5HKdqe4U= +github.com/duckdb/duckdb-go-bindings/lib/darwin-amd64 v0.10504.0 h1:hnWJ9SociR98hpypZPcgB+hTuWgw0hYsYnPFr8mBBEw= +github.com/duckdb/duckdb-go-bindings/lib/darwin-amd64 v0.10504.0/go.mod h1:EnAvZh1kNJHp5yF+M1ZHNEvapnmt6anq1xXHVrAGqMo= +github.com/duckdb/duckdb-go-bindings/lib/darwin-arm64 v0.10504.0 h1:q3JEdS5hU8ytvmcsgFEfGGQeJ5mS8l8QFYpfUlPHDRY= +github.com/duckdb/duckdb-go-bindings/lib/darwin-arm64 v0.10504.0/go.mod h1:IGLSeEcFhNeZF16aVjQCULD7TsFZKG5G7SyKJAXKp5c= +github.com/duckdb/duckdb-go-bindings/lib/linux-amd64 v0.10504.0 h1:2gABzYp2KnclSpE4qqinTZYhGpWSxmNHQKjs9WZ7zWk= +github.com/duckdb/duckdb-go-bindings/lib/linux-amd64 v0.10504.0/go.mod h1:KAIynZ0GHCS7X5fRyuFnQMg/SZBPK/bS9OCOVojClxw= +github.com/duckdb/duckdb-go-bindings/lib/linux-arm64 v0.10504.0 h1:iTFkIVt6Ohd/rTvNQomtbP/IIh90TFGSlOUqFEC68Xo= +github.com/duckdb/duckdb-go-bindings/lib/linux-arm64 v0.10504.0/go.mod h1:81SGOYoEUs8qaAfSk1wRfM5oobrIJ5KI7AzYhK6/bvQ= +github.com/duckdb/duckdb-go-bindings/lib/windows-amd64 v0.10504.0 h1:8O774uudYeexLa+uHekujkT3t3sDjQ7LSnlKIhRNWyc= +github.com/duckdb/duckdb-go-bindings/lib/windows-amd64 v0.10504.0/go.mod h1:K25pJL26ARblGDeuAkrdblFvUen92+CwksLtPEHRqqQ= +github.com/duckdb/duckdb-go/v2 v2.10504.0 h1:bnkcNQpz3EaJmMmfOhFRFJ3ELVDHW4u3PDhZ78ZTCMY= +github.com/duckdb/duckdb-go/v2 v2.10504.0/go.mod h1:DQ8TxrUb0RGyTTFTwPNlpQ9FFx+ocJjoZGDkuOktYp8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gin-contrib/authz v1.0.6 h1:qAO4sSSzOPCwYRZI6YtubC+h2tZVwhwSJeyEZn2W+5k= @@ -182,8 +249,18 @@ github.com/gin-contrib/timeout v1.1.0 h1:WAmWseo5gfBUbMrMJu5hJxDclehfSJUmK2wGwCC github.com/gin-contrib/timeout v1.1.0/go.mod h1:NpRo4gd1Ad8ZQ4T6bQLVFDqiplCmPRs2nvfckxS2Fw4= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/git-lfs/pktline v0.0.0-20230103162542-ca444d533ef1 h1:mtDjlmloH7ytdblogrMz1/8Hqua1y8B4ID+bh3rvod0= +github.com/git-lfs/pktline v0.0.0-20230103162542-ca444d533ef1/go.mod h1:fenKRzpXDjNpsIBhuhUzvjCKlDjKam0boRAenTE0Q6A= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-git/v5 v5.17.0 h1:AbyI4xf+7DsjINHMu35quAh4wJygKBKBuXVjV/pxesM= +github.com/go-git/go-git/v5 v5.17.0/go.mod h1:f82C4YiLx+Lhi8eHxltLeGC5uBTXSFa6PC5WW9o4SjI= +github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY= +github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE= +github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -224,20 +301,30 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc= github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q= -github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/flatbuffers v25.12.19+incompatible h1:haMV2JRRJCe1998HeW/p0X9UaMTK6SDo0ffLn2+DbLs= +github.com/google/flatbuffers v25.12.19+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= +github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -247,6 +334,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o= github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= @@ -257,10 +346,14 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/influxdata/influxdb-client-go/v2 v2.14.0 h1:AjbBfJuq+QoaXNcrova8smSjwJdUHnwvfjMF71M1iI4= github.com/influxdata/influxdb-client-go/v2 v2.14.0/go.mod h1:Ahpm3QXKMJslpXl3IftVLVezreAUtBOTZssDrjZEFHI= github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 h1:W9WBk7wlPfJLvMCdtV4zPulc4uCPrlywQOmbFOhgQNU= github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839/go.mod h1:xaLFMmpvUxqXtVkUJfg9QmT88cDaCJ3ZKgdZ78oO8Qo= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= @@ -272,28 +365,40 @@ github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzh github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs= +github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/lrstanley/bubblezone/v2 v2.0.0 h1:pMb9fHKs0slJF6OrzQ2hEgWusqyl9VU/S0UZ5hyh7ZA= +github.com/lrstanley/bubblezone/v2 v2.0.0/go.mod h1:yV/QTjcm4Zu5cqvGvdHi7xVUfnB36w/SafOuDp57dgY= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/marcboeker/go-duckdb/arrowmapping v0.0.21 h1:geHnVjlsAJGczSWEqYigy/7ARuD+eBtjd0kLN80SPJQ= -github.com/marcboeker/go-duckdb/arrowmapping v0.0.21/go.mod h1:flFTc9MSqQCh2Xm62RYvG3Kyj29h7OtsTb6zUx1CdK8= -github.com/marcboeker/go-duckdb/mapping v0.0.21 h1:6woNXZn8EfYdc9Vbv0qR6acnt0TM1s1eFqnrJZVrqEs= -github.com/marcboeker/go-duckdb/mapping v0.0.21/go.mod h1:q3smhpLyv2yfgkQd7gGHMd+H/Z905y+WYIUjrl29vT4= -github.com/marcboeker/go-duckdb/v2 v2.4.3 h1:bHUkphPsAp2Bh/VFEdiprGpUekxBNZiWWtK+Bv/ljRk= -github.com/marcboeker/go-duckdb/v2 v2.4.3/go.mod h1:taim9Hktg2igHdNBmg5vgTfHAlV26z3gBI0QXQOcuyI= +github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= +github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mcuadros/go-version v0.0.0-20190308113854-92cdf37c5b75/go.mod h1:76rfSfYPWj01Z85hUf/ituArm797mNKcvINh1OlsZKo= +github.com/mcuadros/go-version v0.0.0-20190830083331-035f6764e8d2 h1:YocNLcTBdEdvY3iDK6jfWXvEaM5OCKkjxPKoJRdB3Gg= +github.com/mcuadros/go-version v0.0.0-20190830083331-035f6764e8d2/go.mod h1:76rfSfYPWj01Z85hUf/ituArm797mNKcvINh1OlsZKo= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 h1:AMFGa4R4MiIpspGNG7Z948v4n35fFGB3RR3G/ry4FWs= github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY= github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 h1:+n/aFZefKZp7spd8DFdX7uMikMLXX4oubIzJF4kv/wI= @@ -307,8 +412,12 @@ github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/oapi-codegen/runtime v1.0.0 h1:P4rqFX5fMFWqRzY9M/3YF9+aPSPPB06IzP2P7oOxrWo= @@ -321,41 +430,72 @@ github.com/parquet-go/parquet-go v0.29.0 h1:xXlPtFVR51jpSVzf+cgHnNIcb7Xet+iuvkbe github.com/parquet-go/parquet-go v0.29.0/go.mod h1:navtkAYr2LGoJVp141oXPlO/sxLvaOe3la2JEoD8+rg= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= -github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0= +github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU= github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= +github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sosodev/duration v1.4.0 h1:35ed0KiVFriGHHzZZJaZLgmTEEICIyt8Sx0RQfj9IjE= github.com/sosodev/duration v1.4.0/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY= @@ -372,11 +512,21 @@ github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/vektah/gqlparser/v2 v2.5.32 h1:k9QPJd4sEDTL+qB4ncPLflqTJ3MmjB9SrVzJrawpFSc= github.com/vektah/gqlparser/v2 v2.5.32/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= +github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= +github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= @@ -405,30 +555,38 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/arch v0.25.0 h1:qnk6Ksugpi5Bz32947rkUgDt9/s5qvqDPl/gBKdMJLE= golang.org/x/arch v0.25.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -439,6 +597,8 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa h1:efT73AJZfAAUV7SOip6pWGkwJDzIGiKBZGVzHYa+ve4= @@ -446,16 +606,23 @@ golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/ golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -466,8 +633,13 @@ gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/cli/judgedriver.go b/cli/judgedriver.go new file mode 100644 index 000000000..a51e8ca20 --- /dev/null +++ b/cli/judgedriver.go @@ -0,0 +1,171 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package main + +import ( + "context" + + core "dappco.re/go" + "dappco.re/go/inference" + "dappco.re/go/inference/cli/tui" + "dappco.re/go/inference/dataset" + "dappco.re/go/inference/serving/provider/openai" +) + +// judgeDefaultMaxTokens bounds a judge verdict generation: the contract's +// parse rule wants a bare number back (parseJudgeScore), so a short cap +// keeps a rambling model's cost bounded without starving a well-behaved one +// — a stray "Score: 87" or "87/100" still fits comfortably. +const judgeDefaultMaxTokens = 32 + +// judgeVerdictPayload is the Score.Payload JSON shape a judge-tier row +// carries — the rendered template's raw model reply, kept for audit +// alongside the parsed Value. +type judgeVerdictPayload struct { + Template string `json:"template"` + RawReply string `json:"raw_reply"` +} + +// newJudgeDispatcher is the production seam `lem data score --kind +// judge: --model ` drives: loads modelPath ONCE (a batch CLI +// verb scoring N items never reloads per item — one load, N serial +// generate calls, the "only one model resident" constraint go/dataset's +// JudgeDispatcher doc names, sized to a one-shot command rather than the +// TUI's live queue) and resolves templates through the design's real dirs +// (~/.lem/judges/ override, the in-repo judges/ default). The caller must +// defer the returned close func. +// +// driver, closeModel, err := newJudgeDispatcher("/models/judge-4b", 32) +// if err != nil { return err } +// defer closeModel() +func newJudgeDispatcher(modelPath string, maxTokens int) (dataset.JudgeDispatcher, func() error, error) { + if core.Trim(modelPath) == "" { + return nil, nil, core.NewError("dataset: judge driver requires a model path") + } + loaded := inference.LoadModel(modelPath) + if !loaded.OK { + return nil, nil, core.E("main.newJudgeDispatcher", "load judge model", loaded.Err()) + } + tm, ok := loaded.Value.(inference.TextModel) + if !ok { + return nil, nil, core.E("main.newJudgeDispatcher", "judge model load returned an unexpected type", nil) + } + + overrideDir := "" + if dirResult := tui.OpenJudgesDir(); dirResult.OK { + overrideDir = dirResult.String() + } + inRepoDir, _ := defaultInRepoJudgesDir() + + dispatcher := newJudgeDispatcherFromModel(tm, modelPath, maxTokens, overrideDir, inRepoDir) + closeFn := func() error { + r := tm.Close() + if !r.OK { + return r.Err() + } + return nil + } + return dispatcher, closeFn, nil +} + +// newJudgeDispatcherFromModel builds the dataset.JudgeDispatcher closure +// around an already-loaded model — the pure core the stub-model tests drive +// directly (no GPU, no real load: newJudgeDispatcher is the only caller +// that touches inference.LoadModel). Resolved templates are cached by name +// for the lifetime of the closure so scoring N items against the same +// judge: reads + parses the template file once, not N times. +func newJudgeDispatcherFromModel(tm inference.TextModel, fingerprint string, maxTokens int, overrideDir, inRepoDir string) dataset.JudgeDispatcher { + if maxTokens <= 0 { + maxTokens = judgeDefaultMaxTokens + } + templates := map[string]judgeTemplate{} + + return func(ctx context.Context, name string, item dataset.Item) (dataset.JudgeVerdict, error) { + tpl, ok := templates[name] + if !ok { + resolved, rerr := resolveJudgeTemplateFrom(overrideDir, inRepoDir, name) + if rerr != nil { + return dataset.JudgeVerdict{}, rerr + } + templates[name] = resolved + tpl = resolved + } + + prompt, response, perr := judgeItemPromptResponse(item) + if perr != nil { + return dataset.JudgeVerdict{}, perr + } + rendered := renderJudgeTemplate(tpl, prompt, response) + + reply, gerr := runJudgeGenerate(ctx, tm, rendered, maxTokens) + if gerr != nil { + return dataset.JudgeVerdict{}, gerr + } + + value, serr := parseJudgeScore(reply, tpl) + if serr != nil { + return dataset.JudgeVerdict{}, serr + } + + payload := core.JSONMarshal(judgeVerdictPayload{Template: name, RawReply: core.Trim(reply)}) + return dataset.JudgeVerdict{Value: value, Fingerprint: fingerprint, Payload: payload.Bytes()}, nil + } +} + +// runJudgeGenerate runs one greedy (temperature 0), bounded-max-tokens +// generation against tm and returns the assistant content with any +// thinking-channel markers stripped (openai.NewThinkingExtractor — the same +// extractor every serving route runs; Gemma 4 emits an empty thought +// channel even with thinking off, and the judge reply must be clean for +// parseJudgeScore's strict bare-number rule). +func runJudgeGenerate(ctx context.Context, tm inference.TextModel, prompt string, maxTokens int) (string, error) { + think := false + msgs := []inference.Message{{Role: "user", Content: prompt}} + opts := []inference.GenerateOption{ + inference.WithMaxTokens(maxTokens), + inference.WithTemperature(0), + inference.WithEnableThinking(&think), + } + + var out []byte + for tok := range tm.Chat(ctx, msgs, opts...) { + out = append(out, tok.Text...) + } + if r := tm.Err(); !r.OK { + return "", core.E("main.runJudgeGenerate", "judge generation", r.Err()) + } + + extractor := openai.NewThinkingExtractor() + content, _ := extractor.Process(inference.Token{Text: string(out)}) + flushContent, _ := extractor.Flush() + return content + flushContent, nil +} + +// judgeItemPromptResponse extracts the (prompt, response) text pair a judge +// template renders, mirroring go/dataset's unexported +// heuristicPromptResponse over the SAME exported content shapes +// (dataset.PairContent / dataset.MessagesContent) — go/dataset never talks +// to a model directly, so this small switch is intentionally duplicated +// CLI-side rather than exported from the frozen contract. +func judgeItemPromptResponse(item dataset.Item) (string, string, error) { + switch item.Kind { + case dataset.KindPair: + var pc dataset.PairContent + if r := core.JSONUnmarshal(item.Content, &pc); !r.OK { + return "", "", core.E("main.judgeItemPromptResponse", "parse pair content", r.Err()) + } + return pc.Prompt, pc.Response, nil + case dataset.KindMessages: + var mc dataset.MessagesContent + if r := core.JSONUnmarshal(item.Content, &mc); !r.OK { + return "", "", core.E("main.judgeItemPromptResponse", "parse messages content", r.Err()) + } + pc, ok := mc.LastExchange() + if !ok { + return "", "", core.E("main.judgeItemPromptResponse", "messages content has no assistant turn to score", nil) + } + return pc.Prompt, pc.Response, nil + default: + return "", "", core.E("main.judgeItemPromptResponse", core.Sprintf("item kind %q is not scorable by the judge tier", item.Kind), nil) + } +} diff --git a/cli/judgedriver_test.go b/cli/judgedriver_test.go new file mode 100644 index 000000000..9b7fc2f84 --- /dev/null +++ b/cli/judgedriver_test.go @@ -0,0 +1,271 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package main + +import ( + "context" + "iter" + "os" + "path/filepath" + "testing" + + core "dappco.re/go" + "dappco.re/go/inference" + "dappco.re/go/inference/dataset" +) + +// stubJudgeModel is a minimal inference.TextModel fake for judge-driver +// tests: no GPU, no real load — Chat replays one scripted reply (or an +// error via chatErr, surfaced through Err() the way a real engine reports a +// mid-stream failure after the iterator stops). newJudgeDispatcherFromModel +// is the only seam under test here; newJudgeDispatcher's real +// inference.LoadModel call is exercised end-to-end via cli/data_test.go's +// TestRunDataScore_Judge instead (a real GPU model run is the +// orchestrator's merge gate, never a unit test's job). +type stubJudgeModel struct { + reply string + chatErr error +} + +func (s *stubJudgeModel) Generate(ctx context.Context, prompt string, opts ...inference.GenerateOption) iter.Seq[inference.Token] { + return s.Chat(ctx, nil, opts...) +} + +func (s *stubJudgeModel) Chat(ctx context.Context, messages []inference.Message, opts ...inference.GenerateOption) iter.Seq[inference.Token] { + return func(yield func(inference.Token) bool) { + if s.chatErr != nil || s.reply == "" { + return + } + yield(inference.Token{Text: s.reply}) + } +} + +func (s *stubJudgeModel) Classify(ctx context.Context, prompts []string, opts ...inference.GenerateOption) core.Result { + return core.Fail(core.NewError("stubJudgeModel: Classify not implemented")) +} + +func (s *stubJudgeModel) BatchGenerate(ctx context.Context, prompts []string, opts ...inference.GenerateOption) core.Result { + return core.Fail(core.NewError("stubJudgeModel: BatchGenerate not implemented")) +} + +func (s *stubJudgeModel) ModelType() string { return "stub" } +func (s *stubJudgeModel) Info() inference.ModelInfo { return inference.ModelInfo{} } +func (s *stubJudgeModel) Metrics() inference.GenerateMetrics { return inference.GenerateMetrics{} } + +func (s *stubJudgeModel) Err() core.Result { + if s.chatErr != nil { + return core.Fail(s.chatErr) + } + return core.Ok(nil) +} + +func (s *stubJudgeModel) Close() core.Result { return core.Ok(nil) } + +// pairItemFixture builds a KindPair dataset.Item carrying (prompt, +// response) as its Content — the fixture judge-driver tests score. +func pairItemFixture(t *testing.T, prompt, response string) dataset.Item { + t.Helper() + content := core.JSONMarshal(dataset.PairContent{Prompt: prompt, Response: response}) + if !content.OK { + t.Fatalf("marshal pair content: %v", content.Value) + } + return dataset.Item{ID: dataset.NewID(), Kind: dataset.KindPair, Content: content.Bytes()} +} + +// ---- judgeItemPromptResponse ---- + +func TestJudgeItemPromptResponse_Good(t *testing.T) { + t.Run("pair content", func(t *testing.T) { + item := pairItemFixture(t, "hi", "hello") + prompt, response, err := judgeItemPromptResponse(item) + if err != nil || prompt != "hi" || response != "hello" { + t.Fatalf("judgeItemPromptResponse = (%q, %q, %v)", prompt, response, err) + } + }) + t.Run("messages content reduces to the last exchange", func(t *testing.T) { + mc := dataset.MessagesContent{Messages: []dataset.MessageTurn{ + {Role: "user", Content: "hi"}, + {Role: "assistant", Content: "hello"}, + }} + content := core.JSONMarshal(mc) + if !content.OK { + t.Fatalf("marshal messages content: %v", content.Value) + } + item := dataset.Item{ID: dataset.NewID(), Kind: dataset.KindMessages, Content: content.Bytes()} + prompt, response, err := judgeItemPromptResponse(item) + // LastExchange newline-joins every turn before the last assistant + // turn (go/dataset/dataset.go), so a single preceding "hi" turn + // becomes "hi\n" — not go-inference driver behaviour, the frozen + // contract's own reduction. + if err != nil || prompt != "hi\n" || response != "hello" { + t.Fatalf("judgeItemPromptResponse = (%q, %q, %v)", prompt, response, err) + } + }) +} + +func TestJudgeItemPromptResponse_Bad(t *testing.T) { + t.Run("trace kind is not scorable", func(t *testing.T) { + item := dataset.Item{ID: dataset.NewID(), Kind: dataset.KindTrace, Content: []byte(`{"x":1}`)} + if _, _, err := judgeItemPromptResponse(item); err == nil { + t.Fatalf("judgeItemPromptResponse(trace) = nil error") + } + }) + t.Run("malformed pair json", func(t *testing.T) { + item := dataset.Item{ID: dataset.NewID(), Kind: dataset.KindPair, Content: []byte("not json")} + if _, _, err := judgeItemPromptResponse(item); err == nil { + t.Fatalf("judgeItemPromptResponse(malformed pair) = nil error") + } + }) + t.Run("messages with no assistant turn", func(t *testing.T) { + mc := dataset.MessagesContent{Messages: []dataset.MessageTurn{{Role: "user", Content: "hi"}}} + content := core.JSONMarshal(mc) + if !content.OK { + t.Fatalf("marshal messages content: %v", content.Value) + } + item := dataset.Item{ID: dataset.NewID(), Kind: dataset.KindMessages, Content: content.Bytes()} + if _, _, err := judgeItemPromptResponse(item); err == nil { + t.Fatalf("judgeItemPromptResponse(no assistant turn) = nil error") + } + }) +} + +// ---- newJudgeDispatcherFromModel: the stub-model driver tests ---- + +func TestNewJudgeDispatcherFromModel_Good(t *testing.T) { + inRepoDir := t.TempDir() + writeJudgeTemplateFile(t, inRepoDir, "quality", qualityTemplateFixture("")) + stub := &stubJudgeModel{reply: "87"} + dispatcher := newJudgeDispatcherFromModel(stub, "fake-model-path", 32, "", inRepoDir) + + item := pairItemFixture(t, "what is 2+2?", "4") + verdict, err := dispatcher(context.Background(), "quality", item) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + if verdict.Value != 87 { + t.Errorf("Value = %v, want 87", verdict.Value) + } + if verdict.Fingerprint != "fake-model-path" { + t.Errorf("Fingerprint = %q, want fake-model-path", verdict.Fingerprint) + } + if len(verdict.Payload) == 0 || !core.Contains(string(verdict.Payload), "87") { + t.Errorf("Payload = %s, want it to carry the raw reply", verdict.Payload) + } +} + +// TestNewJudgeDispatcherFromModel_OverrideWins is the resolution-order +// contract exercised through the actual dispatch path (resolveJudgeTemplateFrom +// itself is covered directly in judgetemplate_test.go) — an override in the +// first dir wins even though a differently-scored in-repo default of the +// same name exists in the second. +func TestNewJudgeDispatcherFromModel_OverrideWins(t *testing.T) { + overrideDir := t.TempDir() + inRepoDir := t.TempDir() + writeJudgeTemplateFile(t, overrideDir, "quality", qualityTemplateFixture("Override prompt for {{prompt}} / {{response}}.\n\n"+wantOnlyNumberInstruction)) + writeJudgeTemplateFile(t, inRepoDir, "quality", qualityTemplateFixture("In-repo prompt for {{prompt}} / {{response}}.\n\n"+wantOnlyNumberInstruction)) + + stub := &stubJudgeModel{reply: "60"} + dispatcher := newJudgeDispatcherFromModel(stub, "fp", 32, overrideDir, inRepoDir) + item := pairItemFixture(t, "p", "r") + if _, err := dispatcher(context.Background(), "quality", item); err != nil { + t.Fatalf("dispatch: %v", err) + } +} + +// TestNewJudgeDispatcherFromModel_CachesResolvedTemplate proves a template +// resolved once for a name is cached for the dispatcher's lifetime: scoring +// a second item against the same judge: after the on-disk file has +// been removed still succeeds, because the second call never re-reads it. +func TestNewJudgeDispatcherFromModel_CachesResolvedTemplate(t *testing.T) { + inRepoDir := t.TempDir() + writeJudgeTemplateFile(t, inRepoDir, "quality", qualityTemplateFixture("")) + stub := &stubJudgeModel{reply: "50"} + dispatcher := newJudgeDispatcherFromModel(stub, "fp", 32, "", inRepoDir) + item := pairItemFixture(t, "p", "r") + + if _, err := dispatcher(context.Background(), "quality", item); err != nil { + t.Fatalf("first dispatch: %v", err) + } + if err := os.Remove(filepath.Join(inRepoDir, "quality.md")); err != nil { + t.Fatalf("remove template fixture: %v", err) + } + if _, err := dispatcher(context.Background(), "quality", item); err != nil { + t.Fatalf("second dispatch after the template file was removed: %v (want the cached template to still be used)", err) + } +} + +func TestNewJudgeDispatcherFromModel_Bad(t *testing.T) { + inRepoDir := t.TempDir() + writeJudgeTemplateFile(t, inRepoDir, "quality", qualityTemplateFixture("")) + item := pairItemFixture(t, "p", "r") + + t.Run("unknown template name", func(t *testing.T) { + dispatcher := newJudgeDispatcherFromModel(&stubJudgeModel{reply: "87"}, "fp", 32, "", inRepoDir) + if _, err := dispatcher(context.Background(), "does-not-exist", item); err == nil { + t.Fatalf("dispatch with an unknown template name = nil error") + } + }) + + t.Run("generation error surfaces", func(t *testing.T) { + dispatcher := newJudgeDispatcherFromModel(&stubJudgeModel{chatErr: core.NewError("model unreachable")}, "fp", 32, "", inRepoDir) + if _, err := dispatcher(context.Background(), "quality", item); err == nil { + t.Fatalf("dispatch with a failing model = nil error") + } + }) + + t.Run("malformed item content", func(t *testing.T) { + dispatcher := newJudgeDispatcherFromModel(&stubJudgeModel{reply: "87"}, "fp", 32, "", inRepoDir) + badItem := dataset.Item{ID: dataset.NewID(), Kind: dataset.KindPair, Content: []byte("not json")} + if _, err := dispatcher(context.Background(), "quality", badItem); err == nil { + t.Fatalf("dispatch with malformed item content = nil error") + } + }) + + t.Run("unscorable item kind", func(t *testing.T) { + dispatcher := newJudgeDispatcherFromModel(&stubJudgeModel{reply: "87"}, "fp", 32, "", inRepoDir) + traceItem := dataset.Item{ID: dataset.NewID(), Kind: dataset.KindTrace, Content: []byte(`{"anything":"non-empty"}`)} + if _, err := dispatcher(context.Background(), "quality", traceItem); err == nil { + t.Fatalf("dispatch against a trace item = nil error") + } + }) +} + +// TestNewJudgeDispatcherFromModel_Ugly proves malformed judge OUTPUT — a +// model that ignores the bare-number instruction, or answers out of range — +// is a loud per-item error, never a silently-accepted or clamped score. +func TestNewJudgeDispatcherFromModel_Ugly(t *testing.T) { + inRepoDir := t.TempDir() + writeJudgeTemplateFile(t, inRepoDir, "quality", qualityTemplateFixture("")) + item := pairItemFixture(t, "p", "r") + + for _, tc := range []struct { + name string + reply string + }{ + {"non-numeric prose reply", "I would say this response is quite good, maybe 85."}, + {"out-of-range reply", "500"}, + {"empty reply", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + dispatcher := newJudgeDispatcherFromModel(&stubJudgeModel{reply: tc.reply}, "fp", 32, "", inRepoDir) + verdict, err := dispatcher(context.Background(), "quality", item) + if err == nil { + t.Fatalf("dispatch with malformed judge output %q = nil error, verdict=%+v (want a loud failure, never a silent score)", tc.reply, verdict) + } + }) + } +} + +// ---- newJudgeDispatcher: the production wrapper's fast-fail path ---- + +// TestNewJudgeDispatcher_Bad proves an empty (or whitespace-only) model +// path is rejected before any inference.LoadModel call is attempted — the +// load-failure path itself (a real, non-empty but unreachable path) is +// exercised end-to-end through the verb in TestRunDataScore_Judge. +func TestNewJudgeDispatcher_Bad(t *testing.T) { + for _, path := range []string{"", " "} { + if _, _, err := newJudgeDispatcher(path, 32); err == nil { + t.Errorf("newJudgeDispatcher(%q, ...) = nil error, want a failure", path) + } + } +} diff --git a/cli/judgetemplate.go b/cli/judgetemplate.go new file mode 100644 index 000000000..8a11c31a1 --- /dev/null +++ b/cli/judgetemplate.go @@ -0,0 +1,231 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package main + +import ( + "math" + "strconv" + + core "dappco.re/go" + coreio "dappco.re/go/io" +) + +// judgeTemplate is one parsed judge template — front matter (name, +// description, score range) plus the prompt body a judge driver renders +// {{prompt}}/{{response}} into. See judges/README.md at the repo root for +// the on-disk format this parses. +type judgeTemplate struct { + Name string + Description string + Min, Max float64 + Body string +} + +const ( + // judgeTemplateDelim is the front-matter fence line, top and bottom. + judgeTemplateDelim = "---" + // judgeTemplateExt is the on-disk extension for both the in-repo + // defaults (judges/.md) and ~/.lem/judges/.md overrides. + judgeTemplateExt = ".md" + // judgeTemplateMaxWalkUp bounds defaultInRepoJudgesDir's upward search + // from the working directory — generous for any cwd this repo-resident + // tool is realistically run from, while still guaranteed to terminate. + judgeTemplateMaxWalkUp = 8 +) + +// parseJudgeTemplate parses one template file's raw content. name is the +// resolution name (the filename stem, e.g. "quality" for "quality.md") — +// the front-matter "name:" field must match it exactly; a mismatch is a +// loud error rather than a silently-tolerated rename/typo. +// +// tpl, err := parseJudgeTemplate("quality", content) +func parseJudgeTemplate(name, content string) (judgeTemplate, error) { + lines := core.Split(content, "\n") + i := 0 + for i < len(lines) && core.Trim(lines[i]) == "" { + i++ + } + if i >= len(lines) || core.Trim(lines[i]) != judgeTemplateDelim { + return judgeTemplate{}, core.E("main.parseJudgeTemplate", core.Sprintf("judge template %q: missing opening %q front-matter delimiter", name, judgeTemplateDelim), nil) + } + i++ + + fields := map[string]string{} + for i < len(lines) && core.Trim(lines[i]) != judgeTemplateDelim { + line := core.Trim(lines[i]) + i++ + if line == "" { + continue + } + idx := core.Index(line, ":") + if idx < 0 { + return judgeTemplate{}, core.E("main.parseJudgeTemplate", core.Sprintf("judge template %q: malformed front-matter line %q (want \"key: value\")", name, line), nil) + } + fields[core.Trim(line[:idx])] = core.Trim(line[idx+1:]) + } + if i >= len(lines) { + return judgeTemplate{}, core.E("main.parseJudgeTemplate", core.Sprintf("judge template %q: missing closing %q front-matter delimiter", name, judgeTemplateDelim), nil) + } + i++ // skip past the closing delimiter line + body := core.Trim(core.Join("\n", lines[i:]...)) + + tplName := fields["name"] + description := fields["description"] + rangeExpr := fields["range"] + if tplName == "" || description == "" || rangeExpr == "" { + return judgeTemplate{}, core.E("main.parseJudgeTemplate", core.Sprintf("judge template %q: front matter requires name, description, and range", name), nil) + } + if tplName != name { + return judgeTemplate{}, core.E("main.parseJudgeTemplate", core.Sprintf("judge template %q: front-matter name %q does not match the template's file name", name, tplName), nil) + } + if body == "" { + return judgeTemplate{}, core.E("main.parseJudgeTemplate", core.Sprintf("judge template %q: empty prompt body", name), nil) + } + + min, max, rerr := parseJudgeScoreRange(rangeExpr) + if rerr != nil { + return judgeTemplate{}, core.E("main.parseJudgeTemplate", core.Sprintf("judge template %q: range %q", name, rangeExpr), rerr) + } + return judgeTemplate{Name: tplName, Description: description, Min: min, Max: max, Body: body}, nil +} + +// parseJudgeScoreRange parses "MIN-MAX" — two non-negative numbers +// separated by a single '-', the minimal grammar judges/README.md +// documents (deliberately no support for a negative MIN — every default +// template ships a 0-based scale). +func parseJudgeScoreRange(expr string) (float64, float64, error) { + parts := core.Split(expr, "-") + if len(parts) != 2 { + return 0, 0, core.NewError("want MIN-MAX") + } + min, err := strconv.ParseFloat(core.Trim(parts[0]), 64) + if err != nil { + return 0, 0, core.E("main.parseJudgeScoreRange", core.Sprintf("invalid minimum %q", parts[0]), err) + } + max, err := strconv.ParseFloat(core.Trim(parts[1]), 64) + if err != nil { + return 0, 0, core.E("main.parseJudgeScoreRange", core.Sprintf("invalid maximum %q", parts[1]), err) + } + if !(max > min) { + return 0, 0, core.NewError("maximum must be greater than minimum") + } + return min, max, nil +} + +// renderJudgeTemplate fills tpl's body placeholders with an item's prompt +// and response text — every occurrence, no escaping, no nested templating +// (the design's "keep the format minimal"). +// +// rendered := renderJudgeTemplate(tpl, "2+2?", "4") +func renderJudgeTemplate(tpl judgeTemplate, prompt, response string) string { + rendered := core.Replace(tpl.Body, "{{prompt}}", prompt) + rendered = core.Replace(rendered, "{{response}}", response) + return rendered +} + +// parseJudgeScore parses a judge model's raw reply per the contract's parse +// rules: the ENTIRE trimmed reply must be a bare number — no surrounding +// prose, no best-effort extraction of a number buried in a sentence — and +// it must fall within tpl's declared range. Either failure is a loud, +// descriptive error; this never returns a silent 0. +// +// value, err := parseJudgeScore("87", tpl) +func parseJudgeScore(reply string, tpl judgeTemplate) (float64, error) { + trimmed := core.Trim(reply) + if trimmed == "" { + return 0, core.NewError("judge reply is empty, want a bare number") + } + value, err := strconv.ParseFloat(trimmed, 64) + if err != nil { + return 0, core.E("main.parseJudgeScore", core.Sprintf("judge reply %q is not a bare number", trimmed), nil) + } + // strconv.ParseFloat happily accepts "NaN"/"Inf" as valid float64 + // values, and a NaN comparison is always false — without this check a + // "NaN" reply would silently pass the range test below (neither < nor + // > trips) and slip through as an apparently in-range score. + if math.IsNaN(value) || math.IsInf(value, 0) { + return 0, core.E("main.parseJudgeScore", core.Sprintf("judge reply %q is not a finite number", trimmed), nil) + } + if value < tpl.Min || value > tpl.Max { + return 0, core.E("main.parseJudgeScore", core.Sprintf("judge score %v is outside the template's declared range %v-%v", value, tpl.Min, tpl.Max), nil) + } + return value, nil +} + +// resolveJudgeTemplateFrom is the pure resolution-order logic behind +// judge: scoring: a template in overrideDir wins over one of the same +// name in inRepoDir. Either dir may be "" (not resolvable in this +// environment) without error — only "found in neither" fails. Kept free of +// any real filesystem/HOME dependency so resolution order is directly +// testable against two t.TempDir() fixtures. +// +// tpl, err := resolveJudgeTemplateFrom(overrideDir, inRepoDir, "quality") +func resolveJudgeTemplateFrom(overrideDir, inRepoDir, name string) (judgeTemplate, error) { + if content, ok := readJudgeTemplateFile(overrideDir, name); ok { + return parseJudgeTemplate(name, content) + } + if content, ok := readJudgeTemplateFile(inRepoDir, name); ok { + return parseJudgeTemplate(name, content) + } + return judgeTemplate{}, core.E("main.resolveJudgeTemplateFrom", core.Sprintf("unknown judge template %q (checked ~/.lem/judges/ and the in-repo judges/ default)", name), nil) +} + +// readJudgeTemplateFile reads dir/.md, reporting ok=false (never an +// error) for an empty dir or an absent file — both are ordinary "not found +// here, try the next source" outcomes for the caller's resolution order. +func readJudgeTemplateFile(dir, name string) (string, bool) { + if core.Trim(dir) == "" { + return "", false + } + path := core.Path(dir, name+judgeTemplateExt) + if !coreio.Local.IsFile(path) { + return "", false + } + content, err := coreio.Local.Read(path) + if err != nil { + return "", false + } + return content, true +} + +// findInRepoJudgesDir is the pure, bounded upward walk from start looking +// for the worktree root this in-repo judges/ default ships beside — +// anchored on a directory carrying BOTH go.work and a judges/ subdirectory +// (the worktree root's own signature), never a bare "judges" name match +// against some unrelated ancestor. +func findInRepoJudgesDir(start string, maxLevels int) (string, bool) { + dir := start + for i := 0; i < maxLevels; i++ { + anchor := core.Path(dir, "go.work") + candidate := core.Path(dir, "judges") + if coreio.Local.IsFile(anchor) && coreio.Local.IsDir(candidate) { + return candidate, true + } + parent := core.PathDir(dir) + if parent == dir { + break + } + dir = parent + } + return "", false +} + +// defaultInRepoJudgesDir walks up from the real working directory for the +// in-repo judges/ default judges/README.md documents. lem is built to +// /bin/lem and run via the Taskfile/lem.sh harness from within a +// checkout (the same repo-relative assumption cli/embed_metallib.go's +// plain-build fallback makes), so this is a reasonable, bounded search +// rather than a hard requirement — a binary copied elsewhere with no +// checkout in reach simply finds no in-repo defaults, and resolution falls +// through to a loud "unknown template" error rather than a wrong guess. +func defaultInRepoJudgesDir() (string, bool) { + cwdResult := core.Getwd() + if !cwdResult.OK { + return "", false + } + cwd := cwdResult.String() + if core.Trim(cwd) == "" { + return "", false + } + return findInRepoJudgesDir(cwd, judgeTemplateMaxWalkUp) +} diff --git a/cli/judgetemplate_test.go b/cli/judgetemplate_test.go new file mode 100644 index 000000000..efbb0319d --- /dev/null +++ b/cli/judgetemplate_test.go @@ -0,0 +1,366 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package main + +import ( + "os" + "path/filepath" + "testing" + + core "dappco.re/go" +) + +// writeJudgeTemplateFile writes raw content to dir/.md, creating dir +// if needed, and returns dir — the common fixture step judge template tests +// build on. +func writeJudgeTemplateFile(t *testing.T, dir, name, content string) string { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir %s: %v", dir, err) + } + path := filepath.Join(dir, name+".md") + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } + return dir +} + +const wantOnlyNumberInstruction = "Reply with ONLY the number." + +func qualityTemplateFixture(body string) string { + if body == "" { + body = "Score {{response}} as a reply to {{prompt}}.\n\n" + wantOnlyNumberInstruction + } + return "---\n" + + "name: quality\n" + + "description: Overall response quality, 0-100.\n" + + "range: 0-100\n" + + "---\n" + + body + "\n" +} + +// ---- parseJudgeTemplate ---- + +func TestParseJudgeTemplate_Good(t *testing.T) { + tpl, err := parseJudgeTemplate("quality", qualityTemplateFixture("")) + if err != nil { + t.Fatalf("parseJudgeTemplate: %v", err) + } + if tpl.Name != "quality" { + t.Errorf("Name = %q, want quality", tpl.Name) + } + if tpl.Description != "Overall response quality, 0-100." { + t.Errorf("Description = %q", tpl.Description) + } + if tpl.Min != 0 || tpl.Max != 100 { + t.Errorf("range = %v-%v, want 0-100", tpl.Min, tpl.Max) + } + if !core.Contains(tpl.Body, "{{response}}") || !core.Contains(tpl.Body, "{{prompt}}") { + t.Errorf("Body lost a placeholder: %q", tpl.Body) + } +} + +func TestParseJudgeTemplate_Bad(t *testing.T) { + for _, tc := range []struct { + name string + content string + }{ + {"missing opening delimiter", "name: quality\ndescription: d\nrange: 0-100\n---\nbody\n"}, + {"missing closing delimiter", "---\nname: quality\ndescription: d\nrange: 0-100\nbody with no fence\n"}, + {"malformed front-matter line", "---\nname quality\ndescription: d\nrange: 0-100\n---\nbody\n"}, + {"missing name field", "---\ndescription: d\nrange: 0-100\n---\nbody\n"}, + {"missing description field", "---\nname: quality\nrange: 0-100\n---\nbody\n"}, + {"missing range field", "---\nname: quality\ndescription: d\n---\nbody\n"}, + {"name does not match resolution name", "---\nname: other\ndescription: d\nrange: 0-100\n---\nbody\n"}, + {"empty body", "---\nname: quality\ndescription: d\nrange: 0-100\n---\n\n"}, + {"bad range grammar", "---\nname: quality\ndescription: d\nrange: not-a-range-either\n---\nbody\n"}, + {"range max not greater than min", "---\nname: quality\ndescription: d\nrange: 100-0\n---\nbody\n"}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := parseJudgeTemplate("quality", tc.content); err == nil { + t.Fatalf("parseJudgeTemplate(%q) = nil error, want a failure", tc.name) + } + }) + } +} + +// TestParseJudgeTemplate_Ugly proves a literal "---" line INSIDE the body +// (e.g. a markdown horizontal rule in the judge's prompt) is not mistaken +// for a second front-matter fence — parsing stops at the first closing +// delimiter and treats everything after it as body text verbatim. +func TestParseJudgeTemplate_Ugly(t *testing.T) { + content := "---\n" + + "name: quality\n" + + "description: d\n" + + "range: 0-100\n" + + "---\n" + + "Above the line.\n\n---\n\nBelow the line: {{prompt}} / {{response}}\n" + tpl, err := parseJudgeTemplate("quality", content) + if err != nil { + t.Fatalf("parseJudgeTemplate: %v", err) + } + if !core.Contains(tpl.Body, "Above the line.") || !core.Contains(tpl.Body, "Below the line") { + t.Fatalf("body lost text around the embedded '---': %q", tpl.Body) + } +} + +// ---- parseJudgeScoreRange ---- + +func TestParseJudgeScoreRange_Good(t *testing.T) { + for _, tc := range []struct { + expr string + min, max float64 + }{ + {"0-100", 0, 100}, + {"0-1", 0, 1}, + {"10-20", 10, 20}, + {" 0 - 100 ", 0, 100}, + } { + min, max, err := parseJudgeScoreRange(tc.expr) + if err != nil { + t.Errorf("parseJudgeScoreRange(%q): %v", tc.expr, err) + continue + } + if min != tc.min || max != tc.max { + t.Errorf("parseJudgeScoreRange(%q) = %v-%v, want %v-%v", tc.expr, min, max, tc.min, tc.max) + } + } +} + +func TestParseJudgeScoreRange_Bad(t *testing.T) { + for _, expr := range []string{"", "100", "0-100-200", "abc-100", "0-abc", "100-0", "50-50"} { + if _, _, err := parseJudgeScoreRange(expr); err == nil { + t.Errorf("parseJudgeScoreRange(%q) = nil error, want a failure", expr) + } + } +} + +// ---- renderJudgeTemplate ---- + +func TestRenderJudgeTemplate_Good(t *testing.T) { + tpl := judgeTemplate{Body: "P: {{prompt}}\nR: {{response}}\nrepeat prompt: {{prompt}}"} + got := renderJudgeTemplate(tpl, "what is 2+2?", "4") + want := "P: what is 2+2?\nR: 4\nrepeat prompt: what is 2+2?" + if got != want { + t.Fatalf("renderJudgeTemplate = %q, want %q", got, want) + } + if core.Contains(got, "{{") { + t.Fatalf("renderJudgeTemplate left a placeholder unfilled: %q", got) + } +} + +// ---- parseJudgeScore ---- + +func TestParseJudgeScore_Good(t *testing.T) { + tpl := judgeTemplate{Min: 0, Max: 100} + for _, tc := range []struct { + reply string + want float64 + }{ + {"87", 87}, + {" 42 \n", 42}, + {"0", 0}, + {"100", 100}, + {"73.5", 73.5}, + } { + got, err := parseJudgeScore(tc.reply, tpl) + if err != nil { + t.Errorf("parseJudgeScore(%q): %v", tc.reply, err) + continue + } + if got != tc.want { + t.Errorf("parseJudgeScore(%q) = %v, want %v", tc.reply, got, tc.want) + } + } +} + +// TestParseJudgeScore_Bad covers an in-range-shaped number that falls +// OUTSIDE the template's declared range — a well-formed number is still a +// loud failure, never silently clamped. +func TestParseJudgeScore_Bad(t *testing.T) { + tpl := judgeTemplate{Min: 0, Max: 100} + for _, reply := range []string{"150", "-1", "100.01", "1000000"} { + if _, err := parseJudgeScore(reply, tpl); err == nil { + t.Errorf("parseJudgeScore(%q) = nil error, want an out-of-range failure", reply) + } + } +} + +// TestParseJudgeScore_Ugly covers non-numeric garbage: prose, a number +// buried in a sentence, or an empty reply — the parser never best-effort +// extracts a number, it demands a bare one. +func TestParseJudgeScore_Ugly(t *testing.T) { + tpl := judgeTemplate{Min: 0, Max: 100} + for _, reply := range []string{"", " ", "I'd say around 85", "Score: 87", "87/100", "eighty-seven", "NaN"} { + if _, err := parseJudgeScore(reply, tpl); err == nil { + t.Errorf("parseJudgeScore(%q) = nil error, want a non-numeric failure", reply) + } + } +} + +// ---- readJudgeTemplateFile ---- + +func TestReadJudgeTemplateFile_Good(t *testing.T) { + dir := t.TempDir() + writeJudgeTemplateFile(t, dir, "quality", "content") + content, ok := readJudgeTemplateFile(dir, "quality") + if !ok || content != "content" { + t.Fatalf("readJudgeTemplateFile = (%q, %v), want (\"content\", true)", content, ok) + } +} + +func TestReadJudgeTemplateFile_Bad(t *testing.T) { + dir := t.TempDir() + if _, ok := readJudgeTemplateFile(dir, "missing"); ok { + t.Fatalf("readJudgeTemplateFile found a file that was never written") + } + if _, ok := readJudgeTemplateFile("", "quality"); ok { + t.Fatalf("readJudgeTemplateFile(\"\", ...) = found, want not-found for an empty dir") + } +} + +// ---- resolveJudgeTemplateFrom: the resolution-order contract ---- + +// TestResolveJudgeTemplateFrom_Good proves the design's fixed order: an +// override wins over an in-repo default of the same name, and the in-repo +// default still resolves when no override is present. +func TestResolveJudgeTemplateFrom_Good(t *testing.T) { + overrideDir := t.TempDir() + inRepoDir := t.TempDir() + + t.Run("override wins when both are present", func(t *testing.T) { + writeJudgeTemplateFile(t, overrideDir, "quality", "---\nname: quality\ndescription: override version\nrange: 0-100\n---\noverride body {{prompt}} {{response}}\n") + writeJudgeTemplateFile(t, inRepoDir, "quality", "---\nname: quality\ndescription: in-repo version\nrange: 0-100\n---\nin-repo body {{prompt}} {{response}}\n") + + tpl, err := resolveJudgeTemplateFrom(overrideDir, inRepoDir, "quality") + if err != nil { + t.Fatalf("resolveJudgeTemplateFrom: %v", err) + } + if tpl.Description != "override version" { + t.Fatalf("Description = %q, want the override to win", tpl.Description) + } + }) + + t.Run("in-repo default resolves when no override exists", func(t *testing.T) { + writeJudgeTemplateFile(t, inRepoDir, "factuality", "---\nname: factuality\ndescription: in-repo only\nrange: 0-100\n---\nbody {{prompt}} {{response}}\n") + + tpl, err := resolveJudgeTemplateFrom(overrideDir, inRepoDir, "factuality") + if err != nil { + t.Fatalf("resolveJudgeTemplateFrom: %v", err) + } + if tpl.Description != "in-repo only" { + t.Fatalf("Description = %q, want the in-repo default", tpl.Description) + } + }) + + t.Run("an empty override dir falls through cleanly", func(t *testing.T) { + writeJudgeTemplateFile(t, inRepoDir, "refusal-correctness", "---\nname: refusal-correctness\ndescription: d\nrange: 0-100\n---\nbody {{prompt}} {{response}}\n") + + tpl, err := resolveJudgeTemplateFrom("", inRepoDir, "refusal-correctness") + if err != nil { + t.Fatalf("resolveJudgeTemplateFrom with no override dir: %v", err) + } + if tpl.Name != "refusal-correctness" { + t.Fatalf("Name = %q, want refusal-correctness", tpl.Name) + } + }) +} + +func TestResolveJudgeTemplateFrom_Bad(t *testing.T) { + overrideDir := t.TempDir() + inRepoDir := t.TempDir() + if _, err := resolveJudgeTemplateFrom(overrideDir, inRepoDir, "does-not-exist"); err == nil { + t.Fatalf("resolveJudgeTemplateFrom found in neither dir = nil error, want an unknown-template failure") + } + if _, err := resolveJudgeTemplateFrom("", "", "does-not-exist"); err == nil { + t.Fatalf("resolveJudgeTemplateFrom with two empty dirs = nil error, want an unknown-template failure") + } +} + +// ---- findInRepoJudgesDir ---- + +// TestFindInRepoJudgesDir_Good proves the bounded upward walk finds a +// worktree root (anchored on BOTH go.work and a judges/ subdirectory) +// several levels above a deeply nested starting directory. +func TestFindInRepoJudgesDir_Good(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "go.work"), []byte("go 1.26\n"), 0o644); err != nil { + t.Fatalf("write go.work fixture: %v", err) + } + judgesDir := filepath.Join(root, "judges") + if err := os.MkdirAll(judgesDir, 0o755); err != nil { + t.Fatalf("mkdir judges fixture: %v", err) + } + start := filepath.Join(root, "a", "b", "c") + if err := os.MkdirAll(start, 0o755); err != nil { + t.Fatalf("mkdir nested start fixture: %v", err) + } + + dir, ok := findInRepoJudgesDir(start, judgeTemplateMaxWalkUp) + if !ok { + t.Fatalf("findInRepoJudgesDir(%q) not found, want %q", start, judgesDir) + } + if dir != judgesDir { + t.Fatalf("findInRepoJudgesDir(%q) = %q, want %q", start, dir, judgesDir) + } +} + +// TestFindInRepoJudgesDir_Bad covers two negative shapes: a bound too small +// to reach the anchor, and a judges/ directory present with no go.work +// beside it (a bare name match is never enough). +func TestFindInRepoJudgesDir_Bad(t *testing.T) { + t.Run("bound too small to reach the anchor", func(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "go.work"), []byte("go 1.26\n"), 0o644); err != nil { + t.Fatalf("write go.work fixture: %v", err) + } + if err := os.MkdirAll(filepath.Join(root, "judges"), 0o755); err != nil { + t.Fatalf("mkdir judges fixture: %v", err) + } + start := filepath.Join(root, "a", "b", "c") + if err := os.MkdirAll(start, 0o755); err != nil { + t.Fatalf("mkdir nested start fixture: %v", err) + } + + if _, ok := findInRepoJudgesDir(start, 3); ok { + t.Fatalf("findInRepoJudgesDir found the anchor within a bound too small to reach it") + } + }) + + t.Run("judges dir without a go.work anchor never matches", func(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "judges"), 0o755); err != nil { + t.Fatalf("mkdir judges fixture: %v", err) + } + if _, ok := findInRepoJudgesDir(root, judgeTemplateMaxWalkUp); ok { + t.Fatalf("findInRepoJudgesDir matched a bare judges/ dir with no go.work beside it") + } + }) +} + +// TestDefaultInRepoJudgesDir_Good proves the real in-repo judges/ directory +// this worktree ships is actually discoverable from cli/'s own `go test` +// working directory (one level below the repo root), and that every +// shipped default template — quality.md, factuality.md, +// refusal-correctness.md — parses cleanly with both placeholders present. +// A synthetic-fixture-only test suite could pass while the real shipped +// files were subtly malformed; this closes that gap. +func TestDefaultInRepoJudgesDir_Good(t *testing.T) { + dir, ok := defaultInRepoJudgesDir() + if !ok { + t.Fatalf("defaultInRepoJudgesDir did not find the repo-root judges/ directory from %v", func() string { wd, _ := os.Getwd(); return wd }()) + } + for _, name := range []string{"quality", "factuality", "refusal-correctness"} { + content, ok := readJudgeTemplateFile(dir, name) + if !ok { + t.Fatalf("judges/%s.md not found under %s", name, dir) + } + tpl, err := parseJudgeTemplate(name, content) + if err != nil { + t.Fatalf("parse judges/%s.md: %v", name, err) + } + if !core.Contains(tpl.Body, "{{prompt}}") || !core.Contains(tpl.Body, "{{response}}") { + t.Errorf("judges/%s.md body is missing a placeholder", name) + } + } +} diff --git a/cli/lthn-model-pack/main.go b/cli/lthn-model-pack/main.go index 683224197..581e5f28c 100644 --- a/cli/lthn-model-pack/main.go +++ b/cli/lthn-model-pack/main.go @@ -124,7 +124,7 @@ func runList(args []string) core.Result { if !jr.OK { return jr } - core.Print(os.Stdout, "%s", string(jr.Value.([]byte))) + core.Print(os.Stdout, "%s", string(jr.Bytes())) return core.Ok(nil) } @@ -147,6 +147,6 @@ func runInspect(args []string) core.Result { if !jr.OK { return jr } - core.Print(os.Stdout, "%s", string(jr.Value.([]byte))) + core.Print(os.Stdout, "%s", string(jr.Bytes())) return core.Ok(nil) } diff --git a/cli/main.go b/cli/main.go index adf99a711..5b33802ac 100644 --- a/cli/main.go +++ b/cli/main.go @@ -68,8 +68,14 @@ func runCommand(ctx context.Context, args []string, stdout, stderr io.Writer) in switch args[0] { case "serve": return runServeCommand(ctx, args[1:], stdout, stderr) + case "bench": + return runBenchCommand(ctx, args[1:], stdout, stderr) case "generate": return runGenerateCommand(ctx, args[1:], stdout, stderr) + case "transcribe": + return runTranscribeCommand(ctx, args[1:], stdout, stderr) + case "ocr": + return runOCRCommand(ctx, args[1:], stdout, stderr) case "ssd": return runSSDCommand(ctx, args[1:], stdout, stderr) case "sft": @@ -80,6 +86,8 @@ func runCommand(ctx context.Context, args []string, stdout, stderr io.Writer) in return runPackCommand(ctx, args[1:], stdout, stderr) case "quant": return runQuantCommand(ctx, args[1:], stdout, stderr) + case "data": + return runDataCommand(ctx, args[1:], stdout, stderr) case "spec": return runSpecCommand(ctx, args[1:], stdout, stderr) case "ebook": @@ -106,6 +114,8 @@ func printUsage(w io.Writer) { core.WriteString(w, "Run inference\n") core.WriteString(w, " serve host OpenAI/Anthropic/Ollama HTTP API for a loaded model\n") core.WriteString(w, " generate one-shot generate + decode tok/s (no serve; like-for-like bench)\n") + core.WriteString(w, " transcribe transcribe a WAV clip through a loaded Whisper checkpoint (ASR)\n") + core.WriteString(w, " ocr run OCR on an image through a loaded DeepSeek-OCR checkpoint\n") core.WriteString(w, " tui chat with a model in the terminal (picker, streaming, thinking channel)\n") core.WriteString(w, "\n") core.WriteString(w, "Train\n") @@ -121,6 +131,9 @@ func printUsage(w io.Writer) { core.WriteString(w, "Convert\n") core.WriteString(w, " quant quantise a dense model dir (MLX, GPTQ, FP8, NF4, or GGUF)\n") core.WriteString(w, "\n") + core.WriteString(w, "Data\n") + core.WriteString(w, " data the training-data loop: create/list/stats/import/score/export/archive/review\n") + core.WriteString(w, "\n") core.WriteString(w, "API\n") core.WriteString(w, " spec export the OpenAPI document for lem's HTTP surface (feeds SDK generation)\n") core.WriteString(w, "\n") diff --git a/cli/main_test.go b/cli/main_test.go index 1f28c8329..67cfa2da6 100644 --- a/cli/main_test.go +++ b/cli/main_test.go @@ -42,6 +42,7 @@ func TestRunCommand_Dispatch(t *testing.T) { {"tune route", []string{"tune"}, 2, false}, {"pack route", []string{"pack"}, 2, false}, {"quant route", []string{"quant"}, 2, false}, + {"data route", []string{"data"}, 2, false}, {"ebook route", []string{"ebook"}, 2, false}, {"spec route", []string{"spec", "--output", specOut}, 0, false}, {"version route", []string{"version"}, 0, false}, @@ -79,6 +80,15 @@ func TestHelpPresentsLongFlagsOnly(t *testing.T) { {"pack", "list", "--help"}, {"pack", "extract", "--help"}, {"quant", "--help"}, + {"data", "--help"}, + {"data", "create", "--help"}, + {"data", "list", "--help"}, + {"data", "stats", "--help"}, + {"data", "import", "--help"}, + {"data", "score", "--help"}, + {"data", "export", "--help"}, + {"data", "archive", "--help"}, + {"data", "review", "--help"}, {"spec", "--help"}, {"ebook", "--help"}, {"tui", "--help"}, @@ -105,7 +115,7 @@ func TestPrintUsage(t *testing.T) { var buf bytes.Buffer printUsage(&buf) out := buf.String() - for _, verb := range []string{"serve", "generate", "ssd", "sft", "tune", "pack", "ebook", "quant", "spec"} { + for _, verb := range []string{"serve", "generate", "ssd", "sft", "tune", "pack", "ebook", "quant", "data", "spec"} { if !core.Contains(out, verb) { t.Errorf("usage missing verb %q", verb) } diff --git a/cli/ocr.go b/cli/ocr.go new file mode 100644 index 000000000..0be4f0f67 --- /dev/null +++ b/cli/ocr.go @@ -0,0 +1,133 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package main + +import ( + "context" + "flag" + "io" + + core "dappco.re/go" + "dappco.re/go/inference/model" + "dappco.re/go/inference/model/arch/deepseek-ai/deepseekvl2" + "dappco.re/go/inference/model/arch/rednote-hilab/dotsocr" + "dappco.re/go/inference/model/arch/zai-org/glmocr" +) + +// ocr.go is thin flag-parsing over dappco.re/go/inference/model/arch/deepseek-ai/deepseekvl2's +// Load/Model.OCR — the OCR business logic lives there, not here (mirroring generate.go's/ +// transcribe.go's doc comments: "the business logic lives in the arch package, not here"). +// DeepSeek-OCR's dual-tower vision encoder + MoE decoder never enters model.Assemble (see +// deepseekvl2.Config.Arch's doc comment), so this verb calls deepseekvl2.Load directly rather +// than going through the shared generate/serve model-loading path — the same "own loader" shape +// transcribe.go uses for Whisper. +// +// lem ocr --model ~/models/deepseek-ocr page.png +// +// ctx carries no cancellation point yet — Model.OCR is a single host-CPU forward pass with no +// natural cut point (v1 scope; a later slice could thread it through the decode loop, mirroring +// transcribe.go's identical note). +func runOCRCommand(_ context.Context, args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet(cliCommandName("ocr"), flag.ContinueOnError) + fs.SetOutput(stderr) + modelDir := fs.String("model", "", "DeepSeek-OCR checkpoint directory (config.json, tokenizer.json, *.safetensors)") + prompt := fs.String("prompt", "", "override the OCR prompt (must contain exactly one placeholder); empty uses the checkpoint's recommended default") + maxNewTokens := fs.Int("max-tokens", 0, "cap the generated content length; <=0 uses the package default") + jsonOut := fs.Bool("json", false, "print {\"text\":...} instead of plain OCR text") + fs.Usage = func() { + name := cliName() + core.WriteString(stderr, core.Sprintf("Usage: %s ocr --model [flags] \n", name)) + core.WriteString(stderr, "\n") + core.WriteString(stderr, "Run OCR on one image (PNG/JPEG) through a loaded DeepSeek-OCR checkpoint:\n") + core.WriteString(stderr, "host-f32 greedy decode, the fixed 1024x1024 \"Base\" resolution mode (v1 scope —\n") + core.WriteString(stderr, "any other image size is a named refusal, not a silent resize).\n") + core.WriteString(stderr, "\n") + core.WriteString(stderr, "Flags:\n") + printFlagBlock(stderr, fs) + core.WriteString(stderr, "\n") + core.WriteString(stderr, "Examples:\n") + core.WriteString(stderr, core.Sprintf(" %s ocr --model ~/models/deepseek-ocr page.png\n", name)) + core.WriteString(stderr, " # default \"Free OCR\" prompt, transcript to stdout\n") + core.WriteString(stderr, core.Sprintf(" %s ocr --model ~/models/deepseek-ocr --json page.png\n", name)) + core.WriteString(stderr, " # {\"text\":...} to stdout\n") + } + if err := fs.Parse(args); err != nil { + if core.Is(err, flag.ErrHelp) { + return 0 + } + return 2 + } + if *modelDir == "" { + core.Print(stderr, "%s ocr: --model is required", cliName()) + fs.Usage() + return 2 + } + if fs.NArg() != 1 { + core.Print(stderr, "%s ocr: expected exactly one image path", cliName()) + fs.Usage() + return 2 + } + + imagePath := fs.Arg(0) + read := core.ReadFile(imagePath) + if !read.OK { + core.Print(stderr, "%s ocr: read %s: %s", cliName(), imagePath, read.Error()) + return 1 + } + imageBytes, ok := read.Value.([]byte) + if !ok { + core.Print(stderr, "%s ocr: read %s returned non-byte data", cliName(), imagePath) + return 1 + } + + // Dispatch on the checkpoint's declared model_type — one verb, every OCR arch. + mt, _, perr := model.ProbeDirArch(*modelDir) + if perr != nil { + core.Print(stderr, "%s ocr: probe %s: %v", cliName(), *modelDir, perr) + return 1 + } + var text string + var oerr error + switch mt { + case "deepseek_vl_v2": + m, err := deepseekvl2.Load(*modelDir) + if err != nil { + core.Print(stderr, "%s ocr: %v", cliName(), err) + return 1 + } + result, err := m.OCR(imageBytes, deepseekvl2.Options{Prompt: *prompt, MaxNewTokens: *maxNewTokens}) + if err == nil { + text = result.Text + } + oerr = err + case "dots_ocr", "dots_ocr_1_5": + m, err := dotsocr.Load(*modelDir) + if err != nil { + core.Print(stderr, "%s ocr: %v", cliName(), err) + return 1 + } + text, oerr = m.OCR(imageBytes, *prompt) + case "glm_ocr", "glm_ocr_text": + m, err := glmocr.Load(*modelDir) + if err != nil { + core.Print(stderr, "%s ocr: %v", cliName(), err) + return 1 + } + text, oerr = m.OCR(imageBytes, *prompt) + default: + core.Print(stderr, "%s ocr: model_type %q is not an OCR arch this verb serves (deepseek_vl_v2, dots_ocr, dots_ocr_1_5, glm_ocr, glm_ocr_text)", cliName(), mt) + return 1 + } + if oerr != nil { + core.Print(stderr, "%s ocr: %v", cliName(), oerr) + return 1 + } + + if *jsonOut { + printJSON(stdout, map[string]string{"text": text}) + return 0 + } + core.WriteString(stdout, text) + core.WriteString(stdout, "\n") + return 0 +} diff --git a/cli/pack.go b/cli/pack.go index b0caaf43a..ad24b81c0 100644 --- a/cli/pack.go +++ b/cli/pack.go @@ -142,7 +142,7 @@ func runPackInspect(args []string, stdout, stderr io.Writer) int { core.Print(stderr, "%s pack inspect: %s", cliName(), data.Error()) return 1 } - core.WriteString(stdout, string(data.Value.([]byte))) + core.WriteString(stdout, string(data.Bytes())) core.WriteString(stdout, "\n") return 0 } @@ -185,7 +185,7 @@ func runPackList(args []string, stdout, stderr io.Writer) int { core.Print(stderr, "%s pack list: %s", cliName(), data.Error()) return 1 } - core.WriteString(stdout, string(data.Value.([]byte))) + core.WriteString(stdout, string(data.Bytes())) core.WriteString(stdout, "\n") return 0 } diff --git a/cli/quant_test.go b/cli/quant_test.go index 78176e0a0..5195fc394 100644 --- a/cli/quant_test.go +++ b/cli/quant_test.go @@ -13,6 +13,46 @@ import ( "dappco.re/go/inference/model/safetensors" ) +// gemma3ToyConfigJSON is a gemma-3 config.json carrying every hyperparameter +// gemma3's dedicated GGUF export lane requires (num_hidden_layers, hidden_size, +// num_attention_heads, intermediate_size, head_dim — model/arch/google/gemma3/ +// gguf.gemma3Metadata refuses a config missing any of these) plus the +// context/kv-head/eps/rope fields it writes unconditionally. The toy tensors +// below don't need to agree dimensionally with these values — gemma3Metadata +// validates presence, not cross-consistency with the tensor shapes. +const gemma3ToyConfigJSON = `{"model_type":"gemma3","hidden_size":64,"num_hidden_layers":1,` + + `"num_attention_heads":4,"num_key_value_heads":1,"intermediate_size":128,"head_dim":16,` + + `"max_position_embeddings":8192,"rms_norm_eps":1e-06,"rope_theta":1000000}` + +// writeGGUFTokenizer writes a minimal valid SentencePiece tokenizer.model (a +// two-piece ModelProto) into dir. gemma3's dedicated GGUF export lane reads +// real per-token scores from tokenizer.model rather than the score-less +// tokenizer.json (model/arch/google/gemma3/gguf.gemma3Tokenizer) and fails +// loudly when the file is absent, so the GGUF lane toy fixtures need one even +// though no other quant lane reads it. Hand-encoded protobuf wire bytes — +// ModelProto field 1 (length-delimited) repeated per piece, each piece's own +// field 1 (length-delimited) its text — mirrors the technique +// model/arch/google/gemma3/gguf/tokenizer_test.go uses for the same reader. A +// piece with no score/type field defaults to score 0, type NORMAL, which is +// enough for gemma3Tokenizer's header build (it only requires len(pieces) > 0). +func writeGGUFTokenizer(t *testing.T, dir string) { + t.Helper() + // piece builds one SentencePiece submessage body: field 1 (length-delimited + // string) is the piece text; score/type are left unset (default 0/NORMAL). + piece := func(text string) []byte { + return append([]byte{0x0A, byte(len(text))}, text...) + } + // Wrap each piece body as a ModelProto field-1 (length-delimited) entry. + var model []byte + for _, body := range [][]byte{piece(""), piece("the")} { + model = append(model, 0x0A, byte(len(body))) + model = append(model, body...) + } + if r := core.WriteFile(filepath.Join(dir, "tokenizer.model"), model, 0o644); !r.OK { + t.Fatalf("write tokenizer.model: %v", r.Err()) + } +} + // writeToyModel writes a minimal dense model directory (one eligible 2-D weight + a 1-D // norm, F32 storage) so the quant verb has something real to convert without a GPU or a // downloaded checkpoint. @@ -37,9 +77,10 @@ func writeToyModel(t *testing.T) string { if r := core.WriteFile(filepath.Join(dir, "model.safetensors"), blob, 0o644); !r.OK { t.Fatalf("write shard: %v", r.Err()) } - if r := core.WriteFile(filepath.Join(dir, "config.json"), []byte(`{"model_type":"gemma3","hidden_size":64}`), 0o644); !r.OK { + if r := core.WriteFile(filepath.Join(dir, "config.json"), []byte(gemma3ToyConfigJSON), 0o644); !r.OK { t.Fatalf("write config: %v", r.Err()) } + writeGGUFTokenizer(t, dir) return dir } @@ -111,9 +152,10 @@ func writeGGUFToy(t *testing.T) string { if r := core.WriteFile(filepath.Join(dir, "model.safetensors"), blob, 0o644); !r.OK { t.Fatalf("write shard: %v", r.Err()) } - if r := core.WriteFile(filepath.Join(dir, "config.json"), []byte(`{"model_type":"gemma3","hidden_size":64}`), 0o644); !r.OK { + if r := core.WriteFile(filepath.Join(dir, "config.json"), []byte(gemma3ToyConfigJSON), 0o644); !r.OK { t.Fatalf("write config: %v", r.Err()) } + writeGGUFTokenizer(t, dir) return dir } diff --git a/cli/serve.go b/cli/serve.go index 903c13173..690a30fbc 100644 --- a/cli/serve.go +++ b/cli/serve.go @@ -30,7 +30,7 @@ func runServeCommand(ctx context.Context, args []string, stdout, stderr io.Write noAutoProfile := fs.Bool("no-auto-profile", false, "ignore tuned profiles from `lem tune` (run the flag/engine-default draft block)") profileDir := fs.String("profile-dir", "", "tuned-profile directory (default ~/Lethean/lem/tuning)") contextLen := fs.Int("context", 0, "override context length; 0 uses the model's default") - kvCacheMode := fs.String("kv-cache", "", "KV cache mode override (engine-reported; the no-cgo metal engine runs its built-in cache only — other values are noted and ignored)") + kvCacheMode := fs.String("kv-cache", "", "live KV cache mode: native (default) or turboquant[:4|:3.5|:3|:2] — TurboQuant codes on global attention layers; unknown/unservable modes fail the load loudly") readTimeout := fs.Duration("read-timeout", 30*time.Second, "HTTP read header timeout") writeTimeout := fs.Duration("write-timeout", 5*time.Minute, "HTTP write timeout (covers full streaming response)") shutdownTimeout := fs.Duration("shutdown-timeout", 10*time.Second, "graceful shutdown deadline after SIGINT/SIGTERM") @@ -49,6 +49,7 @@ func runServeCommand(ctx context.Context, args []string, stdout, stderr io.Write embedModel := fs.String("embed-model", "", "embeddings/rerank model: a bert/BGE-class host encoder snapshot directory (config.json + vocab.txt + model.safetensors); served at /v1/embeddings and /v1/rerank under --embed-model-id alongside (or, with --model \"\", instead of) the chat model; empty = those routes serve only what the chat model itself implements (a clean 4xx today). A load failure is fatal at boot") embedModelID := fs.String("embed-model-id", "", "request name for --embed-model's `model` field; empty derives the pack's basename") corsOrigins := fs.String("cors", "", "browser origins allowed via CORS: comma-separated exact origins (e.g. http://localhost:4200) or '*' for any; empty (the default) sends no CORS headers — a browser app on another origin then cannot call the serve") + captureSlug := fs.String("capture", "", "tee each completed (prompt, response) turn into this `lem data` dataset with the serving model's fingerprint; empty (the default) captures nothing — the approved privacy default is opt-in only") fs.Usage = func() { name := cliName() core.WriteString(stderr, core.Sprintf("Usage: %s serve [--model ] [flags]\n", name)) @@ -65,6 +66,7 @@ func runServeCommand(ctx context.Context, args []string, stdout, stderr io.Write core.WriteString(stderr, " POST /api/chat Ollama chat\n") core.WriteString(stderr, " POST /v1/embeddings embedding vectors (chat model or --embed-model)\n") core.WriteString(stderr, " POST /v1/rerank document reranking (chat model or --embed-model)\n") + core.WriteString(stderr, " POST /v1/audio/transcriptions ASR (multipart or JSON data-URL) — --model a Whisper checkpoint serves this INSTEAD of chat/embeddings\n") core.WriteString(stderr, " GET /v1/models list loaded models\n") core.WriteString(stderr, " GET /v1/health process health probe\n") } @@ -137,6 +139,22 @@ func runServeCommand(ctx context.Context, args []string, stdout, stderr io.Write continuityEnabler = continuity.EnableSharing } + // Dataset capture — opt-in only (buildCaptureLoader never opens the + // dataset store when --capture is empty, "OFF without the flag"). A + // --capture pointing at a dataset that doesn't exist yet fails the boot + // closed, before any listener binds, matching the admin-token/policy/ + // embed-model precedent above: a deployer who asked for capture gets it + // or an honest refusal, never a silent no-op. + captureLoader, captureStore, captureErr := buildCaptureLoader(*captureSlug, stderr) + if captureErr != nil { + core.Print(stderr, "%s serve: --capture: %v", cliName(), captureErr) + return 1 + } + if captureStore != nil { + defer captureStore.Close() + core.Print(stderr, "serve: capturing completed turns into dataset %q", core.Trim(*captureSlug)) + } + err = serving.RunServe(ctx, serving.ServeConfig{ Addr: *addr, ModelPath: *modelPath, @@ -148,6 +166,7 @@ func runServeCommand(ctx context.Context, args []string, stdout, stderr io.Write DraftPath: *draftPath, DraftDetect: *draftDetect, DraftBlock: *draftBlock, + Loader: captureLoader, SpeculativeLoader: speculativeLoader, EnableContinuity: continuityEnabler, NoAutoProfile: *noAutoProfile, diff --git a/cli/serve_test.go b/cli/serve_test.go index 9b1aca345..00ec536cb 100644 --- a/cli/serve_test.go +++ b/cli/serve_test.go @@ -98,6 +98,24 @@ func TestRunServeCommand_AdminTokenFailClosed(t *testing.T) { } } +// TestRunServeCommand_CaptureDatasetNotFound proves --capture fails the boot +// closed — before any listener binds — when the named dataset does not +// exist, with an actionable message pointing at `lem data create`. This +// never reaches serving.RunServe (mirrors +// TestRunServeCommand_AdminTokenFailClosed's boundary: no test in this file +// boots a real server), so it is safe to run without a model. +func TestRunServeCommand_CaptureDatasetNotFound(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + var stdout, stderr bytes.Buffer + code := runServeCommand(context.Background(), []string{"--capture", "no-such-dataset"}, &stdout, &stderr) + if code != 1 { + t.Fatalf("exit %d, want 1 (fail-closed); stderr=%s", code, stderr.String()) + } + if !core.Contains(stderr.String(), "lem data create") { + t.Errorf("stderr = %q, want a pointer at `lem data create`", stderr.String()) + } +} + // readToken reads the trimmed admin token from path, failing the test on error. func readToken(t *testing.T, path string) string { t.Helper() diff --git a/cli/ssd.go b/cli/ssd.go index dd1b1cec9..1e0bab413 100644 --- a/cli/ssd.go +++ b/cli/ssd.go @@ -31,6 +31,7 @@ func runSSDCommand(ctx context.Context, args []string, stdout, stderr io.Writer) scoreSamples := fs.Bool("score-samples", false, "score every self-sample at birth with the LEK scorer (writes birth-scores alongside the captured trace)") checkpointDir := fs.String("checkpoint-dir", "", "output dir for the scored trace — ssd-captures.jsonl") contextLen := fs.Int("context", 0, "model context override; 0 uses the model default") + datasetSlug := fs.String("dataset", "", "land the sampled trace as `trace` items in this `lem data` dataset, fingerprinted to the base model; requires --checkpoint-dir (the capture sidecar this tap reads); empty (the default) captures nothing") fs.Usage = func() { name := cliName() core.WriteString(stderr, core.Sprintf("Usage: %s ssd --model --data [flags]\n", name)) @@ -54,6 +55,10 @@ func runSSDCommand(ctx context.Context, args []string, stdout, stderr io.Writer) fs.Usage() return 2 } + if core.Trim(*datasetSlug) != "" && core.Trim(*checkpointDir) == "" { + core.Print(stderr, "%s ssd: --dataset requires --checkpoint-dir (the capture sidecar `lem data` ingests)", cliName()) + return 2 + } err := train.RunSSDCommand(ctx, train.SSDCommandConfig{ ModelPath: *modelPath, @@ -77,5 +82,17 @@ func runSSDCommand(ctx context.Context, args []string, stdout, stderr io.Writer) core.Print(stderr, "%s ssd: %v", cliName(), err) return 1 } + + // Dataset capture — opt-in only (empty --dataset never touches the + // dataset store). A failure here is logged, never fatal: the sampling + // run's checkpoint is already safe on disk by this point, and an hours- + // long GPU run must never turn into a failure exit purely because the + // LAST step — teeing its trace into `lem data` — hit a hiccup. The + // operator can always `lem data import --jsonl` the sidecar later. + if core.Trim(*datasetSlug) != "" { + if tapErr := ingestSSDTrace(*checkpointDir, *datasetSlug, *modelPath, stderr); tapErr != nil { + core.Print(stderr, "%s ssd: --dataset: %v (the sampling run itself already completed; its checkpoint is on disk regardless)", cliName(), tapErr) + } + } return 0 } diff --git a/cli/ssd_test.go b/cli/ssd_test.go index 53bf9c808..19d3636ad 100644 --- a/cli/ssd_test.go +++ b/cli/ssd_test.go @@ -10,9 +10,13 @@ import ( // TestRunSSDCommand_Rejects covers the required-flag guard that stops a // self-distillation run before train.RunSSDCommand loads the frozen base: -// --model and --data are both mandatory, an unknown flag fails at parse, and -h -// exits 0. The both-present path loads a real model and is left to the train -// package's own suite. +// --model and --data are both mandatory, an unknown flag fails at parse, -h +// exits 0, and --dataset without --checkpoint-dir is rejected before any +// sampling starts (the dataset tap reads the checkpoint dir's capture +// sidecar — see ingestSSDTrace in capture.go — so there is nothing it could +// honestly ingest without one). The both-present path loads a real model and +// is left to the train package's own suite; the dataset tap itself is +// covered directly by TestIngestSSDTrace_Good/_Bad in capture_test.go. func TestRunSSDCommand_Rejects(t *testing.T) { for _, tc := range []struct { name string @@ -24,6 +28,7 @@ func TestRunSSDCommand_Rejects(t *testing.T) { {"data without model", []string{"--data", "/some/prompts.jsonl"}, 2}, {"unknown flag", []string{"--nonsense"}, 2}, {"help", []string{"--help"}, 0}, + {"dataset without checkpoint-dir", []string{"--model", "/some/base", "--data", "/some/prompts.jsonl", "--dataset", "some-slug"}, 2}, } { t.Run(tc.name, func(t *testing.T) { var stdout, stderr bytes.Buffer diff --git a/cli/transcribe.go b/cli/transcribe.go new file mode 100644 index 000000000..7ee534cbf --- /dev/null +++ b/cli/transcribe.go @@ -0,0 +1,95 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package main + +import ( + "context" + "flag" + "io" + + core "dappco.re/go" + "dappco.re/go/inference/model/arch/openai/whisper" +) + +// transcribe.go is thin flag-parsing over dappco.re/go/inference/model/arch/openai/whisper's Load/ +// Model.Transcribe — the ASR business logic lives there, not here (mirroring generate.go's doc comment: +// "the serve business logic lives in dappco.re/go/inference/serving, not here"). Whisper is an +// encoder-decoder that never enters model.Assemble (see whisper.Config.Arch's doc comment), so this +// verb calls whisper.Load directly rather than going through the shared generate/serve model-loading +// path — the same "own loader" shape mamba2 would need if it grew a dedicated verb. +// +// lem transcribe --model ~/models/whisper-tiny clip.wav +// +// ctx carries no cancellation point yet — Model.Transcribe is a single host-CPU forward pass with no +// natural cut point (v1 scope; a later slice could thread it through the decode loop). +func runTranscribeCommand(_ context.Context, args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet(cliCommandName("transcribe"), flag.ContinueOnError) + fs.SetOutput(stderr) + modelDir := fs.String("model", "", "Whisper checkpoint directory (config.json, tokenizer.json, *.safetensors)") + language := fs.String("language", "", "force the source language (\"en\" or \"<|en|>\"); empty auto-detects") + jsonOut := fs.Bool("json", false, "print {\"text\":...,\"language\":...} instead of plain transcript text") + fs.Usage = func() { + name := cliName() + core.WriteString(stderr, core.Sprintf("Usage: %s transcribe --model [flags] \n", name)) + core.WriteString(stderr, "\n") + core.WriteString(stderr, "Transcribe one WAV clip (16-bit PCM, mono, 16 kHz) through a loaded Whisper\n") + core.WriteString(stderr, "checkpoint: host-f32 greedy decode, a single <=30s window (v1 scope — longer\n") + core.WriteString(stderr, "audio is a named refusal, not silent truncation).\n") + core.WriteString(stderr, "\n") + core.WriteString(stderr, "Flags:\n") + printFlagBlock(stderr, fs) + core.WriteString(stderr, "\n") + core.WriteString(stderr, "Examples:\n") + core.WriteString(stderr, core.Sprintf(" %s transcribe --model ~/models/whisper-tiny clip.wav\n", name)) + core.WriteString(stderr, " # auto-detect language, transcript to stdout\n") + core.WriteString(stderr, core.Sprintf(" %s transcribe --model ~/models/whisper-tiny --language en --json clip.wav\n", name)) + core.WriteString(stderr, " # force English, {\"text\":...,\"language\":...} to stdout\n") + } + if err := fs.Parse(args); err != nil { + if core.Is(err, flag.ErrHelp) { + return 0 + } + return 2 + } + if *modelDir == "" { + core.Print(stderr, "%s transcribe: --model is required", cliName()) + fs.Usage() + return 2 + } + if fs.NArg() != 1 { + core.Print(stderr, "%s transcribe: expected exactly one audio WAV path", cliName()) + fs.Usage() + return 2 + } + + m, err := whisper.Load(*modelDir) + if err != nil { + core.Print(stderr, "%s transcribe: %v", cliName(), err) + return 1 + } + audioPath := fs.Arg(0) + read := core.ReadFile(audioPath) + if !read.OK { + core.Print(stderr, "%s transcribe: read %s: %s", cliName(), audioPath, read.Error()) + return 1 + } + wavBytes, ok := read.Value.([]byte) + if !ok { + core.Print(stderr, "%s transcribe: read %s returned non-byte data", cliName(), audioPath) + return 1 + } + + result, err := m.Transcribe(wavBytes, whisper.Options{Language: *language}) + if err != nil { + core.Print(stderr, "%s transcribe: %v", cliName(), err) + return 1 + } + + if *jsonOut { + printJSON(stdout, map[string]string{"text": result.Text, "language": result.Language}) + return 0 + } + core.WriteString(stdout, result.Text) + core.WriteString(stdout, "\n") + return 0 +} diff --git a/cli/transcribe_test.go b/cli/transcribe_test.go new file mode 100644 index 000000000..35e049f84 --- /dev/null +++ b/cli/transcribe_test.go @@ -0,0 +1,53 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package main + +import ( + "bytes" + "context" + "path/filepath" + "testing" + + core "dappco.re/go" +) + +// TestRunTranscribeCommand_Rejects covers the flag/argument guards that return before whisper.Load is +// ever reached: a missing --model, the wrong positional count, and an unreadable audio path. A bogus +// --model value is safe in every case except "unreadable audio" (which needs a real --model string to +// reach the audio-read step; whisper.Load itself is exercised at the library level — +// model/arch/openai/whisper/live_test.go — not re-tested here). +func TestRunTranscribeCommand_Rejects(t *testing.T) { + for _, tc := range []struct { + name string + args []string + wantCode int + }{ + {"no --model", []string{"clip.wav"}, 2}, + {"no positional", []string{"--model", "some-dir"}, 2}, + {"two positionals", []string{"--model", "some-dir", "a.wav", "b.wav"}, 2}, + {"unreadable audio", []string{"--model", "some-dir", filepath.Join(t.TempDir(), "nope.wav")}, 1}, + {"help", []string{"--help"}, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + var stdout, stderr bytes.Buffer + if code := runTranscribeCommand(context.Background(), tc.args, &stdout, &stderr); code != tc.wantCode { + t.Fatalf("exit %d, want %d; stderr=%s", code, tc.wantCode, stderr.String()) + } + }) + } +} + +// TestRunTranscribeCommand_BadModelDir proves a --model directory that resolves (unlike "unreadable +// audio" above) but fails to load — no config.json — reports the library's error and exits 1, not 2 (a +// load failure is a runtime error, not a usage error). +func TestRunTranscribeCommand_BadModelDir(t *testing.T) { + audio := filepath.Join(t.TempDir(), "clip.wav") + if r := core.WriteFile(audio, []byte("not really a wav"), 0o644); !r.OK { + t.Fatalf("write fixture audio: %v", r.Err()) + } + var stdout, stderr bytes.Buffer + code := runTranscribeCommand(context.Background(), []string{"--model", t.TempDir(), audio}, &stdout, &stderr) + if code != 1 { + t.Fatalf("exit %d, want 1; stderr=%s", code, stderr.String()) + } +} diff --git a/cli/tui/README.md b/cli/tui/README.md index 0e13d6db1..9a07ae262 100644 --- a/cli/tui/README.md +++ b/cli/tui/README.md @@ -1,32 +1,322 @@ -# tui — `lem tui` +# LEM terminal workspace -A Bubble Tea terminal UI over the go-inference library: pick a model, chat -with streaming + the thinking channel, and host the HTTP API — all in one -process, one copy of the weights. +`lem tui` is a persistent Bubble Tea workspace around `go-inference`. It keeps +agent-style chat inside one stable frame, lets conversations keep generating +while another session or panel is open, and shares one loaded model between +the TUI and the OpenAI/Anthropic/Ollama-compatible HTTP service. -## Tabs (`tab` / `shift+tab` to move) +The workspace opens asynchronously at `~/.lem/`. Chat is the default panel, +including when no model is loaded. -| Tab | Does | -|-----|------| -| **Chat** | streaming transcript + input; `esc` cancels a reply, `ctrl+t` flips thinking | -| **Models** | discovered checkpoints (HF cache + `$LEM_MODELS_DIR`); `enter` loads with the Settings context length | -| **Service** | OpenAI/Anthropic/Ollama HTTP API for the **loaded** model — same weights, no second load. TUI turns and API requests share one serial scheduler lane, so nothing races the engine. Point opencode/codex at `http://localhost:36911/v1`, Anthropic/Ollama clients at the root | -| **Settings** | context length (applies at load), max tokens, thinking default/on/off | -| **Tools** | built-in demo tools; when armed, declarations ride the system turn, calls execute locally and feed back — the full agent loop in the terminal | -| **Modes** | sampling presets: Balanced (checkpoint defaults) · Greedy · Creative · Coder | +## Run it + +From the nested CLI module: + +```sh +go run . tui +go run . tui --model /path/to/model --context 8192 --max-tokens 4096 +go run . tui --check +``` + +`--check` performs a native-execution-disabled workspace bootstrap, renders one +100x30 frame without a TTY, closes the workspace, and exits nonzero if required +storage cannot open. Bootstrap can ensure LEM directories, open DuckDB +read-write, apply migrations, and recover interrupted workspace records. It +does not construct the native agent engine, start Soft Serve, discover or launch +providers, mutate private/source Git, or admit or change native queue work. + +## Primary panels + +| Panel | Behaviour | +| --- | --- | +| **Chat** | Durable multi-session transcript, Bubbles viewport and composer, streamed thought/answer channels, cached Glamour Markdown, per-session draft/scroll state, tool receipts, and hidden-session attention. | +| **Work** | Durable Work CRUD, native provider dispatch, queue controls, ordered process output, blocked questions, retry/resume, and reviewed acceptance through the connected `go/agent/*` engine. Future actions remain visible with an exact unavailable reason. | +| **Models** | Asynchronous discovery from the Hugging Face cache and `LEM_MODELS_DIR`, fuzzy filtering, explicit loading, and loaded-model detail. A swap is refused while any chat job is active and drains the HTTP listener before replacing an idle model. | +| **Service** | Start or stop the API over the exact serial model lane used by Chat; inspect address, model, request count, client URLs, and listener errors. Stopping the service never closes the model. | +| **Data** | Review surface for the `lem data` training-data loop (`~/.lem/datasets.duckdb`): item list with dataset/status/kind/source/score filters, side-by-side detail (rendered content, score breakdown, lineage, welfare flags), and the review actions — approve, reject, quarantine-clear (note required), edit-as-derived (original archived), tag — each with an uppercase bulk form across the current filter behind a count confirmation. Every action has a mirrored palette entry; unavailable states render honestly. | + +Settings, sampling modes, built-in tools, local knowledge, runtime detection, +and agent capability detail live in the contextual inspector instead of +competing for primary tabs. ## Layout -| File | Concern | -|------|---------| -| `tui.go` | `Run` — flags, `-check` headless frame, program boot | -| `app.go` | the Elm update loop: keys, messages, tab routing, tool loop | -| `tabs.go` | tab bar (bubbletea tabs-example borders) | -| `stream.go` | generation goroutine → event channel → `waitEvent` bridge | -| `service.go` | the Service tab: serial scheduler + `serving.Serve` lifecycle | -| `picker.go` / `settings.go` / `modes.go` / `tools.go` | per-tab state + views | -| `style.go` | lipgloss palette (dark) | - -Everything is testable headless: `app_test.go` drives `Update` directly, and -the `LTHN_PROBE_MODEL`-gated live tests load a real checkpoint, chat through -the real loop, and curl the Service tab's API. +- At 120 columns or wider, the main panel and a 32-column inspector are shown + side by side. +- From 80 to 119 columns, `Ctrl+O` opens the inspector above a compact panel + preview. +- Below 80 columns, `Ctrl+O` makes the inspector the single content view. +- Very short terminals retain a bounded frame, panel identity, session strip, + and footer without slicing ANSI sequences. + +The transcript follows output only while already at the bottom. Manual upward +scrolling preserves the reading position and shows a `new output` marker; +`End` returns to live output. + +## Rendering with .ctml + +Screens are migrating from hand-composed Lip Gloss to `.ctml` markup rendered +by go-html's terminal renderer (`dappco.re/go/html`). The tab strip +(`tabs.go` + `tabs.ctml`) establishes the idiom each converted screen copies: + +1. **Markup file** — the screen's structure lives in a `.ctml` file embedded + beside its Go file (`//go:embed`). Text content doubles as its own i18n + key; `class` attributes are static strings; comments record the host + seams the file exposes. +2. **Bindings** — dynamic rows enter at parse time through `ctml.Bindings` + `Sequences` and ``, bound in text as + `{{row.field}}`. Re-parse and re-bind on every state change — screens + this size make that free. A per-row style variation cannot ride a class + attribute (they are static), so the host splits rows into one sequence + per style (see `panelBarBindings`: `tabsBefore` / `tabsActive` / + `tabsAfter`). +3. **Theme** — `html.TermTheme.Classes` maps the markup's class tokens onto + the existing `uiStyles` palette (`panelBarTheme`). The markup carries no + colours of its own; the palette in `style.go` stays the single source of + visual truth. +4. **Boxes and mouse** — render through `html.RenderTermBoxes` to receive the + `html.BoxMap` of every id'd block, then resolve mouse coordinates with + `teabox.Resolve` inside the app's `tea.MouseMsg` handling (`onMouse`). + Screen cells map to frame-inner cells by subtracting `frameInsetRows` / + `frameInsetCols` (the outer border). The renderer boxes block-level + elements only, so a single-row strip derives its per-item boxes from the + render itself (`mergePanelTabBoxes`) and merges them into the same map — + teabox's smallest-box rule then prefers the item over the strip. + +A left click on a tab switches panels through exactly the same path as +`Tab`/`Shift+Tab` (`selectPanel`); the wheel keeps scrolling the transcript. + +## Keys + +| Key | Scope | Action | +| --- | --- | --- | +| `Tab` / `Shift+Tab` | global | Next / previous primary panel | +| `Ctrl+N` | global | Create and open a blank session | +| `Ctrl+P` | global | Fuzzy recent-session switcher | +| `Alt+Left` / `Alt+Right` | global | Previous / next recent session | +| `Ctrl+K` | global | Searchable command palette | +| `Ctrl+F` | global | Search durable session titles and turn content | +| `Ctrl+O` | global | Toggle the inspector | +| `Ctrl+S` | global | Commit inspector settings | +| `F1` | global | Full key help | +| `Ctrl+C` | global | Cancel jobs, stop service, close resources, and quit | +| `Enter` | Chat | Send a non-empty prompt when a model is loaded | +| `Alt+Enter` | Chat | Insert a newline in the composer | +| `Esc` | Chat | Cancel the visible session's generation | +| `Ctrl+T` | Chat | Toggle explicit thinking on/off | +| `Home` / `End` | Chat | Top / resume live transcript follow | +| `PgUp` / `PgDn`, `Ctrl+U` / `Ctrl+D`, mouse wheel | Chat | Scroll transcript | +| `/` | Models or Work | Filter the focused list | +| `a` / `r` | Data | Approve / reject the selected item | +| `c` | Data | Clear the selected item's quarantine (note required) | +| `e` | Data | Edit as a derived item (the original is archived, lineage kept) | +| `t` | Data | Tag the selected item | +| `A` / `R` / `C` / `T` | Data | The same action in bulk across the current filter, behind a count confirmation | +| `s` | Data | Toggle sort (date / score) | +| `f` | Data | Edit the dataset/status/kind/source/score filter | +| `Ctrl+K`, then `New Work` / `Edit Work` | Work | Create or edit title, task, and repository; `Ctrl+S` saves the editor | +| `Ctrl+O`, arrows, `Enter` | Work | Select and invoke an available native action in the inspector | +| `Enter` | Models | Load the selected model when all jobs are idle | +| `Enter` | Service | Start / stop the listener | +| arrows or `h`/`l` | Service | Change listen preset while stopped | +| arrows or `h`/`j`/`k`/`l` | inspector | Select and adjust settings/actions | +| `Esc` | overlay | Close the topmost overlay | + +## Sessions, jobs, and tools + +Sessions, turns, events, generation jobs, work records, artifacts, and +knowledge attachments are relational DuckDB records. Switching panels or +sessions never cancels generation. Each session may own one job; every session +and HTTP request queues through one serial scheduler around the single resident +model. + +The user turn, assistant placeholder, job state, streamed deltas, final metrics, +tool calls/results, and attention transition are persisted. On restart, queued +or generating jobs and their sessions become `interrupted`, a timeline event +is recorded, partial assistant content remains, and nothing restarts +automatically. Graceful quit drains buffered deltas into the cancelled session +before DuckDB closes. + +When built-in tools are enabled, declarations join the system message. A valid +call is recorded, executed locally, persisted as a tool turn/event, and fed +back through a new durable job. Automatic continuation is bounded to two hops. +Malformed and unknown calls produce explicit failure receipts. + +The command palette exposes session creation and switching, history search, +Markdown/structured-JSON export, panel navigation, settings save, Work creation +and editing, manual Work refresh, and every native action whose durable state +currently permits it. + +## Native Work lifecycle + +### Create, register, and dispatch + +`New Work` and `Edit Work` persist a title, task, and repository directory in +DuckDB. Dispatch is a sequence of current, explicit reviews rather than one +blind process launch: + +1. Select the provider and model. LEM discovers Codex, Claude, and OpenCode at + startup and shows unavailable executable/version reasons without disabling + the rest of the workspace. +2. Review the canonical source path, branch, HEAD, included files, and private + repository name. The source must be clean and on a named branch. +3. For an ad-hoc directory, separately confirm `Enable Git`. This creates local + `.git` metadata and a baseline commit using the displayed repository-local + identity; it does not change global Git configuration. +4. Review the exact redacted provider command, source revision, internal + repository, proposed run worktree, and current queue decision. +5. Confirm launch. The durable run is queued with an immutable ID. `Start queue` + changes a frozen queue to accepting; `Stop queue` freezes it immediately or + drains already-running work before becoming frozen. + +LEM seeds a private repository without adding or rewriting a source remote. +Each attempt runs on a branch such as `lem/work//run-` in an +internal worktree below `~/.lem/workspaces/`. Stdout and stderr are streamed in +one durable sequence. At terminal exit, LEM commits any remaining non-ignored +changes, pushes the branch, and records completed, waiting, failed, cancelled, +or interrupted state before releasing a recoverable worktree. + +### Provider policy and native authority + +`~/.lem/agents.yaml` is policy only; live counters and backoff state remain in +DuckDB. The versioned policy selects the default provider, validation commands, +global/provider/model concurrency, delays, quota windows and backoff, plus each +provider executable, default model, credential environment allow-list, and +extra flags. A minimal override is: + +```yaml +version: 1 +dispatch: + default_agent: codex + global_concurrency: 1 + timeout_minutes: 60 + validation: + - command: go + args: [test, ./...] +providers: + codex: + executable: codex + default_model: gpt-5 + credential_env: [OPENAI_API_KEY] +``` + +Native execution has explicit host access. LEM uses an argument vector rather +than a shell command and passes only configured essential and credential +environment keys, but it is not an operating-system sandbox. A provider can +read or contact anything its host account can access. The launch review repeats +this warning; only confirm providers, flags, tasks, and repositories you trust. + +### Cancel, shutdown, answer, and recover + +`Cancel` stops a queued run or shuts down the running provider process group. +Normal TUI shutdown freezes admission, withdraws queued work, cancels every +owned process group including children, joins output readers, flushes log +chunks, and captures/pushes remaining Git work. If capture or push cannot be +made durable, the internal worktree is retained with a recovery receipt. + +A provider can finish in `waiting` with one durable question. `Answer` stores +the response without mutating the parent run; `Resume` creates an immutable +child attempt from that answer and prior ordered output. `Retry` creates a child +attempt from a failed or cancelled run. For an interrupted run, the TUI's +`Resume` action intentionally uses the same durable retry path. Children reuse +the run branch and reconstruct a released worktree from private Git when +needed. + +On startup, any queued, preparing, running, or cancelling run that could not +have a live owned process is marked `interrupted`; partial logs remain, the +queue starts frozen, and nothing reconnects or restarts automatically. The user +reviews the retained state and explicitly resumes it. + +### Review, validate, accept, or reject + +`Review changes` on a completed run fetches its durable branch into a disposable +integration worktree, replays the agent commit range onto the current clean +source revision, renders commits and diff, and executes the configured +validation commands there. Review never mutates the source checkout. + +Conflicts or failed validation block acceptance and leave source HEAD, status, +index, and files unchanged. If no validation command is configured, the review +requires a separate acknowledgement. `Accept` requires a final confirmation, +rechecks the reviewed Git facts and clean source, then advances the source to +the exact reviewed result and records the accepted revision. `Reject` records +the reviewed decision without changing the source. A stale, superseded, or +tampered review receipt cannot authorize an apply. + +## Local storage + +```text +~/.lem/ +├── config.yaml # written only by an explicit settings commit +├── agents.yaml # provider, queue, rate, and validation policy +├── lem.duckdb # chat, Work, native runs, logs, queue, and reviews +├── state.db # scoped drafts, viewport, and active-session state +├── soft-serve/ # private Git service data, keys, owner lock, and log +├── workspaces/ # cached private clones and per-run worktrees +├── packs/ # local Markdown knowledge documents +└── exports/ # atomic Markdown and JSON session exports +``` + +File-like state is accessed through a sandboxed `dappco.re/go/io` medium rooted +at `~/.lem/`; DuckDB and go-store receive only their resolved local database +filenames. Exports use same-directory temporary files and atomic rename. + +Failure to open the root or DuckDB is blocking and shows Retry/Quit without an +in-memory-history fallback. A go-store failure degrades draft/UI persistence +with a visible warning. Malformed config is preserved, defaults are used for +the run, and commits remain disabled until a successful reload. + +Native orchestration uses the `agent_projects`, `agent_runs`, `agent_events`, +`agent_log_chunks`, `agent_questions`, `agent_answers`, `agent_acceptances`, +`agent_queue_state`, and `agent_provider_state` tables in `lem.duckdb`. Native +records are not copied into the chat-oriented `lem_events` table. + +Soft Serve starts lazily on `127.0.0.1:23231` when registration or dispatch +needs private Git. One live LEM PID owns `soft-serve/owner.lock`; another TUI +continues to provide non-agent panels but receives a visible agent capability +reason. A stale lock is removed only after its recorded PID is no longer live. +The owning TUI stops the listener, closes Soft Serve's private metadata, and +releases the lock during shutdown. That metadata is not application state and +is never queried as DuckDB domain data. + +Knowledge discovery reads Markdown below `packs/` through medium `List`, +`Stat`, and `Read` only. Stored attachments keep a content snapshot and hash, +so later source changes are marked stale without rewriting conversation +context. The current inspector is read-only and rescans knowledge/runtime state +on the next application start. + +## Authority limits + +This slice ports native lifecycle behaviour into focused `go/agent/*` packages; +it does not import CoreAgent. It does launch Codex, Claude, and OpenCode and can +mutate a source repository only for separately confirmed Git enablement or a +validated, explicitly accepted result. It does not create a container, run a +remote agent, contact a forge, create or merge a pull request, or download a +knowledge pack. Those future actions remain visible with their exact +unavailable reasons rather than simulated success. + +LEM runtime metadata stays below `~/.lem/`. It does not create question, +answer, status, Markdown, JSON, YAML, or other LEM control files in source, +cached, execution, review, or validation repositories. + +## Verification + +Deterministic tests use temporary DuckDB/go-store files, clean temporary Git +repositories, fake native provider executables, fake inference models, and fake +runtime adapters. The native receipts drive Codex, Claude, and OpenCode through +dispatch, ordered output, commit, completion, validation, and acceptance; a +separate receipt kills a provider and its child, verifies interruption and log +flush, reopens the orchestrator, resumes from private Git, and accepts the +result. + +```sh +go test ./tui -count=1 +go test -race ./tui -count=1 +go test ./tui -covermode=atomic -coverprofile=/tmp/lem-tui-cover.out -count=1 +go test ./tui -run '^$' -bench '^BenchmarkMarkdownTranscript$' -benchmem -benchtime=20x +cd ../go +go test -race ./agent/work ./agent/queue ./agent/provider ./agent/gitserver ./agent/workspace ./agent/orchestrator -count=1 +``` + +Darwin/Metal live receipts remain opt-in. Set `LTHN_PROBE_MODEL` and +`MLX_METALLIB_PATH` to run a real model through both the chat update loop and +the shared HTTP service. diff --git a/cli/tui/agentadapter.go b/cli/tui/agentadapter.go new file mode 100644 index 000000000..fe4b0155b --- /dev/null +++ b/cli/tui/agentadapter.go @@ -0,0 +1,707 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package tui + +import ( + "context" + "sync" + "time" + + core "dappco.re/go" + "dappco.re/go/inference/agent/orchestrator" + "dappco.re/go/inference/agent/work" + "dappco.re/go/inference/agent/workspace" +) + +// nativeAgentEngine is the reusable orchestration surface consumed at the +// single private adapter boundary. Bubble Tea code only sees the private +// request, review, snapshot, and receipt values declared in agentcap.go. +type nativeAgentEngine interface { + Capabilities() []work.Capability + Snapshot(context.Context, string) core.Result + ReviewProject(context.Context, work.Item) core.Result + RegisterProject(context.Context, orchestrator.ProjectReview, bool) core.Result + ReviewDispatch(context.Context, work.DispatchRequest) core.Result + Dispatch(context.Context, orchestrator.DispatchReview) core.Result + Cancel(context.Context, string) core.Result + Answer(context.Context, string, string) core.Result + Retry(context.Context, work.Item, string) core.Result + Resume(context.Context, work.ResumeRequest) core.Result + StartQueue(context.Context) core.Result + StopQueue(context.Context) core.Result + ReviewChanges(context.Context, string) core.Result + Accept(context.Context, workspace.AcceptRequest) core.Result + Reject(context.Context, string) core.Result + Close() core.Result +} + +// nativeChildReviewEngine is optional so the standalone CLI keeps compiling +// against an older published inference contract while preferring the current +// fail-closed review/confirm surface when it is available. +type nativeChildReviewEngine interface { + ReviewRetry(context.Context, work.Item, string) core.Result + ConfirmRetry(context.Context, any) core.Result + ReviewResume(context.Context, work.ResumeRequest) core.Result + ConfirmResume(context.Context, any) core.Result +} + +type nativeRecoveryEngine interface { + AbandonRecovery(context.Context, string, string) core.Result +} + +type agentChildReviewProjection struct { + Action string + RunID string + Provider string + Model string + WorktreePath string + Branch string + Warning string + Project struct{ RepositoryName string } + Source struct{ Path, Branch, Revision string } + Detection struct{ Provider, Executable, Version string } + Command struct{ Receipt string } + Queue struct { + Allowed bool + Reason string + } +} + +type agentChildReviewPayload struct { + Action agentFeature + Value any +} + +type nativeAgentAdapter struct { + engine nativeAgentEngine + availability *agentAvailability + closeMu sync.Mutex + closeComplete bool + closeResult core.Result +} + +type agentProjectRegistration struct { + Review orchestrator.ProjectReview + Provider string + Model string +} + +func newAgentAdapter(engine nativeAgentEngine) core.Result { + return newAgentAdapterWithAvailability(engine, nil) +} + +func newAgentAdapterWithAvailability(engine nativeAgentEngine, availability *agentAvailability) core.Result { + if engine == nil { + return core.Fail(core.E("tui.newAgentAdapter", "native agent engine is required", nil)) + } + return core.Ok(agentProvider(&nativeAgentAdapter{engine: engine, availability: availability, closeResult: core.Ok(nil)})) +} + +func (adapter *nativeAgentAdapter) Capabilities() []agentCapability { + if adapter == nil || adapter.engine == nil { + return newUnavailableAgentProvider("native agent engine is unavailable").Capabilities() + } + reported := make(map[agentFeature]work.Capability) + for _, capability := range adapter.engine.Capabilities() { + feature := agentFeature(core.Trim(capability.Name)) + if isNativeAgentFeature(feature) { + reported[feature] = capability + } + } + catalog := agentFeatureCatalog("") + for index := range catalog { + feature := catalog[index].Feature + if capability, ok := reported[feature]; ok { + catalog[index].Available = capability.Available + catalog[index].Reason = core.Trim(capability.Reason) + if feature == agentFeatureRecoveryAbandon { + if _, supported := adapter.engine.(nativeRecoveryEngine); !supported { + catalog[index].Available = false + catalog[index].Reason = "native agent engine does not support retained recovery cleanup" + } + } + if !catalog[index].Available && catalog[index].Reason == "" { + catalog[index].Reason = "native agent engine reports this capability unavailable" + } + if reason := adapter.availability.reason(feature); reason != "" { + catalog[index].Available = false + catalog[index].Reason = reason + } + continue + } + if isNativeAgentFeature(feature) { + catalog[index].Reason = "native agent engine does not report this capability" + continue + } + catalog[index].Reason = futureAgentFeatureReason(feature) + } + return catalog +} + +func isNativeAgentFeature(feature agentFeature) bool { + switch feature { + case agentFeatureDispatch, agentFeatureCancel, agentFeatureAnswer, agentFeatureRetry, + agentFeatureResume, agentFeatureQueueStart, agentFeatureQueueStop, + agentFeatureChangesReview, agentFeatureAccept, agentFeatureReject, agentFeatureRecoveryAbandon: + return true + default: + return false + } +} + +func futureAgentFeatureReason(feature agentFeature) string { + reasons := map[agentFeature]string{ + agentFeatureSetup: "project setup is performed through reviewed dispatch registration", + agentFeatureProvider: "provider policy is configured in agents.yaml", + agentFeatureTemplate: "agent templates are not part of the native execution slice", + agentFeaturePlan: "agent planning is not part of the native execution slice", + agentFeatureSession: "agent sessions are not part of the native execution slice", + agentFeatureHandoff: "agent handoff is not part of the native execution slice", + agentFeatureScan: "agent scanning is not part of the native execution slice", + agentFeatureAudit: "agent audit is not part of the native execution slice", + agentFeaturePipeline: "agent pipelines are not part of the native execution slice", + agentFeatureMonitor: "agent monitoring is not part of the native execution slice", + agentFeatureHarvest: "agent harvesting is not part of the native execution slice", + agentFeatureBrainRecall: "Brain recall is not connected to the native execution slice", + agentFeatureBrainRemember: "Brain memory is not connected to the native execution slice", + agentFeatureMessage: "agent messaging is not part of the native execution slice", + agentFeatureFleet: "agent fleet controls are not part of the native execution slice", + agentFeatureForge: "Forge controls are not part of the native execution slice", + agentFeatureRemote: "remote agents are not part of the native execution slice", + agentFeatureQA: "standalone QA is represented by reviewed validation commands", + agentFeatureReview: "generic review is represented by Review Changes", + agentFeaturePRCreate: "pull-request creation is not part of the native execution slice", + agentFeaturePRMerge: "pull-request merging is not part of the native execution slice", + } + if reason := reasons[feature]; reason != "" { + return reason + } + return "capability is not part of the native execution slice" +} + +func (adapter *nativeAgentAdapter) Snapshot(ctx context.Context) core.Result { + if adapter == nil || adapter.engine == nil { + return core.Fail(core.E("tui.agentAdapter.Snapshot", "native agent engine is unavailable", nil)) + } + result := adapter.engine.Snapshot(ctx, "") + if !result.OK { + return result + } + snapshot, ok := result.Value.(work.Snapshot) + if !ok { + return core.Fail(core.E("tui.agentAdapter.Snapshot", core.Sprintf("native agent snapshot has type %T", result.Value), nil)) + } + return core.Ok(mapAgentSnapshot(snapshot)) +} + +func mapAgentSnapshot(snapshot work.Snapshot) agentSnapshot { + mapped := agentSnapshot{ + Work: make([]agentWorkSnapshot, 0, len(snapshot.Projects)+len(snapshot.Runs)), + Events: make([]agentEventSnapshot, 0, len(snapshot.Events)+len(snapshot.Logs)+len(snapshot.Questions)), + } + mapped.QueueStatus, mapped.QueueReason = string(snapshot.Queue.Status), snapshot.Queue.Reason + workIndex := make(map[string]int) + projects := make(map[string]work.Project, len(snapshot.Projects)) + for _, project := range snapshot.Projects { + projects[project.ID] = project + } + runWork := make(map[string]string, len(snapshot.Runs)) + runs := make(map[string]work.Run, len(snapshot.Runs)) + for _, run := range snapshot.Runs { + runWork[run.ID] = run.WorkID + runs[run.ID] = run + index, exists := workIndex[run.WorkID] + if !exists { + index = len(mapped.Work) + workIndex[run.WorkID] = index + mapped.Work = append(mapped.Work, agentWorkSnapshot{ExternalID: run.WorkID, Title: run.WorkID}) + } + item := &mapped.Work[index] + project := projects[run.ProjectID] + if item.Repo == "" { + item.Repo = project.SourcePath + } + if item.Branch == "" { + item.Branch = project.SourceBranch + } + if run.Branch != "" { + item.Branch = run.Branch + } + item.Status = string(run.Status) + item.NativeRunID = run.ID + item.Agent = run.Provider + item.Runtime = run.Model + } + pendingRecoveries := make(map[agentRecoveryReceipt]agentPendingRecovery) + pendingRecoveryTimes := make(map[agentRecoveryReceipt]time.Time) + recoveryAliases := make(map[string]agentRecoveryReceipt) + for _, event := range snapshot.Events { + workID := event.WorkID + if workID == "" { + workID = runWork[event.RunID] + } + if event.Kind == "answered" { + var projection agentAnswerProjection + decoded := core.JSONUnmarshalString(event.DetailJSON, &projection) + if index, exists := workIndex[workID]; decoded.OK && exists && mapped.Work[index].NativeRunID == event.RunID && + projection.AnswerID != "" && projection.QuestionID != "" && projection.ResumeRunID != "" { + mapped.Work[index].AnswerID = projection.AnswerID + mapped.Work[index].ResumeRunID = projection.ResumeRunID + } + } + if recovery, available := mapRetainedAgentRecovery(event, runs); available { + recoveryAliases[recovery.EventID] = recovery.Receipt + createdAt, exists := pendingRecoveryTimes[recovery.Receipt] + canonical := pendingRecoveries[recovery.Receipt] + if !exists || event.CreatedAt.Before(createdAt) || event.CreatedAt.Equal(createdAt) && recovery.EventID < canonical.EventID { + pendingRecoveries[recovery.Receipt] = recovery + pendingRecoveryTimes[recovery.Receipt] = event.CreatedAt + } + } + mapped.Events = append(mapped.Events, agentEventSnapshot{ + ExternalID: event.ID, WorkID: workID, RunID: event.RunID, Kind: event.Kind, + Title: event.Title, Detail: event.Detail, CreatedAt: event.CreatedAt, + }) + } + for _, event := range snapshot.Events { + outcome, succeeded, available := mapAgentRecoveryOutcome(event) + if !available { + continue + } + receipt, exists := recoveryAliases[outcome.RecoveryEventID] + if !exists || receipt != outcome.Receipt || event.RunID != outcome.Receipt.RunID || event.WorkID != outcome.Receipt.WorkID { + continue + } + if succeeded && (event.Detail != receipt.Worktree || outcome.Error != "") { + continue + } + if succeeded { + delete(pendingRecoveries, receipt) + } + } + orderedRecoveries := make([]agentPendingRecovery, 0, len(pendingRecoveries)) + for _, recovery := range pendingRecoveries { + orderedRecoveries = append(orderedRecoveries, recovery) + } + core.SliceSortFunc(orderedRecoveries, func(left, right agentPendingRecovery) bool { + return left.EventID < right.EventID + }) + for _, recovery := range orderedRecoveries { + if index, exists := workIndex[recovery.Receipt.WorkID]; exists { + mapped.Work[index].RecoveryCount++ + if mapped.Work[index].Recovery.EventID == "" { + mapped.Work[index].Recovery = recovery + } + } + } + for _, log := range snapshot.Logs { + mapped.Events = append(mapped.Events, agentEventSnapshot{ + ExternalID: core.Sprintf("log:%s:%d", log.RunID, log.Sequence), + WorkID: runWork[log.RunID], RunID: log.RunID, Sequence: log.Sequence, Stream: log.Stream, + Kind: core.Concat("log.", core.Lower(core.Trim(log.Stream))), + Title: core.Concat(core.Upper(core.Trim(log.Stream)), " output"), + Detail: log.Text, + CreatedAt: log.CreatedAt, + }) + } + for _, question := range snapshot.Questions { + workID := runWork[question.RunID] + mapped.Events = append(mapped.Events, agentEventSnapshot{ + ExternalID: question.ID, WorkID: workID, RunID: question.RunID, Kind: "question", + Title: "Agent question", Detail: question.Text, CreatedAt: question.CreatedAt, + }) + index, exists := workIndex[workID] + if !exists || mapped.Work[index].NativeRunID != question.RunID { + continue + } + mapped.Work[index].Question = question.Text + mapped.Work[index].QuestionID = question.ID + } + for _, acceptance := range snapshot.Acceptances { + if index, exists := workIndex[acceptance.WorkID]; exists && mapped.Work[index].NativeRunID == acceptance.RunID { + mapped.Work[index].ReviewID, mapped.Work[index].ReviewStatus = acceptance.ID, acceptance.Status + var review workspace.ChangeReview + if decoded := core.JSONUnmarshalString(acceptance.ValidationJSON, &review); decoded.OK { + allowed := len(review.Conflicts) == 0 + for _, validation := range review.Validation { + if !validation.Passed { + allowed = false + } + } + mapped.Work[index].Review = mapChangeReview(review, allowed) + } + } + } + sortAgentEvents(mapped.Events) + return mapped +} + +func mapRetainedAgentRecovery(event work.Event, runs map[string]work.Run) (agentPendingRecovery, bool) { + if event.Kind != "workspace_cleanup_retained" && event.Kind != "review_cleanup_retained" { + return agentPendingRecovery{}, false + } + var receipt agentRecoveryReceipt + decoded := core.JSONUnmarshalString(event.DetailJSON, &receipt) + if !decoded.OK || core.Trim(event.ID) == "" || receipt.RunID != event.RunID || receipt.WorkID != event.WorkID || + core.Trim(receipt.ProjectID) == "" || core.Trim(receipt.Branch) == "" || core.Trim(receipt.Worktree) == "" { + return agentPendingRecovery{}, false + } + run, exists := runs[receipt.RunID] + if !exists || run.WorkID != receipt.WorkID || run.ProjectID != receipt.ProjectID || run.Number != receipt.RunNumber { + return agentPendingRecovery{}, false + } + if receipt.Kind == "run" { + if event.Kind != "workspace_cleanup_retained" || receipt.ReviewID != "" || core.Trim(receipt.WorkspaceRunID) == "" { + return agentPendingRecovery{}, false + } + } else if receipt.Kind == "review" { + if event.Kind != "review_cleanup_retained" || core.Trim(receipt.ReviewID) == "" || receipt.WorkspaceRunID != "" { + return agentPendingRecovery{}, false + } + } else { + return agentPendingRecovery{}, false + } + return agentPendingRecovery{EventID: event.ID, Receipt: receipt}, true +} + +func mapAgentRecoveryOutcome(event work.Event) (agentRecoveryOutcome, bool, bool) { + succeeded := event.Kind == "cleanup_recovery_succeeded" + if !succeeded && event.Kind != "cleanup_recovery_failed" { + return agentRecoveryOutcome{}, false, false + } + var outcome agentRecoveryOutcome + decoded := core.JSONUnmarshalString(event.DetailJSON, &outcome) + if !decoded.OK || core.Trim(outcome.RecoveryEventID) == "" { + return agentRecoveryOutcome{}, false, false + } + return outcome, succeeded, true +} + +func mapChangeReview(review workspace.ChangeReview, allowed bool) agentReview { + warning := "Accept applies the reviewed result to the source only after explicit final confirmation." + if len(review.Conflicts) > 0 { + warning = "Integration conflicts must be resolved by a later reviewed attempt; the source remains unchanged." + } else if !allowed { + warning = "At least one validation failed; the source remains unchanged until a later reviewed attempt passes." + } else if len(review.Validation) == 0 { + warning = "No validation command is configured; acceptance requires explicit acknowledgement." + } + return agentReview{Feature: agentFeatureChangesReview, Title: "Review agent changes", Body: renderAgentChangeReview(review), Warning: warning, ConfirmRequired: true, NeedsAcknowledgement: len(review.Validation) == 0, AcceptanceAllowed: allowed, Payload: review} +} + +func firstAgentText(values ...string) string { + if value := firstNonEmptyAgentText(values...); value != "" { + return value + } + return "Agent work" +} + +func firstNonEmptyAgentText(values ...string) string { + for _, value := range values { + if value = core.Trim(value); value != "" { + return value + } + } + return "" +} + +func (adapter *nativeAgentAdapter) Review(ctx context.Context, request agentReviewRequest) core.Result { + if adapter == nil || adapter.engine == nil { + return core.Fail(core.E("tui.agentAdapter.Review", "native agent engine is unavailable", nil)) + } + switch request.Feature { + case agentFeatureDispatch: + item := nativeWorkItem(request.Work, request.WorkID, request.Input) + reviewResult := adapter.engine.ReviewProject(ctx, item) + if !reviewResult.OK { + return reviewResult + } + review, ok := reviewResult.Value.(orchestrator.ProjectReview) + if !ok { + return core.Fail(core.E("tui.agentAdapter.Review", core.Sprintf("project review has type %T", reviewResult.Value), nil)) + } + warning := "Registering starts the private Git service and seeds an internal repository." + if review.RequiresGitEnable { + warning = "This directory is not a Git repository. Enabling Git requires a separate explicit confirmation before private registration." + } + return core.Ok(agentReview{ + Feature: agentFeatureDispatch, Title: "Review project registration", + Body: core.Sprintf("Source: %s\nBranch: %s\nRevision: %s\nPrivate repository: %s\nIncluded files: %d", + review.Source.Path, review.Source.Branch, review.Source.Revision, review.RepositoryName, len(review.Source.Included)), + Warning: warning, ConfirmRequired: true, GitConfirmRequired: review.RequiresGitEnable, + Payload: agentProjectRegistration{Review: review, Provider: request.Provider, Model: request.Model}, + }) + case agentFeatureChangesReview: + reviewResult := adapter.engine.ReviewChanges(ctx, request.WorkID) + if !reviewResult.OK { + return reviewResult + } + review, ok := reviewResult.Value.(workspace.ChangeReview) + if !ok { + return core.Fail(core.E("tui.agentAdapter.Review", core.Sprintf("change review has type %T", reviewResult.Value), nil)) + } + allowed := len(review.Conflicts) == 0 + for _, validation := range review.Validation { + if !validation.Passed { + allowed = false + } + } + return core.Ok(mapChangeReview(review, allowed)) + case agentFeatureRetry, agentFeatureResume: + children, ok := adapter.engine.(nativeChildReviewEngine) + if !ok { + return core.Fail(core.E("tui.agentAdapter.Review", "native agent engine does not support reviewed child launches", nil)) + } + var reviewed core.Result + item := nativeWorkItem(request.Work, request.Work.ID, "") + action := agentFeatureResume + if request.Feature == agentFeatureRetry || core.Trim(request.Input) == "" { + action = agentFeatureRetry + reviewed = children.ReviewRetry(ctx, item, request.WorkID) + } else { + reviewed = children.ReviewResume(ctx, work.ResumeRequest{ + Work: item, ParentRunID: request.WorkID, AnswerID: request.Input, + Provider: request.Provider, Model: request.Model, + }) + } + if !reviewed.OK { + return reviewed + } + return mapChildLaunchReview(request.Feature, action, reviewed.Value) + case agentFeatureRecoveryAbandon: + recovery := request.Recovery + if recovery.EventID == "" || recovery.Receipt.RunID == "" || recovery.Receipt.WorkID != request.WorkID { + return core.Fail(core.E("tui.agentAdapter.Review", "retained recovery receipt is incomplete", nil)) + } + return core.Ok(agentReview{ + Feature: agentFeatureRecoveryAbandon, Title: "Abandon retained recovery", + Body: core.Sprintf("Recovery event: %s\nRun: %s (#%d)\nWorkspace owner: %s\nKind: %s\nBranch: %s\nWorktree: %s", + recovery.EventID, recovery.Receipt.RunID, recovery.Receipt.RunNumber, recovery.Receipt.WorkspaceRunID, + recovery.Receipt.Kind, recovery.Receipt.Branch, recovery.Receipt.Worktree), + Warning: "Confirmation retries verified cleanup of only this retained internal worktree and generated branch.", + ConfirmRequired: true, Payload: recovery, + }) + default: + return core.Fail(core.E("tui.agentAdapter.Review", core.Concat("agent feature does not support review: ", string(request.Feature)), nil)) + } +} + +func renderAgentChangeReview(review workspace.ChangeReview) string { + builder := core.NewBuilder() + builder.WriteString(core.Sprintf("Source branch: %s\nSource revision: %s\nAgent base: %s\nAgent tip: %s\nResult revision: %s\nIntegration: %s\n\nCommits:\n%s\n\nDiff:\n%s\n\nValidation:\n", review.SourceBranch, review.SourceRevision, review.AgentBase, review.AgentTip, review.ResultRevision, review.IntegrationPath, review.CommitLog, review.Diff)) + if len(review.Validation) == 0 { + builder.WriteString("No validation command configured\n") + } + for _, validation := range review.Validation { + status := "FAILED" + if validation.Passed { + status = "PASSED" + } + builder.WriteString(core.Sprintf("%s %s %s receipt: %s\n%s\n", status, validation.Command.Executable, core.Join(" ", validation.Command.Args...), validation.Receipt, validation.Output)) + } + builder.WriteString("Conflicts:\n") + if len(review.Conflicts) == 0 { + builder.WriteString("none\n") + } + for _, conflict := range review.Conflicts { + builder.WriteString(conflict + "\n") + } + return builder.String() +} + +func (adapter *nativeAgentAdapter) Run(ctx context.Context, request agentRequest) core.Result { + if adapter == nil || adapter.engine == nil { + return core.Fail(core.E("tui.agentAdapter.Run", "native agent engine is unavailable", nil)) + } + var result core.Result + runID := firstNonEmptyAgentText(request.RunID, request.WorkID) + switch request.Feature { + case agentFeatureDispatch: + return adapter.runDispatch(ctx, request) + case agentFeatureCancel: + result = adapter.engine.Cancel(ctx, runID) + case agentFeatureAnswer: + result = adapter.engine.Answer(ctx, runID, request.Input) + case agentFeatureRetry: + children, ok := adapter.engine.(nativeChildReviewEngine) + payload, payloadOK := request.Review.Payload.(agentChildReviewPayload) + if !ok || !payloadOK || payload.Action != agentFeatureRetry || !request.Confirmed || request.Review.Feature != agentFeatureRetry || payload.Value == nil { + return core.Fail(core.E("tui.agentAdapter.Run", "retry requires an explicitly confirmed child launch review", nil)) + } + result = children.ConfirmRetry(ctx, payload.Value) + case agentFeatureResume: + children, ok := adapter.engine.(nativeChildReviewEngine) + payload, payloadOK := request.Review.Payload.(agentChildReviewPayload) + if !ok || !payloadOK || !request.Confirmed || request.Review.Feature != agentFeatureResume || payload.Value == nil { + return core.Fail(core.E("tui.agentAdapter.Run", "resume requires an explicitly confirmed child launch review", nil)) + } + if payload.Action == agentFeatureRetry { + result = children.ConfirmRetry(ctx, payload.Value) + } else if payload.Action == agentFeatureResume { + result = children.ConfirmResume(ctx, payload.Value) + } else { + return core.Fail(core.E("tui.agentAdapter.Run", "resume child review has an invalid continuation action", nil)) + } + case agentFeatureQueueStart: + result = adapter.engine.StartQueue(ctx) + case agentFeatureQueueStop: + result = adapter.engine.StopQueue(ctx) + case agentFeatureAccept: + review, ok := request.Review.Payload.(workspace.ChangeReview) + if !ok || request.Review.Feature != agentFeatureChangesReview { + return core.Fail(core.E("tui.agentAdapter.Run", "accept requires a reviewed change receipt", nil)) + } + result = adapter.engine.Accept(ctx, workspace.AcceptRequest{Review: review, Confirmed: request.Confirmed}) + case agentFeatureReject: + result = adapter.engine.Reject(ctx, runID) + case agentFeatureRecoveryAbandon: + recoveryEngine, supported := adapter.engine.(nativeRecoveryEngine) + recovery, payloadOK := request.Review.Payload.(agentPendingRecovery) + if !supported || !payloadOK || !request.Confirmed || request.Review.Feature != agentFeatureRecoveryAbandon || + recovery != request.Recovery || recovery.EventID == "" || recovery.Receipt.RunID != runID { + return core.Fail(core.E("tui.agentAdapter.Run", "retained recovery cleanup requires an explicitly confirmed current receipt", nil)) + } + result = recoveryEngine.AbandonRecovery(ctx, recovery.Receipt.RunID, recovery.EventID) + default: + return core.Fail(core.E("tui.agentAdapter.Run", core.Concat("unsupported native agent action: ", string(request.Feature)), nil)) + } + return privateAgentReceipt(request, result) +} + +func mapChildLaunchReview(feature, action agentFeature, payload any) core.Result { + var review agentChildReviewProjection + decoded := core.JSONUnmarshalString(core.JSONMarshalString(payload), &review) + if !decoded.OK || review.Action != string(action) || review.RunID == "" || review.Command.Receipt == "" { + return core.Fail(core.E("tui.mapChildLaunchReview", "native child review is incomplete", decoded.Err())) + } + queueStatus := "ready for admission" + if reason := core.Trim(review.Queue.Reason); reason != "" { + queueStatus = reason + } + title := "Review native retry launch" + if feature == agentFeatureResume { + title = "Review native resume launch" + } + return core.Ok(agentReview{ + Feature: feature, Title: title, + Body: core.Sprintf("Provider: %s\nModel: %s\nCommand: %s\nSource: %s\nBranch: %s\nRevision: %s\nPrivate repository: %s\nWorktree: %s\nQueue: %s\nParent branch: %s\nChild run: %s", + review.Provider, review.Model, review.Command.Receipt, review.Source.Path, review.Source.Branch, + review.Source.Revision, review.Project.RepositoryName, review.WorktreePath, queueStatus, + review.Branch, review.RunID), + Warning: review.Warning, ConfirmRequired: true, Payload: agentChildReviewPayload{Action: action, Value: payload}, + }) +} + +func (adapter *nativeAgentAdapter) runDispatch(ctx context.Context, request agentRequest) core.Result { + switch payload := request.Review.Payload.(type) { + case agentProjectRegistration: + if !request.Confirmed { + return core.Fail(core.E("tui.agentAdapter.Run", "project registration requires explicit confirmation", nil)) + } + if payload.Review.RequiresGitEnable != request.EnableGit { + if payload.Review.RequiresGitEnable { + return core.Fail(core.E("tui.agentAdapter.Run", "project registration requires separate Git enable confirmation", nil)) + } + return core.Fail(core.E("tui.agentAdapter.Run", "Git enable confirmation is invalid for an existing repository", nil)) + } + registered := adapter.engine.RegisterProject(ctx, payload.Review, true) + if !registered.OK { + return registered + } + project, ok := registered.Value.(work.Project) + if !ok { + return core.Fail(core.E("tui.agentAdapter.Run", core.Sprintf("registered project has type %T", registered.Value), nil)) + } + dispatchResult := adapter.engine.ReviewDispatch(ctx, work.DispatchRequest{ + Work: payload.Review.Work, Provider: payload.Provider, Model: payload.Model, + ConfirmedSourceRevision: project.SourceRevision, + }) + if !dispatchResult.OK { + return dispatchResult + } + dispatch, ok := dispatchResult.Value.(orchestrator.DispatchReview) + if !ok { + return core.Fail(core.E("tui.agentAdapter.Run", core.Sprintf("dispatch review has type %T", dispatchResult.Value), nil)) + } + return core.Ok(mapDispatchReview(dispatch)) + case orchestrator.DispatchReview: + if !request.Confirmed { + return core.Fail(core.E("tui.agentAdapter.Run", "dispatch requires explicit launch confirmation", nil)) + } + return privateAgentReceipt(request, adapter.engine.Dispatch(ctx, payload)) + default: + return core.Fail(core.E("tui.agentAdapter.Run", "dispatch requires a current reviewed receipt", nil)) + } +} + +func mapDispatchReview(review orchestrator.DispatchReview) agentReview { + queueStatus := "ready for admission" + if reason := core.Trim(review.Queue.Reason); reason != "" { + queueStatus = reason + } + return agentReview{ + Feature: agentFeatureDispatch, Title: "Review native agent launch", + Body: core.Sprintf("Provider: %s\nModel: %s\nCommand: %s\nSource: %s\nBranch: %s\nRevision: %s\nPrivate repository: %s\nWorktree: %s\nQueue: %s", + review.Request.Provider, review.Request.Model, review.Command.Receipt, review.Source.Path, + review.Source.Branch, review.Source.Revision, review.Project.RepositoryName, review.WorktreePath, queueStatus), + Warning: review.Warning, ConfirmRequired: true, Payload: review, + } +} + +func nativeWorkItem(request agentWorkRequest, fallbackID, fallbackInput string) work.Item { + id := firstNonEmptyAgentText(request.ID, request.ExternalID, fallbackID) + title := firstNonEmptyAgentText(request.Title, id) + task := core.Trim(request.Task) + repository := firstNonEmptyAgentText(request.Repository, fallbackInput) + return work.Item{ID: id, ExternalID: request.ExternalID, Title: title, Task: task, Repository: repository} +} + +func privateAgentReceipt(request agentRequest, result core.Result) core.Result { + if !result.OK { + return result + } + receipt := agentActionReceipt{Feature: request.Feature, WorkID: request.WorkID} + switch value := result.Value.(type) { + case work.Run: + receipt.WorkID = value.WorkID + receipt.RunID = value.ID + receipt.Status = string(value.Status) + case work.Answer: + receipt.RunID = value.ResumeRunID + receipt.Status = "answered" + receipt.Detail = value.ID + case work.QueueState: + receipt.Status = string(value.Status) + receipt.Detail = value.Reason + case work.Acceptance: + receipt.WorkID = value.WorkID + receipt.RunID = value.RunID + receipt.Status = value.Status + case work.Event: + receipt.RunID = value.RunID + receipt.Status = value.Kind + receipt.Detail = value.Detail + case nil: + receipt.Status = "completed" + default: + receipt.Status = "completed" + receipt.Detail = core.Sprintf("%v", value) + } + return core.Ok(receipt) +} + +func (adapter *nativeAgentAdapter) Close() core.Result { + if adapter == nil { + return core.Ok(nil) + } + adapter.closeMu.Lock() + defer adapter.closeMu.Unlock() + if adapter.closeComplete { + return adapter.closeResult + } + if adapter.engine != nil { + adapter.closeResult = adapter.engine.Close() + } + adapter.closeComplete = adapter.closeResult.OK + return adapter.closeResult +} diff --git a/cli/tui/agentadapter_test.go b/cli/tui/agentadapter_test.go new file mode 100644 index 000000000..7680baa6f --- /dev/null +++ b/cli/tui/agentadapter_test.go @@ -0,0 +1,1428 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package tui + +import ( + "context" + "reflect" + "strings" + "sync" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + + core "dappco.re/go" + "dappco.re/go/inference/agent/gitserver" + "dappco.re/go/inference/agent/orchestrator" + "dappco.re/go/inference/agent/provider" + "dappco.re/go/inference/agent/queue" + "dappco.re/go/inference/agent/work" + "dappco.re/go/inference/agent/workspace" + coreio "dappco.re/go/io" + commandexec "dappco.re/go/process/exec" +) + +func TestAgentAdapter_Capabilities_Good(t *testing.T) { + engine := &fixtureNativeAgentEngine{capabilities: []work.Capability{ + {Name: "dispatch", Available: true}, {Name: "cancel", Available: true}, + {Name: "answer", Available: true}, {Name: "retry", Available: true}, + {Name: "resume", Available: true}, {Name: "queue.start", Available: true}, + {Name: "queue.stop", Available: true}, {Name: "changes.review", Available: true}, + {Name: "accept", Available: true}, {Name: "reject", Available: true}, + }} + adapter := requireAgentAdapter(t, engine) + + capabilities := adapter.Capabilities() + available := make([]agentFeature, 0, 10) + unavailableReasons := make(map[agentFeature]string) + for _, capability := range capabilities { + if capability.Available { + available = append(available, capability.Feature) + } else { + unavailableReasons[capability.Feature] = capability.Reason + } + } + want := []agentFeature{ + agentFeatureDispatch, agentFeatureCancel, agentFeatureAnswer, agentFeatureRetry, + agentFeatureResume, agentFeatureQueueStart, agentFeatureQueueStop, + agentFeatureChangesReview, agentFeatureAccept, agentFeatureReject, + } + if core.JSONMarshalString(available) != core.JSONMarshalString(want) { + t.Fatalf("available capabilities = %#v, want %#v", available, want) + } + for _, feature := range []agentFeature{agentFeatureSetup, agentFeatureProvider, agentFeatureTemplate, agentFeaturePlan, agentFeatureSession, agentFeatureHandoff, agentFeatureScan, agentFeatureAudit, agentFeaturePipeline, agentFeatureMonitor, agentFeatureHarvest, agentFeatureBrainRecall, agentFeatureBrainRemember, agentFeatureMessage, agentFeatureFleet, agentFeatureForge, agentFeatureRemote, agentFeatureQA, agentFeatureReview, agentFeaturePRCreate, agentFeaturePRMerge} { + if core.Trim(unavailableReasons[feature]) == "" { + t.Fatalf("future capability %q has no specific unavailable reason", feature) + } + } +} + +func TestAgentAdapter_Snapshot_Good(t *testing.T) { + at := time.Date(2026, time.July, 18, 11, 0, 0, 0, time.UTC) + engine := &fixtureNativeAgentEngine{snapshot: work.Snapshot{ + Projects: []work.Project{{ID: "work-1", SourcePath: "/src/one", SourceBranch: "main", RepositoryName: "private-one"}}, + Runs: []work.Run{ + {ID: "run-1", WorkID: "work-1", ProjectID: "work-1", Provider: "codex", Model: "gpt-5", Branch: "lem/work-1/1", Status: work.RunRunning}, + {ID: "run-2", WorkID: "work-2", Provider: "claude", Model: "opus", Status: work.RunWaiting}, + }, + Events: []work.Event{{ID: "event-2", RunID: "run-1", WorkID: "work-1", Kind: "started", Title: "started", CreatedAt: at.Add(2 * time.Second)}}, + Logs: []work.LogChunk{{RunID: "run-1", Sequence: 4, Stream: "stdout", Text: "building", CreatedAt: at.Add(time.Second)}}, + Questions: []work.Question{{ID: "question-1", RunID: "run-2", Text: "Which target?", CreatedAt: at.Add(3 * time.Second)}}, + }} + adapter := requireAgentAdapter(t, engine) + + result := adapter.Snapshot(context.Background()) + if !result.OK { + t.Fatalf("Snapshot failed: %s", result.Error()) + } + snapshot, ok := result.Value.(agentSnapshot) + if !ok { + t.Fatalf("Snapshot value = %T, want agentSnapshot", result.Value) + } + if len(snapshot.Work) != 2 || snapshot.Work[0].ExternalID != "work-1" || snapshot.Work[0].Repo != "/src/one" || snapshot.Work[0].Agent != "codex" || snapshot.Work[1].ExternalID != "work-2" || snapshot.Work[1].Question != "Which target?" { + t.Fatalf("mapped work = %#v", snapshot.Work) + } + if len(snapshot.Events) != 3 || snapshot.Events[0].Kind != "log.stdout" || snapshot.Events[1].ExternalID != "event-2" || snapshot.Events[2].Kind != "question" { + t.Fatalf("ordered events/logs/questions = %#v", snapshot.Events) + } + if engine.snapshotWorkID != "" { + t.Fatalf("Snapshot work ID = %q, want all work", engine.snapshotWorkID) + } +} + +func TestAgentAdapterSnapshotMapsAnsweredWaitingResumeIdentity(t *testing.T) { + at := time.Date(2026, time.July, 19, 10, 0, 0, 0, time.UTC) + projection := agentAnswerProjection{AnswerID: "answer-1", QuestionID: "question-1", ResumeRunID: "resume-1"} + adapter := requireAgentAdapter(t, &fixtureNativeAgentEngine{snapshot: work.Snapshot{ + Runs: []work.Run{{ID: "waiting-1", WorkID: "work-1", Status: work.RunWaiting}}, + Questions: []work.Question{{ID: projection.QuestionID, RunID: "waiting-1", Text: "Which target?", CreatedAt: at}}, + Events: []work.Event{{ + ID: "answer:" + projection.AnswerID, RunID: "waiting-1", WorkID: "work-1", Kind: "answered", + DetailJSON: core.JSONMarshalString(projection), CreatedAt: at.Add(time.Second), + }}, + }}) + + result := adapter.Snapshot(context.Background()) + core.AssertTrue(t, result.OK, result.Error()) + mapped := result.Value.(agentSnapshot) + core.AssertEqual(t, 1, len(mapped.Work)) + core.AssertEqual(t, projection.QuestionID, mapped.Work[0].QuestionID) + core.AssertEqual(t, projection.AnswerID, mapped.Work[0].AnswerID) + core.AssertEqual(t, projection.ResumeRunID, mapped.Work[0].ResumeRunID) +} + +func TestAgentAdapterSnapshotMapsRetainedCleanupRecovery(t *testing.T) { + at := time.Date(2026, time.July, 19, 13, 0, 0, 0, time.UTC) + receipt := agentRecoveryReceipt{ + Kind: "run", ProjectID: "project-1", WorkID: "work-1", RunID: "attempt-7", + RunNumber: 7, WorkspaceRunID: "lineage-root", Branch: "lem/work/work-1/run-7", + Worktree: "/private/workspaces/project-1/runs/lineage-root/worktree", + } + adapter := requireAgentAdapter(t, &fixtureNativeAgentEngine{snapshot: work.Snapshot{ + Runs: []work.Run{{ID: receipt.RunID, WorkID: receipt.WorkID, ProjectID: receipt.ProjectID, Number: receipt.RunNumber, Status: work.RunFailed}}, + Events: []work.Event{{ + ID: "recovery-event-1", RunID: receipt.RunID, WorkID: receipt.WorkID, + Kind: "workspace_cleanup_retained", Detail: receipt.Worktree, + DetailJSON: core.JSONMarshalString(receipt), CreatedAt: at, + }}, + }}) + + result := adapter.Snapshot(context.Background()) + core.AssertTrue(t, result.OK, result.Error()) + snapshot := result.Value.(agentSnapshot) + core.AssertEqual(t, 1, len(snapshot.Work)) + core.AssertEqual(t, 1, snapshot.Work[0].RecoveryCount) + core.AssertEqual(t, "recovery-event-1", snapshot.Work[0].Recovery.EventID) + core.AssertEqual(t, receipt, snapshot.Work[0].Recovery.Receipt) +} + +func TestAgentAdapterSnapshotFoldsCleanupRecoveryOutcomes(t *testing.T) { + at := time.Date(2026, time.July, 19, 13, 30, 0, 0, time.UTC) + receipt := agentRecoveryReceipt{ + Kind: "review", ProjectID: "project-1", WorkID: "work-1", RunID: "attempt-8", + RunNumber: 8, ReviewID: "review-2", + Branch: "lem/integration/attempt-8/review-2", + Worktree: "/private/workspaces/project-1/reviews/lineage-root/review-2/worktree", + } + retained := work.Event{ + ID: "recovery-event-2", RunID: receipt.RunID, WorkID: receipt.WorkID, + Kind: "review_cleanup_retained", Detail: receipt.Worktree, + DetailJSON: core.JSONMarshalString(receipt), CreatedAt: at, + } + for _, test := range []struct { + name string + kind string + outcomeError string + eventDetail string + wantCount int + }{ + {name: "failed remains pending", kind: "cleanup_recovery_failed", outcomeError: "worktree remove failed", wantCount: 1}, + {name: "succeeded resolves pending", kind: "cleanup_recovery_succeeded", wantCount: 0}, + {name: "success with error fails closed", kind: "cleanup_recovery_succeeded", outcomeError: "cleanup remains retained", wantCount: 1}, + {name: "success with mismatched detail fails closed", kind: "cleanup_recovery_succeeded", eventDetail: "/different/worktree", wantCount: 1}, + } { + t.Run(test.name, func(t *testing.T) { + outcome := agentRecoveryOutcome{RecoveryEventID: retained.ID, Receipt: receipt, Error: test.outcomeError} + eventDetail := test.eventDetail + if eventDetail == "" { + eventDetail = receipt.Worktree + } + adapter := requireAgentAdapter(t, &fixtureNativeAgentEngine{snapshot: work.Snapshot{ + Runs: []work.Run{{ID: receipt.RunID, WorkID: receipt.WorkID, ProjectID: receipt.ProjectID, Number: receipt.RunNumber, Status: work.RunCompleted}}, + Events: []work.Event{retained, { + ID: "recovery-outcome-2", RunID: receipt.RunID, WorkID: receipt.WorkID, + Kind: test.kind, Detail: eventDetail, + DetailJSON: core.JSONMarshalString(outcome), CreatedAt: at.Add(time.Second), + }}, + }}) + + result := adapter.Snapshot(context.Background()) + core.AssertTrue(t, result.OK, result.Error()) + state := result.Value.(agentSnapshot).Work[0] + core.AssertEqual(t, test.wantCount, state.RecoveryCount) + if test.wantCount == 0 { + core.AssertEqual(t, "", state.Recovery.EventID) + } else { + core.AssertEqual(t, retained.ID, state.Recovery.EventID) + } + }) + } +} + +func TestAgentAdapterSnapshotDeduplicatesHistoricalCleanupRecoveryReceipts(t *testing.T) { + at := time.Date(2026, time.July, 19, 13, 45, 0, 0, time.UTC) + receipt := agentRecoveryReceipt{ + Kind: "review", ProjectID: "project-1", WorkID: "work-1", RunID: "attempt-8", + RunNumber: 8, ReviewID: "review-2", Branch: "lem/integration/attempt-8/review-2", + Worktree: "/private/workspaces/project-1/reviews/lineage-root/review-2/worktree", + } + first := work.Event{ + ID: "recovery-event-a", RunID: receipt.RunID, WorkID: receipt.WorkID, + Kind: "review_cleanup_retained", Detail: receipt.Worktree, + DetailJSON: core.JSONMarshalString(receipt), CreatedAt: at, + } + duplicate := first + duplicate.ID = "recovery-event-b" + duplicate.CreatedAt = at.Add(time.Second) + mismatched := receipt + mismatched.Worktree = core.Concat(receipt.Worktree, "-other") + for _, test := range []struct { + name string + outcomeKind string + outcomeReceipt agentRecoveryReceipt + wantCount int + }{{name: "duplicates fold", wantCount: 1}, + {name: "failed alias remains folded", outcomeKind: "cleanup_recovery_failed", outcomeReceipt: receipt, wantCount: 1}, + {name: "successful alias resolves receipt", outcomeKind: "cleanup_recovery_succeeded", outcomeReceipt: receipt, wantCount: 0}, + {name: "mismatched alias fails closed", outcomeKind: "cleanup_recovery_succeeded", outcomeReceipt: mismatched, wantCount: 1}} { + t.Run(test.name, func(t *testing.T) { + events := []work.Event{duplicate, first} + if test.outcomeKind != "" { + outcome := agentRecoveryOutcome{RecoveryEventID: duplicate.ID, Receipt: test.outcomeReceipt} + if test.outcomeKind == "cleanup_recovery_failed" { + outcome.Error = "worktree remove failed" + } + events = append(events, work.Event{ + ID: "recovery-outcome", RunID: receipt.RunID, WorkID: receipt.WorkID, + Kind: test.outcomeKind, Detail: test.outcomeReceipt.Worktree, + DetailJSON: core.JSONMarshalString(outcome), CreatedAt: at.Add(2 * time.Second), + }) + } + adapter := requireAgentAdapter(t, &fixtureNativeAgentEngine{snapshot: work.Snapshot{ + Runs: []work.Run{{ID: receipt.RunID, WorkID: receipt.WorkID, ProjectID: receipt.ProjectID, Number: receipt.RunNumber, Status: work.RunCompleted}}, + Events: events, + }}) + + result := adapter.Snapshot(context.Background()) + core.AssertTrue(t, result.OK, result.Error()) + state := result.Value.(agentSnapshot).Work[0] + core.AssertEqual(t, test.wantCount, state.RecoveryCount) + if test.wantCount == 0 { + core.AssertEqual(t, "", state.Recovery.EventID) + } else { + core.AssertEqual(t, first.ID, state.Recovery.EventID) + core.AssertEqual(t, receipt, state.Recovery.Receipt) + } + }) + } +} + +func TestAgentAdapterRecoveryAbandonRequiresReviewedConfirmation(t *testing.T) { + recovery := agentPendingRecovery{EventID: "recovery-event-9", Receipt: agentRecoveryReceipt{ + Kind: "run", ProjectID: "project-1", WorkID: "work-1", RunID: "attempt-9", + RunNumber: 9, WorkspaceRunID: "lineage-root", Branch: "lem/work/work-1/run-9", + Worktree: "/private/workspaces/project-1/runs/lineage-root/worktree", + }} + engine := &fixtureNativeAgentEngine{capabilities: []work.Capability{{Name: "recovery.abandon", Available: true}}} + adapter := requireAgentAdapter(t, engine) + + reviewed := adapter.Review(context.Background(), agentReviewRequest{Feature: agentFeatureRecoveryAbandon, WorkID: recovery.Receipt.WorkID, Recovery: recovery}) + core.AssertTrue(t, reviewed.OK, reviewed.Error()) + review := reviewed.Value.(agentReview) + core.AssertTrue(t, review.ConfirmRequired) + for _, want := range []string{recovery.EventID, recovery.Receipt.RunID, recovery.Receipt.Branch, recovery.Receipt.Worktree} { + core.AssertContains(t, review.Body, want) + } + + unconfirmed := adapter.Run(context.Background(), agentRequest{Feature: agentFeatureRecoveryAbandon, WorkID: recovery.Receipt.WorkID, RunID: recovery.Receipt.RunID, Recovery: recovery, Review: review}) + core.AssertFalse(t, unconfirmed.OK) + core.AssertEqual(t, 0, engine.abandonRecoveryCalls) + + confirmed := adapter.Run(context.Background(), agentRequest{Feature: agentFeatureRecoveryAbandon, WorkID: recovery.Receipt.WorkID, RunID: recovery.Receipt.RunID, Recovery: recovery, Review: review, Confirmed: true}) + core.AssertTrue(t, confirmed.OK, confirmed.Error()) + receipt := confirmed.Value.(agentActionReceipt) + core.AssertEqual(t, agentFeatureRecoveryAbandon, receipt.Feature) + core.AssertEqual(t, recovery.Receipt.RunID, engine.abandonRecoveryRunID) + core.AssertEqual(t, recovery.EventID, engine.abandonRecoveryEventID) + core.AssertEqual(t, 1, engine.abandonRecoveryCalls) +} + +func TestAgentAdapterRecoveryCapabilityRequiresOptionalEngine(t *testing.T) { + base := &fixtureNativeAgentEngine{capabilities: []work.Capability{{Name: "recovery.abandon", Available: true}}} + adapter := requireAgentAdapter(t, &nativeEngineWithoutRecovery{nativeAgentEngine: base}) + for _, capability := range adapter.Capabilities() { + if capability.Feature != agentFeatureRecoveryAbandon { + continue + } + core.AssertFalse(t, capability.Available) + core.AssertContains(t, capability.Reason, "does not support retained recovery cleanup") + return + } + t.Fatal("recovery.abandon capability is missing") +} + +func TestAgentAdapter_SnapshotProjectWithoutRunDoesNotCreateWork(t *testing.T) { + adapter := requireAgentAdapter(t, &fixtureNativeAgentEngine{snapshot: work.Snapshot{Projects: []work.Project{{ID: "project-only", RepositoryName: "private-project"}}}}) + result := adapter.Snapshot(context.Background()) + if !result.OK { + t.Fatalf("Snapshot: %s", result.Error()) + } + snapshot := result.Value.(agentSnapshot) + if len(snapshot.Work) != 0 { + t.Fatalf("project-only snapshot created Work: %#v", snapshot.Work) + } +} + +func TestAgentAdapter_SnapshotKeepsHistoricalQuestionWithoutStaleAttention(t *testing.T) { + at := time.Date(2026, time.July, 18, 15, 0, 0, 0, time.UTC) + adapter := requireAgentAdapter(t, &fixtureNativeAgentEngine{snapshot: work.Snapshot{ + Runs: []work.Run{ + {ID: "run-parent", WorkID: "work-1", Status: work.RunWaiting}, + {ID: "run-child", WorkID: "work-1", Status: work.RunInterrupted}, + }, + Questions: []work.Question{{ID: "question-parent", RunID: "run-parent", Text: "Which target?", CreatedAt: at}}, + }}) + + result := adapter.Snapshot(context.Background()) + if !result.OK { + t.Fatalf("Snapshot: %s", result.Error()) + } + snapshot := result.Value.(agentSnapshot) + if len(snapshot.Work) != 1 || snapshot.Work[0].NativeRunID != "run-child" || snapshot.Work[0].Question != "" || snapshot.Work[0].QuestionID != "" { + t.Fatalf("selected child attention = %#v", snapshot.Work) + } + if len(snapshot.Events) != 1 || snapshot.Events[0].ExternalID != "question-parent" || snapshot.Events[0].RunID != "run-parent" || snapshot.Events[0].WorkID != "work-1" || snapshot.Events[0].Kind != "question" || snapshot.Events[0].Detail != "Which target?" { + t.Fatalf("historical question timeline = %#v", snapshot.Events) + } +} + +func TestAgentAdapter_SnapshotReviewUsesFreshReviewPresentation(t *testing.T) { + review := workspace.ChangeReview{ + WorkID: "work-1", RunID: "run-1", SourceBranch: "main", SourceRevision: "source-123", + AgentBase: "source-123", AgentTip: "agent-456", IntegrationBranch: "lem/integration/run-1", + IntegrationPath: "/private/reviews/run-1", ResultRevision: "result-789", + CommitLog: "agent-456 implement reviewed change", Diff: "diff --git a/a.go b/a.go\n+reviewed", + Validation: []workspace.ValidationResult{{ + Command: workspace.Command{Dir: "/src/project", Executable: "go", Args: []string{"test", "./..."}}, + ExitCode: 0, Output: "ok all packages", Receipt: "receipt-sha256", Passed: true, + }}, + Conflicts: []string{}, + } + engine := &fixtureNativeAgentEngine{ + changeReview: review, + snapshot: work.Snapshot{ + Runs: []work.Run{{ID: review.RunID, WorkID: review.WorkID, Status: work.RunCompleted}}, + Acceptances: []work.Acceptance{{ + ID: "review-1", WorkID: review.WorkID, RunID: review.RunID, + Status: "prepared", ValidationJSON: core.JSONMarshalString(review), + }}, + }, + } + adapter := requireAgentAdapter(t, engine) + freshResult := adapter.Review(context.Background(), agentReviewRequest{Feature: agentFeatureChangesReview, WorkID: review.RunID}) + snapshotResult := adapter.Snapshot(context.Background()) + if !freshResult.OK || !snapshotResult.OK { + t.Fatalf("fresh/snapshot review = %#v / %#v", freshResult, snapshotResult) + } + fresh := freshResult.Value.(agentReview) + decoded := snapshotResult.Value.(agentSnapshot).Work[0].Review + if decoded.Body != fresh.Body || decoded.Warning != fresh.Warning || core.JSONMarshalString(decoded.Payload) != core.JSONMarshalString(review) { + t.Fatalf("snapshot review differs from fresh review:\nfresh=%#v\ndecoded=%#v", fresh, decoded) + } + for _, want := range []string{ + "Source branch: main", "Source revision: source-123", "Agent base: source-123", + "Agent tip: agent-456", "Result revision: result-789", "agent-456 implement reviewed change", + "+reviewed", "PASSED", "go test ./...", "receipt-sha256", "ok all packages", "Conflicts:\nnone", + } { + if !strings.Contains(decoded.Body, want) { + t.Fatalf("snapshot review missing %q:\n%s", want, decoded.Body) + } + } + + noValidation := review + noValidation.Validation = nil + engine.changeReview = noValidation + engine.snapshot.Acceptances[0].ValidationJSON = core.JSONMarshalString(noValidation) + freshResult = adapter.Review(context.Background(), agentReviewRequest{Feature: agentFeatureChangesReview, WorkID: review.RunID}) + snapshotResult = adapter.Snapshot(context.Background()) + fresh = freshResult.Value.(agentReview) + decoded = snapshotResult.Value.(agentSnapshot).Work[0].Review + if decoded.Body != fresh.Body || decoded.Warning != fresh.Warning || !decoded.NeedsAcknowledgement || !strings.Contains(decoded.Body, "No validation command configured") { + t.Fatalf("no-validation snapshot review = %#v, fresh %#v", decoded, fresh) + } +} + +func TestAgentAdapter_ProjectAndDispatchReview_Good(t *testing.T) { + projectReview := orchestrator.ProjectReview{ + Work: work.Item{ID: "work-1", Title: "Ship it", Task: "Implement the slice", Repository: "/src/project"}, + Source: workspace.SourceReview{Path: "/src/project", Root: "/src/project", Branch: "main", Revision: "abc123", IncludedHash: "hash", Included: []string{"go.mod", "main.go"}}, + RepositoryName: "work-1", RequiresGitEnable: true, + } + dispatchReview := orchestrator.DispatchReview{ + Request: work.DispatchRequest{Work: projectReview.Work, Provider: "codex", Model: "gpt-5", ConfirmedSourceRevision: "abc123"}, + Project: work.Project{ID: "work-1", SourcePath: "/src/project", SourceRevision: "abc123", RepositoryName: "work-1"}, + Source: projectReview.Source, WorktreePath: "/private/runs/pending-run/worktree", Warning: "native host access", + } + engine := &fixtureNativeAgentEngine{projectReview: projectReview, registeredProject: dispatchReview.Project, dispatchReview: dispatchReview} + adapter := requireAgentAdapter(t, engine) + request := agentReviewRequest{ + Feature: agentFeatureDispatch, WorkID: "work-1", Provider: "codex", Model: "gpt-5", + Work: agentWorkRequest{ID: "work-1", Title: "Ship it", Task: "Implement the slice", Repository: "/src/project"}, + } + + reviewResult := adapter.Review(context.Background(), request) + if !reviewResult.OK { + t.Fatalf("project Review failed: %s", reviewResult.Error()) + } + review := reviewResult.Value.(agentReview) + if !review.ConfirmRequired || !review.GitConfirmRequired || !strings.Contains(review.Body, "/src/project") || !strings.Contains(review.Warning, "Git") { + t.Fatalf("project review = %#v", review) + } + withoutGit := adapter.Run(context.Background(), agentRequest{Feature: agentFeatureDispatch, Review: review, Confirmed: true}) + if withoutGit.OK || engine.registerCalls != 0 { + t.Fatalf("registration without separate Git confirmation = %#v, calls=%d", withoutGit, engine.registerCalls) + } + + registered := adapter.Run(context.Background(), agentRequest{Feature: agentFeatureDispatch, Review: review, Confirmed: true, EnableGit: true}) + if !registered.OK { + t.Fatalf("register project: %s", registered.Error()) + } + launch := registered.Value.(agentReview) + if !launch.ConfirmRequired || launch.GitConfirmRequired || !strings.Contains(launch.Body, "codex") || !strings.Contains(launch.Body, "/private/runs/pending-run/worktree") || !strings.Contains(launch.Warning, "native host access") { + t.Fatalf("dispatch review = %#v", launch) + } + if engine.registerConfirmed != true || engine.registerCalls != 1 || engine.reviewDispatchCalls != 1 { + t.Fatalf("project/dispatch calls = register %d confirmed %v, review %d", engine.registerCalls, engine.registerConfirmed, engine.reviewDispatchCalls) + } + if core.JSONMarshalString(engine.reviewedProject) != core.JSONMarshalString(projectReview.Work) { + t.Fatalf("ReviewProject item = %#v, want %#v", engine.reviewedProject, projectReview.Work) + } + if core.JSONMarshalString(engine.registeredReview) != core.JSONMarshalString(projectReview) || !engine.registerConfirmed { + t.Fatalf("RegisterProject args = review %#v confirmed %v, want %#v true", engine.registeredReview, engine.registerConfirmed, projectReview) + } + wantDispatchRequest := work.DispatchRequest{Work: projectReview.Work, Provider: "codex", Model: "gpt-5", ConfirmedSourceRevision: dispatchReview.Project.SourceRevision} + if core.JSONMarshalString(engine.reviewedDispatch) != core.JSONMarshalString(wantDispatchRequest) { + t.Fatalf("ReviewDispatch request = %#v, want %#v", engine.reviewedDispatch, wantDispatchRequest) + } + + dispatched := adapter.Run(context.Background(), agentRequest{Feature: agentFeatureDispatch, Review: launch, Confirmed: true}) + if !dispatched.OK || engine.dispatchCalls != 1 { + t.Fatalf("dispatch = %#v, calls=%d", dispatched, engine.dispatchCalls) + } + if core.JSONMarshalString(engine.dispatchedReview) != core.JSONMarshalString(dispatchReview) { + t.Fatalf("Dispatch review = %#v, want %#v", engine.dispatchedReview, dispatchReview) + } +} + +func TestAgentAdapter_Actions_Good(t *testing.T) { + changeReview := workspace.ChangeReview{WorkID: "work-1", RunID: "run-1", Diff: "+change", CommitLog: "abc change", Validation: []workspace.ValidationResult{{Passed: true}}} + engine := &fixtureNativeAgentEngine{changeReview: changeReview} + adapter := requireAgentAdapter(t, engine) + workRequest := agentWorkRequest{ID: "work-1", Title: "Work one", Task: "Task one", Repository: "/src/one"} + + actions := []agentRequest{ + {Feature: agentFeatureCancel, WorkID: "run-1"}, + {Feature: agentFeatureAnswer, WorkID: "run-1", Input: "Use target A"}, + {Feature: agentFeatureQueueStart}, + {Feature: agentFeatureQueueStop}, + {Feature: agentFeatureReject, WorkID: "run-1"}, + } + for _, request := range actions { + if result := adapter.Run(context.Background(), request); !result.OK { + t.Fatalf("Run(%s): %s", request.Feature, result.Error()) + } else if _, ok := result.Value.(agentActionReceipt); !ok { + t.Fatalf("Run(%s) value = %T, want private receipt", request.Feature, result.Value) + } + } + retryReview := adapter.Review(context.Background(), agentReviewRequest{Feature: agentFeatureRetry, WorkID: "run-1", Work: workRequest}) + if !retryReview.OK { + t.Fatalf("retry Review: %s", retryReview.Error()) + } + if retried := adapter.Run(context.Background(), agentRequest{Feature: agentFeatureRetry, Review: retryReview.Value.(agentReview), Confirmed: true}); !retried.OK { + t.Fatalf("confirmed Retry: %s", retried.Error()) + } + resumeReview := adapter.Review(context.Background(), agentReviewRequest{Feature: agentFeatureResume, WorkID: "run-1", Input: "answer-1", Provider: "codex", Model: "gpt-5", Work: workRequest}) + if !resumeReview.OK { + t.Fatalf("resume Review: %s", resumeReview.Error()) + } + if resumed := adapter.Run(context.Background(), agentRequest{Feature: agentFeatureResume, Review: resumeReview.Value.(agentReview), Confirmed: true}); !resumed.OK { + t.Fatalf("confirmed Resume: %s", resumed.Error()) + } + + reviewed := adapter.Review(context.Background(), agentReviewRequest{Feature: agentFeatureChangesReview, WorkID: "run-1"}) + if !reviewed.OK { + t.Fatalf("change Review: %s", reviewed.Error()) + } + review := reviewed.Value.(agentReview) + if !review.ConfirmRequired || !strings.Contains(review.Body, "+change") { + t.Fatalf("change review = %#v", review) + } + accepted := adapter.Run(context.Background(), agentRequest{Feature: agentFeatureAccept, Review: review, Confirmed: true}) + if !accepted.OK { + t.Fatalf("Accept: %s", accepted.Error()) + } + wantCalls := []string{"cancel", "answer", "queue.start", "queue.stop", "reject", "retry.review", "retry", "resume.review", "resume", "changes.review", "accept"} + if core.JSONMarshalString(engine.calls) != core.JSONMarshalString(wantCalls) { + t.Fatalf("mapped action order = %#v, want %#v", engine.calls, wantCalls) + } + if engine.cancelRunID != "run-1" || engine.answerRunID != "run-1" || engine.answerText != "Use target A" { + t.Fatalf("cancel/answer args = %q / %q %q", engine.cancelRunID, engine.answerRunID, engine.answerText) + } + if core.JSONMarshalString(engine.retryItem) != core.JSONMarshalString(nativeWorkItem(workRequest, workRequest.ID, "")) || engine.retryParent != "run-1" { + t.Fatalf("Retry args = item %#v parent %q", engine.retryItem, engine.retryParent) + } + wantResume := work.ResumeRequest{Work: nativeWorkItem(workRequest, workRequest.ID, ""), ParentRunID: "run-1", AnswerID: "answer-1", Provider: "codex", Model: "gpt-5"} + if core.JSONMarshalString(engine.resumeRequest) != core.JSONMarshalString(wantResume) { + t.Fatalf("Resume request = %#v, want %#v", engine.resumeRequest, wantResume) + } + if engine.reviewChangesRunID != "run-1" || engine.rejectRunID != "run-1" { + t.Fatalf("review/reject run IDs = %q / %q", engine.reviewChangesRunID, engine.rejectRunID) + } + wantAccept := workspace.AcceptRequest{Review: changeReview, Confirmed: true} + if core.JSONMarshalString(engine.acceptRequest) != core.JSONMarshalString(wantAccept) { + t.Fatalf("Accept request = %#v, want %#v", engine.acceptRequest, wantAccept) + } +} + +func TestAgentAdapterChildActionsRequireReviewedConfirmation(t *testing.T) { + engine := &fixtureNativeAgentEngine{} + adapter := requireAgentAdapter(t, engine) + request := agentReviewRequest{ + Feature: agentFeatureRetry, WorkID: "run-parent", + Work: agentWorkRequest{ID: "work-1", Title: "Work one", Task: "Task one", Repository: "/src/one"}, + } + reviewed := adapter.Review(context.Background(), request) + core.AssertTrue(t, reviewed.OK, reviewed.Error()) + review := reviewed.Value.(agentReview) + core.AssertTrue(t, review.ConfirmRequired) + core.AssertEqual(t, agentFeatureRetry, review.Feature) + core.AssertFalse(t, adapter.Run(context.Background(), agentRequest{Feature: agentFeatureRetry, Review: review}).OK) + confirmed := adapter.Run(context.Background(), agentRequest{Feature: agentFeatureRetry, Review: review, Confirmed: true}) + core.AssertTrue(t, confirmed.OK, confirmed.Error()) +} + +func TestAgentAdapter_Close_Ugly(t *testing.T) { + engine := &fixtureNativeAgentEngine{closeFailures: 1} + adapter := requireAgentAdapter(t, engine) + if result := adapter.Close(); result.OK { + t.Fatal("first Close unexpectedly ignored injected ownership cleanup failure") + } + if result := adapter.Close(); !result.OK { + t.Fatalf("retried Close: %s", result.Error()) + } + if result := adapter.Close(); !result.OK { + t.Fatalf("idempotent Close: %s", result.Error()) + } + if engine.closeCalls != 2 { + t.Fatalf("engine Close calls = %d, want 2", engine.closeCalls) + } +} + +func TestAgentAdapter_CloseConcurrentCapabilities_Ugly(t *testing.T) { + engine := &fixtureNativeAgentEngine{capabilities: nativeFixtureCapabilities()} + adapter := requireAgentAdapter(t, engine) + var workers sync.WaitGroup + start := make(chan struct{}) + for index := 0; index < 32; index++ { + workers.Add(1) + go func() { + defer workers.Done() + <-start + for iteration := 0; iteration < 200; iteration++ { + if len(adapter.Capabilities()) == 0 { + t.Error("Capabilities returned an empty catalog") + return + } + } + }() + } + workers.Add(1) + go func() { + defer workers.Done() + <-start + if result := adapter.Close(); !result.OK { + t.Errorf("Close: %s", result.Error()) + } + }() + close(start) + workers.Wait() + if engine.closeCalls != 1 { + t.Fatalf("engine Close calls = %d, want 1", engine.closeCalls) + } +} + +func TestAgentAdapter_DurableInterruptedResumeAfterRestartReceipt(t *testing.T) { + root, repository, agentStore := openTestAgentStore(t) + initialRepositoryClosed := false + t.Cleanup(func() { + if initialRepositoryClosed { + return + } + if result := repository.Close(); !result.OK { + t.Errorf("cleanup initial repository/store: %v", result.Value) + } + }) + at := time.Date(2026, time.July, 18, 18, 0, 0, 0, time.UTC) + task := "Resume the durable interrupted run" + project := testDurableAgentProject(t, at) + parent := testAgentRun("run-interrupted", work.RunInterrupted, at) + parent.ProcessID = 0 + parent.SourceRevision = project.SourceRevision + parent.DurableRevision = project.SourceRevision + parent.ExecutionRevision = project.SourceRevision + parent.Worktree = t.TempDir() + event := work.Event{ + ID: "event-interrupted", RunID: parent.ID, WorkID: parent.WorkID, + Kind: "queued", Title: "queued", Detail: task, CreatedAt: at, + } + logs := []work.LogChunk{ + {RunID: parent.ID, Sequence: 1, Stream: "stdout", Text: "durable-one", CreatedAt: at.Add(time.Second)}, + {RunID: parent.ID, Sequence: 2, Stream: "stderr", Text: "durable-two", CreatedAt: at.Add(2 * time.Second)}, + } + initial := newApp("", 0, 64) + if result := initial.attachWork(repository, requireAgentAdapter(t, &fixtureNativeAgentEngine{})); !result.OK { + t.Fatalf("attach initial Work: %v", result.Value) + } + initial.work.ids = sequenceIDs(parent.WorkID) + if result := initial.work.CreateWork("Durable restart", task, project.SourcePath); !result.OK { + t.Fatalf("create durable Work: %v", result.Value) + } + if result := agentStore.Commit(orchestrator.Commit{ + Project: &project, Run: &parent, CreateRun: true, Event: &event, Logs: logs, + }); !result.OK { + t.Fatalf("persist interrupted parent: %v", result.Value) + } + if result := repository.Close(); !result.OK { + t.Fatalf("close initial repository/store: %v", result.Value) + } + initialRepositoryClosed = true + + reopenedResult := openDuckRepository(root + "/lem.duckdb") + if !reopenedResult.OK { + t.Fatalf("reopen durable repository: %v", reopenedResult.Value) + } + reopened := reopenedResult.Value.(workspaceRepository) + t.Cleanup(func() { + if result := reopened.Close(); !result.OK { + t.Errorf("cleanup reopened repository/store: %v", result.Value) + } + }) + reopenedStore := requireAgentValue[*duckAgentStore](t, "newDuckAgentStore reopened", newDuckAgentStore(reopened)) + reopenedSnapshot := requireAgentValue[work.Snapshot](t, "reopened Snapshot", reopenedStore.Snapshot(parent.WorkID)) + if len(reopenedSnapshot.Runs) != 1 || reopenedSnapshot.Runs[0].ID != parent.ID || reopenedSnapshot.Runs[0].Status != work.RunInterrupted { + t.Fatalf("reopened parent = %#v", reopenedSnapshot.Runs) + } + if len(reopenedSnapshot.Logs) != 2 || reopenedSnapshot.Logs[0].Text != "durable-one" || reopenedSnapshot.Logs[1].Text != "durable-two" || reopenedSnapshot.Logs[0].Sequence >= reopenedSnapshot.Logs[1].Sequence { + t.Fatalf("reopened ordered logs = %#v", reopenedSnapshot.Logs) + } + storedParent := requireAgentValue[work.Run](t, "parent before UI Resume", reopenedStore.Run(parent.ID)) + engine := newDurableResumeReceiptEngine(t, reopenedStore, at.Add(time.Hour)) + if _, available := engine.(nativeChildReviewEngine); !available { + _ = engine.Close() + t.Skip("standalone inference dependency predates reviewed child continuation APIs") + } + var adapter agentProvider + t.Cleanup(func() { + var result core.Result + if adapter != nil { + result = adapter.Close() + } else { + result = engine.Close() + } + if !result.OK { + t.Errorf("cleanup restarted native adapter/orchestrator: %v", result.Value) + } + }) + adapter = requireAgentAdapter(t, engine) + restarted := newApp("", 0, 64) + if result := restarted.attachWork(reopened, adapter); !result.OK { + t.Fatalf("attach restarted Work: %v", result.Value) + } + driveCorrectiveCommand(t, &restarted, restarted.requestAgentSnapshot()) + selected, ok := restarted.work.Selected() + if !ok || selected.ID != parent.WorkID || selected.Status != string(work.RunInterrupted) { + t.Fatalf("restarted selected Work = %#v, selected=%v", selected, ok) + } + if result := restarted.queueAgentAction(agentFeatureResume); !result.OK { + t.Fatalf("queue interrupted UI Resume: %v", result.Value) + } + driveCorrectiveCommand(t, &restarted, restarted.takeAgentCommand()) + if restarted.activeOverlay != overlayLaunchReview { + t.Fatalf("interrupted Resume did not present launch review: overlay=%d err=%q", restarted.activeOverlay, restarted.errText) + } + beforeConfirm := requireAgentValue[work.Snapshot](t, "Snapshot before explicit Resume confirmation", reopenedStore.Snapshot(parent.WorkID)) + if len(beforeConfirm.Runs) != 1 { + t.Fatalf("interrupted Resume created child before confirmation: %#v", beforeConfirm.Runs) + } + model, command := restarted.Update(tea.KeyMsg{Type: tea.KeyEnter}) + restarted = model.(app) + next := driveCorrectiveCommand(t, &restarted, command) + if next != nil { + driveCorrectiveCommand(t, &restarted, next) + } + + resumedSnapshot := requireAgentValue[work.Snapshot](t, "Snapshot after UI Resume", reopenedStore.Snapshot(parent.WorkID)) + var child work.Run + for _, candidate := range resumedSnapshot.Runs { + if candidate.ParentRunID == parent.ID { + child = candidate + } + } + if child.ID == "" || child.ID == parent.ID || child.Status != work.RunQueued || child.Attempt != parent.Attempt+1 || child.Branch != parent.Branch || child.Worktree != parent.Worktree { + t.Fatalf("durable resumed child = %#v", child) + } + afterParent := requireAgentValue[work.Run](t, "parent after UI Resume", reopenedStore.Run(parent.ID)) + if core.JSONMarshalString(afterParent) != core.JSONMarshalString(storedParent) { + t.Fatalf("UI Resume mutated durable parent:\nbefore=%#v\nafter=%#v", storedParent, afterParent) + } + if len(resumedSnapshot.Logs) != 2 || resumedSnapshot.Logs[0] != reopenedSnapshot.Logs[0] || resumedSnapshot.Logs[1] != reopenedSnapshot.Logs[1] { + t.Fatalf("UI Resume changed durable parent logs: %#v", resumedSnapshot.Logs) + } +} + +func TestAgentAdapterDurableAnsweredWaitingResumeAfterRestartReceipt(t *testing.T) { + root, repository, agentStore := openTestAgentStore(t) + initialRepositoryClosed := false + t.Cleanup(func() { + if !initialRepositoryClosed { + _ = repository.Close() + } + }) + at := time.Date(2026, time.July, 19, 11, 0, 0, 0, time.UTC) + task := "Resume the durable answered waiting run" + project := testDurableAgentProject(t, at) + parent := testAgentRun("run-answered-waiting", work.RunWaiting, at) + parent.ProcessID = 0 + parent.SourceRevision = project.SourceRevision + parent.DurableRevision = project.SourceRevision + parent.ExecutionRevision = project.SourceRevision + parent.Worktree = t.TempDir() + question := work.Question{ID: "question-reopened", RunID: parent.ID, Text: "Which target?", CreatedAt: at.Add(time.Second)} + answer := work.Answer{ + ID: "answer-reopened", QuestionID: question.ID, ResumeRunID: "resume-reopened", + Text: "Use target A", CreatedAt: at.Add(2 * time.Second), + } + event := work.Event{ + ID: "event-answered-waiting", RunID: parent.ID, WorkID: parent.WorkID, + Kind: "queued", Title: "queued", Detail: task, CreatedAt: at, + } + initial := newApp("", 0, 64) + if result := initial.attachWork(repository, requireAgentAdapter(t, &fixtureNativeAgentEngine{})); !result.OK { + t.Fatalf("attach initial answered Work: %s", result.Error()) + } + initial.work.ids = sequenceIDs(parent.WorkID) + if result := initial.work.CreateWork("Answered restart", task, project.SourcePath); !result.OK { + t.Fatalf("create durable answered Work: %s", result.Error()) + } + if result := agentStore.Commit(orchestrator.Commit{ + Project: &project, Run: &parent, CreateRun: true, Event: &event, Question: &question, Answer: &answer, + }); !result.OK { + t.Fatalf("persist answered waiting parent: %s", result.Error()) + } + if result := repository.Close(); !result.OK { + t.Fatalf("close answered repository/store: %s", result.Error()) + } + initialRepositoryClosed = true + + reopenedResult := openDuckRepository(core.PathJoin(root, "lem.duckdb")) + if !reopenedResult.OK { + t.Fatalf("reopen answered repository: %s", reopenedResult.Error()) + } + reopened := reopenedResult.Value.(workspaceRepository) + t.Cleanup(func() { _ = reopened.Close() }) + reopenedStore := requireAgentValue[*duckAgentStore](t, "newDuckAgentStore answered reopened", newDuckAgentStore(reopened)) + engine := newDurableResumeReceiptEngine(t, reopenedStore, at.Add(time.Hour)) + if _, available := engine.(nativeChildReviewEngine); !available { + _ = engine.Close() + t.Skip("standalone inference dependency predates reviewed child continuation APIs") + } + adapter := requireAgentAdapter(t, engine) + t.Cleanup(func() { _ = adapter.Close() }) + restarted := newApp("", 0, 64) + if result := restarted.attachWork(reopened, adapter); !result.OK { + t.Fatalf("attach restarted answered Work: %s", result.Error()) + } + driveCorrectiveCommand(t, &restarted, restarted.requestAgentSnapshot()) + selected, ok := restarted.work.Selected() + if !ok || selected.ID != parent.WorkID || selected.Status != string(work.RunWaiting) { + t.Fatalf("reopened answered selection = %#v selected=%v", selected, ok) + } + state := restarted.work.AgentState(selected) + if state.NativeRunID != parent.ID || state.QuestionID != question.ID || state.AnswerID != answer.ID || state.ResumeRunID != answer.ResumeRunID { + t.Fatalf("reopened durable answer projection = %#v", state) + } + if result := restarted.queueAgentAction(agentFeatureResume); !result.OK { + t.Fatalf("queue answered Resume: %s", result.Error()) + } + driveCorrectiveCommand(t, &restarted, restarted.takeAgentCommand()) + if restarted.activeOverlay != overlayLaunchReview { + t.Fatalf("answered Resume did not present launch review: overlay=%d err=%q", restarted.activeOverlay, restarted.errText) + } + beforeConfirm := requireAgentValue[work.Snapshot](t, "answered Snapshot before confirmation", reopenedStore.Snapshot(parent.WorkID)) + if len(beforeConfirm.Runs) != 1 { + t.Fatalf("answered Resume created child before confirmation: %#v", beforeConfirm.Runs) + } + model, confirm := restarted.Update(tea.KeyMsg{Type: tea.KeyEnter}) + restarted = model.(app) + next := driveCorrectiveCommand(t, &restarted, confirm) + if next != nil { + driveCorrectiveCommand(t, &restarted, next) + } + after := requireAgentValue[work.Snapshot](t, "answered Snapshot after confirmation", reopenedStore.Snapshot(parent.WorkID)) + var child work.Run + for _, candidate := range after.Runs { + if candidate.ParentRunID == parent.ID { + child = candidate + } + } + if child.ID != answer.ResumeRunID || child.Status != work.RunQueued || child.Attempt != parent.Attempt+1 || child.Branch != parent.Branch || child.Worktree != parent.Worktree { + t.Fatalf("durable answered resumed child = %#v", child) + } +} + +func TestAgentAdapterDurableCleanupRecoveryAfterTransientPersistenceFailureReopensActionable(t *testing.T) { + if contract := linkedAgentRuntimeContract(context.Background()); !contract.OK { + t.Skip("standalone inference dependency predates durable cleanup recovery persistence") + } + root, repository, agentStore := openTestAgentStore(t) + initialRepositoryClosed := false + t.Cleanup(func() { + if !initialRepositoryClosed { + _ = repository.Close() + } + }) + at := time.Date(2026, time.July, 19, 14, 0, 0, 0, time.UTC) + workspaceRoot := core.PathJoin(root, "native-workspaces") + core.AssertTrue(t, core.MkdirAll(workspaceRoot, 0o700).OK) + files, filesErr := coreio.NewSandboxed(workspaceRoot) + if filesErr != nil { + t.Fatalf("construct cleanup recovery workspace medium: %s", filesErr) + } + runner := &agentAdapterRecoveryGitRunner{} + server := &agentAdapterRecoveryGitServer{root: core.PathJoin(root, "private-git")} + managerIDs := &agentAdapterRecoveryIDs{prefix: "review-"} + managerResult := workspace.NewManager(workspace.ManagerOptions{ + Root: workspaceRoot, Files: files, Git: runner, Server: server, + IDs: managerIDs.New, Now: func() time.Time { return at }, + }) + core.AssertTrue(t, managerResult.OK, managerResult.Error()) + manager := managerResult.Value.(*workspace.Manager) + sourceProject := testDurableAgentProject(t, at) + sourceReview := manager.ReviewSource(context.Background(), sourceProject.SourcePath) + core.AssertTrue(t, sourceReview.OK, sourceReview.Error()) + registered := manager.Register(context.Background(), workspace.RegisterRequest{ + ProjectID: "project-agent", SourcePath: sourceProject.SourcePath, RepositoryName: "project-agent", + Confirmed: true, ExpectedIncludedHash: sourceReview.Value.(workspace.SourceReview).IncludedHash, + }) + core.AssertTrue(t, registered.OK, registered.Error()) + project := registered.Value.(work.Project) + run := work.Run{ + ID: "cleanup-recovery-run", WorkID: "work-agent", ProjectID: project.ID, Provider: "codex", Model: "gpt-5", + SourceRevision: project.SourceRevision, Status: work.RunPreparing, Number: 1, Attempt: 1, + QueuedAt: at, UpdatedAt: at, + } + preparedResult := manager.PrepareRun(context.Background(), project, run) + core.AssertTrue(t, preparedResult.OK, preparedResult.Error()) + prepared := preparedResult.Value.(workspace.RunWorkspace) + core.AssertTrue(t, core.WriteFile(core.PathJoin(prepared.Path, "agent.txt"), []byte("durable cleanup recovery\n"), 0o600).OK) + capturedResult := manager.CaptureRun(context.Background(), prepared) + core.AssertTrue(t, capturedResult.OK, capturedResult.Error()) + captured := capturedResult.Value.(workspace.Capture) + core.AssertTrue(t, captured.Pushed) + run.Status = work.RunCompleted + run.Branch = prepared.Branch + run.Worktree = prepared.Path + run.DurableRevision = captured.DurableRevision + run.ExecutionRevision = captured.Revision + run.FinishedAt = at + run.UpdatedAt = at + if committed := agentStore.Commit(orchestrator.Commit{Project: &project, Run: &run, CreateRun: true}); !committed.OK { + t.Fatalf("persist completed cleanup recovery run: %s", committed.Error()) + } + + transientStore := &agentAdapterTransientCleanupStore{Store: agentStore} + engine := newAgentAdapterRecoveryEngine(t, transientStore, manager, server, at, "persist-") + runner.setCleanupFailure(true) + reviewed := engine.ReviewChanges(context.Background(), run.ID) + core.AssertFalse(t, reviewed.OK) + core.AssertEqual(t, 2, len(transientStore.eventIDs())) + if len(transientStore.eventIDs()) == 2 { + core.AssertEqual(t, transientStore.eventIDs()[0], transientStore.eventIDs()[1]) + } + initialSnapshot := requireAgentValue[work.Snapshot](t, "transient cleanup Snapshot", agentStore.Snapshot(run.WorkID)) + retainedEvents := make([]work.Event, 0, 1) + for _, event := range initialSnapshot.Events { + if event.Kind == "review_cleanup_retained" { + retainedEvents = append(retainedEvents, event) + } + } + core.AssertEqual(t, 1, len(retainedEvents)) + if len(retainedEvents) == 0 { + _ = engine.Close() + return + } + retained := retainedEvents[0] + var receipt agentRecoveryReceipt + decoded := core.JSONUnmarshalString(retained.DetailJSON, &receipt) + core.AssertTrue(t, decoded.OK, decoded.Error()) + core.AssertEqual(t, transientStore.eventIDs()[0], retained.ID) + core.AssertTrue(t, core.Stat(receipt.Worktree).OK) + core.AssertTrue(t, engine.Close().OK) + if closed := repository.Close(); !closed.OK { + t.Fatalf("close transient cleanup repository: %s", closed.Error()) + } + initialRepositoryClosed = true + + reopenedResult := openDuckRepository(core.PathJoin(root, "lem.duckdb")) + core.AssertTrue(t, reopenedResult.OK, reopenedResult.Error()) + reopened := reopenedResult.Value.(workspaceRepository) + reopenedClosed := false + t.Cleanup(func() { + if !reopenedClosed { + _ = reopened.Close() + } + }) + reopenedStore := requireAgentValue[*duckAgentStore](t, "newDuckAgentStore reopened cleanup action", newDuckAgentStore(reopened)) + restartedFiles, restartedFilesErr := coreio.NewSandboxed(workspaceRoot) + if restartedFilesErr != nil { + t.Fatalf("construct restarted cleanup workspace medium: %s", restartedFilesErr) + } + restartedRunner := &agentAdapterRecoveryGitRunner{} + restartedServer := &agentAdapterRecoveryGitServer{root: server.root} + restartedManagerIDs := &agentAdapterRecoveryIDs{prefix: "restart-review-"} + restartedManagerResult := workspace.NewManager(workspace.ManagerOptions{ + Root: workspaceRoot, Files: restartedFiles, Git: restartedRunner, Server: restartedServer, + IDs: restartedManagerIDs.New, Now: func() time.Time { return at.Add(time.Hour) }, + }) + core.AssertTrue(t, restartedManagerResult.OK, restartedManagerResult.Error()) + restartedManager := restartedManagerResult.Value.(*workspace.Manager) + restartedEngine := newAgentAdapterRecoveryEngine(t, reopenedStore, restartedManager, restartedServer, at.Add(time.Hour), "restart-") + adapter := requireAgentAdapter(t, restartedEngine) + adapterClosed := false + t.Cleanup(func() { + if !adapterClosed { + _ = adapter.Close() + } + }) + core.AssertEqual(t, 0, agentAdapterManagerRecoveryCount(t, restartedManager, run.ID)) + restartedManagerIDs.mu.Lock() + managerIDCountBeforeGuard := restartedManagerIDs.next + restartedManagerIDs.mu.Unlock() + guardedReview := restartedEngine.ReviewChanges(context.Background(), run.ID) + core.AssertFalse(t, guardedReview.OK) + core.AssertContains(t, guardedReview.Error(), "retained review cleanup recovery") + core.AssertContains(t, guardedReview.Error(), receipt.Worktree) + restartedManagerIDs.mu.Lock() + managerIDCountAfterGuard := restartedManagerIDs.next + restartedManagerIDs.mu.Unlock() + core.AssertEqual(t, managerIDCountBeforeGuard, managerIDCountAfterGuard) + core.AssertTrue(t, core.Stat(receipt.Worktree).OK) + core.AssertEqual(t, 0, agentAdapterManagerRecoveryCount(t, restartedManager, run.ID)) + + reopenedSnapshot := adapter.Snapshot(context.Background()) + core.AssertTrue(t, reopenedSnapshot.OK, reopenedSnapshot.Error()) + mapped := reopenedSnapshot.Value.(agentSnapshot) + core.AssertEqual(t, 1, len(mapped.Work)) + core.AssertEqual(t, 1, mapped.Work[0].RecoveryCount) + core.AssertEqual(t, retained.ID, mapped.Work[0].Recovery.EventID) + recovery := mapped.Work[0].Recovery + reviewResult := adapter.Review(context.Background(), agentReviewRequest{ + Feature: agentFeatureRecoveryAbandon, WorkID: recovery.Receipt.WorkID, Recovery: recovery, + }) + core.AssertTrue(t, reviewResult.OK, reviewResult.Error()) + review := reviewResult.Value.(agentReview) + runResult := adapter.Run(context.Background(), agentRequest{ + Feature: agentFeatureRecoveryAbandon, WorkID: recovery.Receipt.WorkID, RunID: recovery.Receipt.RunID, + Recovery: recovery, Review: review, Confirmed: true, + }) + core.AssertTrue(t, runResult.OK, runResult.Error()) + refreshed := adapter.Snapshot(context.Background()) + core.AssertTrue(t, refreshed.OK, refreshed.Error()) + core.AssertEqual(t, 0, refreshed.Value.(agentSnapshot).Work[0].RecoveryCount) + core.AssertFalse(t, core.Stat(receipt.Worktree).OK) + branches := agentAdapterGit(t, workspaceRoot, "--git-dir", project.ClonePath, "branch", "--list", receipt.Branch) + core.AssertEqual(t, "", core.Trim(branches)) + core.AssertTrue(t, adapter.Close().OK) + adapterClosed = true + if closed := reopened.Close(); !closed.OK { + t.Fatalf("close reopened cleanup repository: %s", closed.Error()) + } + reopenedClosed = true +} + +type agentAdapterTransientCleanupStore struct { + orchestrator.Store + mu sync.Mutex + failed bool + attempts []string +} + +func (store *agentAdapterTransientCleanupStore) Commit(commit orchestrator.Commit) core.Result { + if commit.Event == nil || (commit.Event.Kind != "workspace_cleanup_retained" && commit.Event.Kind != "review_cleanup_retained") { + return store.Store.Commit(commit) + } + store.mu.Lock() + store.attempts = append(store.attempts, commit.Event.ID) + if !store.failed { + store.failed = true + store.mu.Unlock() + return core.Fail(core.NewError("injected transient cleanup persistence failure")) + } + store.mu.Unlock() + return store.Store.Commit(commit) +} + +func (store *agentAdapterTransientCleanupStore) eventIDs() []string { + store.mu.Lock() + defer store.mu.Unlock() + return append([]string(nil), store.attempts...) +} + +type agentAdapterRecoveryGitRunner struct { + mu sync.Mutex + failCleanup bool +} + +func (runner *agentAdapterRecoveryGitRunner) setCleanupFailure(fail bool) { + runner.mu.Lock() + runner.failCleanup = fail + runner.mu.Unlock() +} + +func (runner *agentAdapterRecoveryGitRunner) Run(ctx context.Context, command workspace.Command) core.Result { + runner.mu.Lock() + fail := runner.failCleanup + runner.mu.Unlock() + if fail { + hasWorktree := agentAdapterCommandHasArgument(command, "worktree") + if agentAdapterCommandHasArgument(command, "log") || hasWorktree && + (agentAdapterCommandHasArgument(command, "remove") || agentAdapterCommandHasArgument(command, "list")) { + return core.Fail(core.NewError("injected retained cleanup Git failure")) + } + } + return (agentAdapterDirectGitRunner{}).Run(ctx, command) +} + +func agentAdapterCommandHasArgument(command workspace.Command, expected string) bool { + for _, argument := range command.Args { + if argument == expected { + return true + } + } + return false +} + +type agentAdapterRecoveryGitServer struct{ root string } + +func (server *agentAdapterRecoveryGitServer) Start(context.Context) core.Result { + return core.MkdirAll(server.root, 0o700) +} + +func (server *agentAdapterRecoveryGitServer) EnsureRepository(ctx context.Context, name string) core.Result { + if started := server.Start(ctx); !started.OK { + return started + } + path := core.PathJoin(server.root, core.Concat(name, ".git")) + if !core.Stat(path).OK { + created := (agentAdapterDirectGitRunner{}).Run(ctx, workspace.Command{ + Executable: "git", Args: []string{"init", "--bare", "--initial-branch=main", path}, + Environment: []string{"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_NOSYSTEM=1", "LC_ALL=C"}, + }) + if !created.OK { + return created + } + } + return core.Ok(gitserver.Repository{Name: name, CloneURL: path}) +} + +func (server *agentAdapterRecoveryGitServer) Health(context.Context) core.Result { + return core.Ok(gitserver.Health{Running: true, Address: server.root}) +} + +func (*agentAdapterRecoveryGitServer) Close() core.Result { return core.Ok(nil) } + +type agentAdapterRecoveryIDs struct { + mu sync.Mutex + prefix string + next int +} + +func agentAdapterManagerRecoveryCount(t *testing.T, manager *workspace.Manager, runID string) int { + t.Helper() + method := reflect.ValueOf(manager).MethodByName("Recovery") + if !method.IsValid() { + t.Fatal("linked workspace Manager does not expose Recovery") + } + values := method.Call([]reflect.Value{reflect.ValueOf(runID)}) + if len(values) != 1 { + t.Fatalf("workspace Manager Recovery returned %d values", len(values)) + } + result, ok := values[0].Interface().(core.Result) + if !ok { + t.Fatalf("workspace Manager Recovery returned %T", values[0].Interface()) + } + if !result.OK { + t.Fatalf("workspace Manager Recovery failed: %s", result.Error()) + } + recoveries := reflect.ValueOf(result.Value) + if !recoveries.IsValid() || recoveries.Kind() != reflect.Slice { + t.Fatalf("workspace Manager Recovery value has type %T", result.Value) + } + return recoveries.Len() +} + +func (ids *agentAdapterRecoveryIDs) New() string { + ids.mu.Lock() + defer ids.mu.Unlock() + ids.next++ + return core.Sprintf("%s%d", ids.prefix, ids.next) +} + +func newAgentAdapterRecoveryEngine(t *testing.T, store orchestrator.Store, manager *workspace.Manager, server gitserver.Service, at time.Time, idPrefix string) *orchestrator.Orchestrator { + t.Helper() + registryResult := provider.NewRegistry(&fixtureNativeProvider{name: "codex", available: true}) + core.AssertTrue(t, registryResult.OK, registryResult.Error()) + queueResult := queue.NewController(queue.Policy{ + Version: 1, + Dispatch: queue.DispatchConfig{DefaultAgent: "codex", GlobalConcurrency: 1, TimeoutMinutes: 1}, + Concurrency: map[string]queue.ConcurrencyLimit{"codex": {Total: 1}}, + Rates: map[string]queue.RateConfig{}, + Providers: map[string]queue.NativeConfig{"codex": {Executable: "codex"}}, + }, work.QueueState{ID: "default", Status: work.QueueFrozen}, nil) + core.AssertTrue(t, queueResult.OK, queueResult.Error()) + engineResult := orchestrator.New(orchestrator.Options{ + Store: store, GitServer: server, Workspaces: manager, + Providers: registryResult.Value.(*provider.Registry), Queue: queueResult.Value.(*queue.Controller), + Launcher: &fixtureAgentLauncher{}, Clock: agentClock{now: func() time.Time { return at }}, + IDs: &agentAdapterRecoveryIDs{prefix: idPrefix}, + }) + core.AssertTrue(t, engineResult.OK, engineResult.Error()) + return engineResult.Value.(*orchestrator.Orchestrator) +} + +func newDurableResumeReceiptEngine(t *testing.T, store orchestrator.Store, at time.Time) nativeAgentEngine { + t.Helper() + workspaceRoot := t.TempDir() + files, filesErr := coreio.NewSandboxed(workspaceRoot) + if filesErr != nil { + t.Fatalf("construct durable receipt workspace medium: %v", filesErr) + } + managerResult := workspace.NewManager(workspace.ManagerOptions{ + Root: workspaceRoot, Files: files, Git: agentAdapterDirectGitRunner{}, Server: &fixtureGitServer{}, + IDs: func() string { return "durable-review" }, Now: func() time.Time { return at }, + }) + if !managerResult.OK { + t.Fatalf("construct durable receipt workspace manager: %s", managerResult.Error()) + } + registryResult := provider.NewRegistry(&fixtureNativeProvider{name: "codex", available: true}) + if !registryResult.OK { + t.Fatalf("construct durable receipt provider registry: %v", registryResult.Value) + } + policy := queue.Policy{ + Version: 1, + Dispatch: queue.DispatchConfig{ + DefaultAgent: "codex", GlobalConcurrency: 1, TimeoutMinutes: 1, + }, + Concurrency: map[string]queue.ConcurrencyLimit{"codex": {Total: 1}}, + Rates: map[string]queue.RateConfig{}, + Providers: map[string]queue.NativeConfig{"codex": {Executable: "codex"}}, + } + queueResult := queue.NewController(policy, work.QueueState{ID: "default", Status: work.QueueFrozen}, nil) + if !queueResult.OK { + t.Fatalf("construct durable receipt queue: %v", queueResult.Value) + } + engineResult := orchestrator.New(orchestrator.Options{ + Store: store, GitServer: &fixtureGitServer{}, Workspaces: managerResult.Value.(*workspace.Manager), + Providers: registryResult.Value.(*provider.Registry), Queue: queueResult.Value.(*queue.Controller), + Launcher: &fixtureAgentLauncher{}, Clock: agentClock{now: func() time.Time { return at }}, + IDs: &fixtureAgentIdentifiers{}, + }) + if !engineResult.OK { + t.Fatalf("construct durable receipt orchestrator: %v", engineResult.Value) + } + return engineResult.Value.(nativeAgentEngine) +} + +func testDurableAgentProject(t *testing.T, at time.Time) work.Project { + t.Helper() + source := t.TempDir() + canonical := core.PathEvalSymlinks(source) + if !canonical.OK { + t.Fatalf("canonicalize durable review source: %s", canonical.Error()) + } + source = canonical.Value.(string) + agentAdapterGit(t, source, "init", "-b", "main") + agentAdapterGit(t, source, "config", "user.name", "LEM Test") + agentAdapterGit(t, source, "config", "user.email", "lem@example.invalid") + if result := core.WriteFile(core.PathJoin(source, "README.md"), []byte("durable review source\n"), 0o600); !result.OK { + t.Fatalf("write durable review source: %s", result.Error()) + } + agentAdapterGit(t, source, "add", "README.md") + agentAdapterGit(t, source, "commit", "-m", "durable review source") + revision := core.Trim(agentAdapterGit(t, source, "rev-parse", "HEAD")) + project := testAgentProject(at) + project.SourcePath = source + project.RepositoryRoot = source + project.SourceBranch = "main" + project.SourceRevision = revision + return project +} + +func agentAdapterGit(t *testing.T, directory string, arguments ...string) string { + t.Helper() + result := (agentAdapterDirectGitRunner{}).Run(context.Background(), workspace.Command{ + Dir: directory, Executable: "git", Args: arguments, + Environment: []string{"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_NOSYSTEM=1", "LC_ALL=C"}, + }) + if !result.OK { + t.Fatalf("git %s: %s", core.Join(" ", arguments...), result.Error()) + } + return result.Value.(string) +} + +type agentAdapterDirectGitRunner struct{} + +func (agentAdapterDirectGitRunner) Run(ctx context.Context, command workspace.Command) core.Result { + stdout := core.NewBuffer() + stderr := core.NewBuffer() + result := commandexec.Command(ctx, command.Executable, command.Args...). + WithDir(command.Dir). + WithEnv(command.Environment). + WithStdout(stdout). + WithStderr(stderr). + Run() + output := core.Concat(stdout.String(), stderr.String()) + if !result.OK { + return core.Fail(core.E("test.agentAdapterDirectGitRunner", output, result.Err())) + } + return core.Ok(output) +} + +func requireAgentAdapter(t *testing.T, engine nativeAgentEngine) agentProvider { + t.Helper() + result := newAgentAdapter(engine) + if !result.OK { + t.Fatalf("newAgentAdapter: %s", result.Error()) + } + return result.Value.(agentProvider) +} + +type nativeEngineWithoutRecovery struct{ nativeAgentEngine } + +type fixtureNativeAgentEngine struct { + capabilities []work.Capability + snapshot work.Snapshot + projectReview orchestrator.ProjectReview + registeredProject work.Project + dispatchReview orchestrator.DispatchReview + changeReview workspace.ChangeReview + snapshotWorkID string + reviewedProject work.Item + registeredReview orchestrator.ProjectReview + registerProject func(context.Context, orchestrator.ProjectReview, bool) core.Result + reviewedDispatch work.DispatchRequest + dispatchedReview orchestrator.DispatchReview + cancelRunID string + answerRunID string + answerText string + retryItem work.Item + retryParent string + resumeRequest work.ResumeRequest + reviewChangesRunID string + acceptRequest workspace.AcceptRequest + rejectRunID string + calls []string + registerCalls int + registerConfirmed bool + reviewDispatchCalls int + dispatchCalls int + closeCalls int + closeFailures int + abandonRecoveryRunID string + abandonRecoveryEventID string + abandonRecoveryCalls int +} + +func (engine *fixtureNativeAgentEngine) Capabilities() []work.Capability { + return append([]work.Capability(nil), engine.capabilities...) +} + +func (engine *fixtureNativeAgentEngine) Snapshot(_ context.Context, workID string) core.Result { + engine.snapshotWorkID = workID + return core.Ok(engine.snapshot) +} + +func (engine *fixtureNativeAgentEngine) ReviewProject(_ context.Context, item work.Item) core.Result { + engine.reviewedProject = item + return core.Ok(engine.projectReview) +} + +func (engine *fixtureNativeAgentEngine) RegisterProject(ctx context.Context, review orchestrator.ProjectReview, confirmed bool) core.Result { + engine.registerCalls++ + engine.registerConfirmed = confirmed + engine.registeredReview = review + if engine.registerProject != nil { + return engine.registerProject(ctx, review, confirmed) + } + return core.Ok(engine.registeredProject) +} + +func (engine *fixtureNativeAgentEngine) ReviewDispatch(_ context.Context, request work.DispatchRequest) core.Result { + engine.reviewDispatchCalls++ + engine.reviewedDispatch = request + return core.Ok(engine.dispatchReview) +} + +func (engine *fixtureNativeAgentEngine) Dispatch(_ context.Context, review orchestrator.DispatchReview) core.Result { + engine.dispatchCalls++ + engine.dispatchedReview = review + return core.Ok(work.Run{ID: "run-1", WorkID: "work-1", Status: work.RunQueued}) +} + +func (engine *fixtureNativeAgentEngine) Cancel(_ context.Context, runID string) core.Result { + engine.calls = append(engine.calls, "cancel") + engine.cancelRunID = runID + return core.Ok(work.Run{ID: "run-1", WorkID: "work-1", Status: work.RunCancelled}) +} + +func (engine *fixtureNativeAgentEngine) Answer(_ context.Context, runID, text string) core.Result { + engine.calls = append(engine.calls, "answer") + engine.answerRunID = runID + engine.answerText = text + return core.Ok(work.Answer{ID: "answer-1", ResumeRunID: "run-2"}) +} + +func (engine *fixtureNativeAgentEngine) Retry(_ context.Context, item work.Item, parentRunID string) core.Result { + engine.calls = append(engine.calls, "retry.legacy") + engine.retryItem = item + engine.retryParent = parentRunID + return core.Ok(work.Run{ID: "run-2", WorkID: "work-1", Status: work.RunQueued}) +} + +func (engine *fixtureNativeAgentEngine) Resume(_ context.Context, request work.ResumeRequest) core.Result { + engine.calls = append(engine.calls, "resume.legacy") + engine.resumeRequest = request + return core.Ok(work.Run{ID: "run-3", WorkID: "work-1", Status: work.RunQueued}) +} + +func (engine *fixtureNativeAgentEngine) ReviewRetry(_ context.Context, item work.Item, parentRunID string) core.Result { + engine.calls = append(engine.calls, "retry.review") + engine.retryItem = item + engine.retryParent = parentRunID + return core.Ok(fixtureChildReview("retry", "run-2", "fake", "test-model")) +} + +func (engine *fixtureNativeAgentEngine) ConfirmRetry(_ context.Context, _ any) core.Result { + engine.calls = append(engine.calls, "retry") + return core.Ok(work.Run{ID: "run-2", WorkID: "work-1", Status: work.RunQueued}) +} + +func (engine *fixtureNativeAgentEngine) ReviewResume(_ context.Context, request work.ResumeRequest) core.Result { + engine.calls = append(engine.calls, "resume.review") + engine.resumeRequest = request + return core.Ok(fixtureChildReview("resume", "run-3", request.Provider, request.Model)) +} + +func (engine *fixtureNativeAgentEngine) ConfirmResume(_ context.Context, _ any) core.Result { + engine.calls = append(engine.calls, "resume") + return core.Ok(work.Run{ID: "run-3", WorkID: "work-1", Status: work.RunQueued}) +} + +func fixtureChildReview(action, runID, providerName, model string) agentChildReviewProjection { + review := agentChildReviewProjection{ + Action: action, RunID: runID, Provider: providerName, Model: model, + WorktreePath: "/private/runs/parent/worktree", Branch: "lem/work-1/1", + Warning: "native host access", + } + review.Project.RepositoryName = "private-one" + review.Source.Path, review.Source.Branch, review.Source.Revision = "/src/one", "main", "abc123" + review.Command.Receipt = core.Concat(providerName, " run ") + review.Queue.Reason = "queue frozen" + return review +} + +func (engine *fixtureNativeAgentEngine) StartQueue(context.Context) core.Result { + engine.calls = append(engine.calls, "queue.start") + return core.Ok(work.QueueState{ID: "default", Status: work.QueueAccepting}) +} + +func (engine *fixtureNativeAgentEngine) StopQueue(context.Context) core.Result { + engine.calls = append(engine.calls, "queue.stop") + return core.Ok(work.QueueState{ID: "default", Status: work.QueueFrozen}) +} + +func (engine *fixtureNativeAgentEngine) ReviewChanges(_ context.Context, runID string) core.Result { + engine.calls = append(engine.calls, "changes.review") + engine.reviewChangesRunID = runID + return core.Ok(engine.changeReview) +} + +func (engine *fixtureNativeAgentEngine) Accept(_ context.Context, request workspace.AcceptRequest) core.Result { + engine.calls = append(engine.calls, "accept") + engine.acceptRequest = request + return core.Ok(work.Acceptance{ID: "accept-1", WorkID: "work-1", RunID: "run-1", Status: "accepted"}) +} + +func (engine *fixtureNativeAgentEngine) Reject(_ context.Context, runID string) core.Result { + engine.calls = append(engine.calls, "reject") + engine.rejectRunID = runID + return core.Ok(work.Acceptance{ID: "reject-1", WorkID: "work-1", RunID: "run-1", Status: "rejected"}) +} + +func (engine *fixtureNativeAgentEngine) AbandonRecovery(_ context.Context, runID, eventID string) core.Result { + engine.calls = append(engine.calls, "recovery.abandon") + engine.abandonRecoveryRunID = runID + engine.abandonRecoveryEventID = eventID + engine.abandonRecoveryCalls++ + return core.Ok(work.Event{ID: "cleanup-success", RunID: runID, Kind: "cleanup_recovery_succeeded"}) +} + +func (engine *fixtureNativeAgentEngine) Close() core.Result { + engine.closeCalls++ + if engine.closeFailures > 0 { + engine.closeFailures-- + return core.Fail(core.E("test.nativeAgentEngine.Close", "injected ownership cleanup failure", nil)) + } + return core.Ok(nil) +} + +func nativeFixtureCapabilities() []work.Capability { + return []work.Capability{ + {Name: "dispatch", Available: true}, {Name: "cancel", Available: true}, + {Name: "answer", Available: true}, {Name: "retry", Available: true}, + {Name: "resume", Available: true}, {Name: "queue.start", Available: true}, + {Name: "queue.stop", Available: true}, {Name: "changes.review", Available: true}, + {Name: "accept", Available: true}, {Name: "reject", Available: true}, + } +} diff --git a/cli/tui/agentcap.go b/cli/tui/agentcap.go new file mode 100644 index 000000000..810689c6f --- /dev/null +++ b/cli/tui/agentcap.go @@ -0,0 +1,285 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package tui + +import ( + "context" + "time" + + core "dappco.re/go" +) + +const defaultAgentUnavailableReason = "agent capability not installed; the go/agent provider has not been connected" + +type agentFeature string + +const ( + agentFeatureDispatch agentFeature = "dispatch" + agentFeatureCancel agentFeature = "cancel" + agentFeatureAnswer agentFeature = "answer" + agentFeatureRetry agentFeature = "retry" + agentFeatureResume agentFeature = "resume" + agentFeatureRecoveryAbandon agentFeature = "recovery.abandon" + agentFeatureQueueStart agentFeature = "queue.start" + agentFeatureQueueStop agentFeature = "queue.stop" + agentFeatureSetup agentFeature = "setup" + agentFeatureProvider agentFeature = "provider" + agentFeatureTemplate agentFeature = "template" + agentFeaturePlan agentFeature = "plan" + agentFeatureSession agentFeature = "session" + agentFeatureHandoff agentFeature = "handoff" + agentFeatureScan agentFeature = "scan" + agentFeatureAudit agentFeature = "audit" + agentFeaturePipeline agentFeature = "pipeline" + agentFeatureMonitor agentFeature = "monitor" + agentFeatureHarvest agentFeature = "harvest" + agentFeatureBrainRecall agentFeature = "brain.recall" + agentFeatureBrainRemember agentFeature = "brain.remember" + agentFeatureMessage agentFeature = "message" + agentFeatureFleet agentFeature = "fleet" + agentFeatureForge agentFeature = "forge" + agentFeatureRemote agentFeature = "remote" + agentFeatureQA agentFeature = "qa" + agentFeatureReview agentFeature = "review" + agentFeatureChangesReview agentFeature = "changes.review" + agentFeatureAccept agentFeature = "accept" + agentFeatureReject agentFeature = "reject" + agentFeaturePRCreate agentFeature = "pr.create" + agentFeaturePRMerge agentFeature = "pr.merge" +) + +type agentCapability struct { + Feature agentFeature + Available bool + Reason string +} + +type agentWorkSnapshot struct { + ExternalID string + NativeRunID string + QuestionID string + AnswerID string + ResumeRunID string + QueueStatus string + QueueReason string + ReviewID string + ReviewStatus string + Review agentReview + Title string + Status string + Agent string + Repo string + Branch string + Runtime string + Question string + PRURL string + Recovery agentPendingRecovery + RecoveryCount int +} + +type agentRecoveryReceipt struct { + Kind string + ProjectID string + WorkID string + RunID string + RunNumber int + WorkspaceRunID string + ReviewID string + Branch string + Worktree string +} + +type agentPendingRecovery struct { + EventID string + Receipt agentRecoveryReceipt +} + +type agentRecoveryOutcome struct { + RecoveryEventID string + Receipt agentRecoveryReceipt + Error string +} + +type agentEventSnapshot struct { + ExternalID string + WorkID string + RunID string + Sequence int64 + Stream string + Kind string + Title string + Detail string + CreatedAt time.Time +} + +type agentSnapshot struct { + Work []agentWorkSnapshot + Events []agentEventSnapshot + QueueStatus string + QueueReason string +} + +type agentRequest struct { + Feature agentFeature + WorkID string + RunID string + QuestionID string + Provider string + Model string + Input string + Work agentWorkRequest + Review agentReview + Recovery agentPendingRecovery + Confirmed bool + EnableGit bool +} + +type agentWorkRequest struct { + ID string + ExternalID string + Title string + Task string + Repository string +} + +type agentReviewRequest struct { + Feature agentFeature + WorkID string + Provider string + Model string + Input string + Work agentWorkRequest + Recovery agentPendingRecovery +} + +type agentReview struct { + Feature agentFeature + Title string + Body string + Warning string + ConfirmRequired bool + GitConfirmRequired bool + NeedsAcknowledgement bool + AcceptanceAllowed bool + Payload any +} + +type agentActionReceipt struct { + Feature agentFeature + WorkID string + RunID string + Status string + Detail string +} + +type agentProvider interface { + Capabilities() []agentCapability + Snapshot(ctx context.Context) core.Result + Review(ctx context.Context, request agentReviewRequest) core.Result + Run(ctx context.Context, request agentRequest) core.Result + Close() core.Result +} + +type agentFeatureGroup struct { + Title string + Features []agentFeature +} + +var agentFeatureGroups = []agentFeatureGroup{ + {Title: "EXECUTION", Features: []agentFeature{agentFeatureDispatch, agentFeatureCancel, agentFeatureAnswer, agentFeatureRetry, agentFeatureResume, agentFeatureRecoveryAbandon}}, + {Title: "QUEUE + SETUP", Features: []agentFeature{agentFeatureQueueStart, agentFeatureQueueStop, agentFeatureSetup, agentFeatureProvider, agentFeatureTemplate}}, + {Title: "PLANS + SESSIONS", Features: []agentFeature{agentFeaturePlan, agentFeatureSession, agentFeatureHandoff}}, + {Title: "SCAN + MONITOR", Features: []agentFeature{agentFeatureScan, agentFeatureAudit, agentFeaturePipeline, agentFeatureMonitor, agentFeatureHarvest}}, + {Title: "BRAIN + MESSAGES", Features: []agentFeature{agentFeatureBrainRecall, agentFeatureBrainRemember, agentFeatureMessage}}, + {Title: "FLEET + FORGE", Features: []agentFeature{agentFeatureFleet, agentFeatureForge, agentFeatureRemote}}, + {Title: "QA + REVIEW + PR", Features: []agentFeature{agentFeatureQA, agentFeatureReview, agentFeatureChangesReview, agentFeatureAccept, agentFeatureReject, agentFeaturePRCreate, agentFeaturePRMerge}}, +} + +func agentFeatureCatalog(reason string) []agentCapability { + catalog := make([]agentCapability, 0, 32) + for _, group := range agentFeatureGroups { + for _, feature := range group.Features { + catalog = append(catalog, agentCapability{Feature: feature, Reason: reason}) + } + } + return catalog +} + +func agentFeatureTitle(feature agentFeature) string { + switch feature { + case agentFeatureQueueStart: + return "Start queue" + case agentFeatureQueueStop: + return "Stop queue" + case agentFeatureBrainRecall: + return "Recall from Brain" + case agentFeatureBrainRemember: + return "Remember in Brain" + case agentFeaturePRCreate: + return "Create pull request" + case agentFeaturePRMerge: + return "Merge pull request" + case agentFeatureChangesReview: + return "Review changes" + case agentFeatureRecoveryAbandon: + return "Abandon retained recovery" + case agentFeatureQA: + return "Run QA" + default: + name := core.Replace(string(feature), ".", " ") + if name == "" { + return "Unknown agent action" + } + return core.Upper(name[:1]) + name[1:] + } +} + +type unavailableAgentProvider struct { + reason string +} + +func newUnavailableAgentProvider(reason string) agentProvider { + reason = core.Trim(reason) + if reason == "" { + reason = defaultAgentUnavailableReason + } + return &unavailableAgentProvider{reason: reason} +} + +func (provider *unavailableAgentProvider) Capabilities() []agentCapability { + reason := defaultAgentUnavailableReason + if provider != nil && provider.reason != "" { + reason = provider.reason + } + return agentFeatureCatalog(reason) +} + +func (*unavailableAgentProvider) Snapshot(context.Context) core.Result { + return core.Ok(agentSnapshot{Work: []agentWorkSnapshot{}, Events: []agentEventSnapshot{}}) +} + +func (provider *unavailableAgentProvider) Review(_ context.Context, request agentReviewRequest) core.Result { + reason := defaultAgentUnavailableReason + if provider != nil && provider.reason != "" { + reason = provider.reason + } + return core.Fail(core.E( + "tui.agentProvider.Review", + core.Concat("agent review ", string(request.Feature), " is unavailable: ", reason), + nil, + )) +} + +func (provider *unavailableAgentProvider) Run(_ context.Context, request agentRequest) core.Result { + reason := defaultAgentUnavailableReason + if provider != nil && provider.reason != "" { + reason = provider.reason + } + return core.Fail(core.E( + "tui.agentProvider.Run", + core.Concat("agent action ", string(request.Feature), " is unavailable: ", reason), + nil, + )) +} + +func (*unavailableAgentProvider) Close() core.Result { return core.Ok(nil) } diff --git a/cli/tui/agentcap_test.go b/cli/tui/agentcap_test.go new file mode 100644 index 000000000..907cefee9 --- /dev/null +++ b/cli/tui/agentcap_test.go @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package tui + +import ( + "context" + "strings" + "testing" + "time" + + core "dappco.re/go" +) + +func TestAgentFeatureCatalog_Good(t *testing.T) { + reason := "agent runtime is not installed" + want := []agentFeature{ + agentFeatureDispatch, agentFeatureCancel, agentFeatureAnswer, agentFeatureRetry, + agentFeatureResume, agentFeatureRecoveryAbandon, agentFeatureQueueStart, agentFeatureQueueStop, agentFeatureSetup, + agentFeatureProvider, agentFeatureTemplate, agentFeaturePlan, agentFeatureSession, + agentFeatureHandoff, agentFeatureScan, agentFeatureAudit, agentFeaturePipeline, + agentFeatureMonitor, agentFeatureHarvest, agentFeatureBrainRecall, agentFeatureBrainRemember, + agentFeatureMessage, agentFeatureFleet, agentFeatureForge, agentFeatureRemote, + agentFeatureQA, agentFeatureReview, agentFeaturePRCreate, agentFeaturePRMerge, + agentFeatureChangesReview, agentFeatureAccept, agentFeatureReject, + } + catalog := agentFeatureCatalog(reason) + if len(catalog) != len(want) { + t.Fatalf("agentFeatureCatalog length = %d, want %d", len(catalog), len(want)) + } + seen := make(map[agentFeature]int, len(catalog)) + for _, capability := range catalog { + seen[capability.Feature]++ + if capability.Available || capability.Reason != reason { + t.Fatalf("catalog capability = %#v", capability) + } + } + for _, feature := range want { + if seen[feature] != 1 { + t.Fatalf("feature %q appears %d times", feature, seen[feature]) + } + } +} + +func TestUnavailableAgentProvider_Bad(t *testing.T) { + provider := newUnavailableAgentProvider("port go/agent to enable execution") + snapshot := provider.Snapshot(context.Background()) + if !snapshot.OK { + t.Fatalf("Snapshot failed: %v", snapshot.Value) + } + value, ok := snapshot.Value.(agentSnapshot) + if !ok || value.Work == nil || value.Events == nil || len(value.Work) != 0 || len(value.Events) != 0 { + t.Fatalf("Snapshot = %#v (%T), want empty typed snapshot", snapshot.Value, snapshot.Value) + } + run := provider.Run(context.Background(), agentRequest{Feature: agentFeatureDispatch, WorkID: "work-1"}) + if run.OK || !strings.Contains(run.Error(), string(agentFeatureDispatch)) || !strings.Contains(run.Error(), "port go/agent") { + t.Fatalf("Run = %#v", run) + } + review := provider.Review(context.Background(), agentReviewRequest{Feature: agentFeatureDispatch, WorkID: "work-1"}) + if review.OK || !strings.Contains(review.Error(), string(agentFeatureDispatch)) || !strings.Contains(review.Error(), "port go/agent") { + t.Fatalf("Review = %#v", review) + } + if result := provider.Close(); !result.OK { + t.Fatalf("first Close: %v", result.Value) + } + if result := provider.Close(); !result.OK { + t.Fatalf("second Close: %v", result.Value) + } +} + +func TestAgentProviderBoundary_Ugly(t *testing.T) { + repository := openTestDuckRepository(t) + defer closeTestDuckRepository(t, repository) + createdAt := time.Date(2026, time.July, 17, 16, 0, 0, 0, time.UTC) + provider := &fixtureAgentProvider{snapshot: agentSnapshot{ + Work: []agentWorkSnapshot{ + {ExternalID: "agent-2", Title: "Second", Status: "waiting", Agent: "beta", Repo: "repo-b", Question: "Which target?"}, + {ExternalID: "agent-1", Title: "First", Status: "active", Agent: "alpha", Repo: "repo-a", Branch: "feat/first"}, + }, + Events: []agentEventSnapshot{ + {ExternalID: "event-3", WorkID: "agent-2", Kind: "question", Title: "Needs input", CreatedAt: createdAt.Add(3 * time.Minute)}, + {ExternalID: "event-1", WorkID: "agent-1", Kind: "started", Title: "Started", CreatedAt: createdAt.Add(time.Minute)}, + {ExternalID: "event-2", WorkID: "agent-1", Kind: "log", Title: "Checked tree", CreatedAt: createdAt.Add(2 * time.Minute)}, + }, + }} + opened := newWorkPanel(repository, provider, sequenceIDs("work-a", "work-b"), func() time.Time { return createdAt }) + if !opened.OK { + t.Fatalf("newWorkPanel: %v", opened.Value) + } + panel := opened.Value.(*workPanel) + for pass := 0; pass < 2; pass++ { + if result := panel.Refresh(context.Background()); !result.OK { + t.Fatalf("Refresh pass %d: %v", pass, result.Value) + } + } + if items := panel.Items(); len(items) != 0 { + t.Fatalf("provider snapshot created Work rows: %#v", items) + } + if provider.snapshots != 2 { + t.Fatalf("Snapshot calls = %d, want 2", provider.snapshots) + } +} + +type fixtureAgentProvider struct { + snapshot agentSnapshot + caps []agentCapability + snapshots int + runs int + closeCalls int +} + +func (provider *fixtureAgentProvider) Capabilities() []agentCapability { + if provider.caps != nil { + return append([]agentCapability(nil), provider.caps...) + } + capabilities := agentFeatureCatalog("") + for index := range capabilities { + capabilities[index].Available = true + } + return capabilities +} + +func (provider *fixtureAgentProvider) Snapshot(context.Context) core.Result { + provider.snapshots++ + return core.Ok(provider.snapshot) +} + +func (*fixtureAgentProvider) Review(context.Context, agentReviewRequest) core.Result { + return core.Ok(agentReview{}) +} + +func (provider *fixtureAgentProvider) Run(context.Context, agentRequest) core.Result { + provider.runs++ + return core.Ok(nil) +} + +func (provider *fixtureAgentProvider) Close() core.Result { + provider.closeCalls++ + return core.Ok(nil) +} diff --git a/cli/tui/agentoverlay.go b/cli/tui/agentoverlay.go new file mode 100644 index 000000000..4817ff118 --- /dev/null +++ b/cli/tui/agentoverlay.go @@ -0,0 +1,340 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package tui + +import ( + "github.com/charmbracelet/bubbles/textarea" + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + + core "dappco.re/go" +) + +// workEditor keeps Work creation and editing local to the TUI. It deliberately +// has no provider dependency: recording a Work must never start a Git service. +type workEditor struct { + title textinput.Model + task textarea.Model + repository textinput.Model + focus int + editingID string + validation string +} + +func newWorkEditor(record workItemRecord) *workEditor { + title := textinput.New() + title.Prompt = "" + title.Placeholder = "Work title" + title.SetValue(record.Title) + title.Focus() + task := textarea.New() + task.Prompt = "" + task.Placeholder = "Describe the complete task" + task.ShowLineNumbers = false + task.SetHeight(4) + task.SetValue(record.Task) + repository := textinput.New() + repository.Prompt = "" + repository.Placeholder = "/path/to/repository" + repository.SetValue(record.Repo) + return &workEditor{title: title, task: task, repository: repository, editingID: record.ID} +} + +func (editor *workEditor) Update(message tea.Msg) tea.Cmd { + if editor == nil { + return nil + } + if key, ok := message.(tea.KeyMsg); ok { + switch key.String() { + case "tab": + editor.focus = (editor.focus + 1) % 3 + case "shift+tab": + editor.focus = (editor.focus + 2) % 3 + default: + return editor.updateFocused(message) + } + editor.applyFocus() + return nil + } + return editor.updateFocused(message) +} + +func (editor *workEditor) updateFocused(message tea.Msg) tea.Cmd { + var command tea.Cmd + switch editor.focus { + case 0: + editor.title, command = editor.title.Update(message) + case 1: + editor.task, command = editor.task.Update(message) + default: + editor.repository, command = editor.repository.Update(message) + } + return command +} + +func (editor *workEditor) applyFocus() { + if editor == nil { + return + } + editor.title.Blur() + editor.task.Blur() + editor.repository.Blur() + switch editor.focus { + case 0: + editor.title.Focus() + case 1: + editor.task.Focus() + default: + editor.repository.Focus() + } +} + +func (editor *workEditor) values() (string, string, string) { + if editor == nil { + return "", "", "" + } + return core.Trim(editor.title.Value()), core.Trim(editor.task.Value()), core.Trim(editor.repository.Value()) +} + +func (editor *workEditor) View(width, height int, styles uiStyles) string { + if editor == nil { + return "" + } + fieldWidth := max(12, width-6) + editor.title.Width = fieldWidth + editor.repository.Width = fieldWidth + editor.task.SetWidth(fieldWidth) + title := "Create Work" + if editor.editingID != "" { + title = "Edit Work" + } + return fitPane(core.Join("\n", title, "", "Work title", editor.title.View(), "", "Full task", editor.task.View(), "", "Repository", editor.repository.View(), "", "tab changes field · ctrl+s saves · esc cancels", editor.validation), width, height, styles.panel) +} + +type launchReviewOverlay struct { + review agentReview + provider string + model string + providerInput textinput.Model + modelInput textinput.Model + focus int + editable bool + confirmed bool +} + +type agentAnswerOverlay struct { + runID string + questionID string + question string + input textarea.Model +} + +func newAgentAnswerOverlay(runID, questionID, question string) *agentAnswerOverlay { + input := textarea.New() + input.Prompt = "" + input.Placeholder = "Type the answer for this native run" + input.SetHeight(4) + input.ShowLineNumbers = false + input.Focus() + return &agentAnswerOverlay{runID: core.Trim(runID), questionID: core.Trim(questionID), question: core.Trim(question), input: input} +} + +func (overlay *agentAnswerOverlay) Update(message tea.KeyMsg) bool { + if overlay == nil { + return false + } + if message.String() == "enter" { + return overlay.answer() != "" + } + var command tea.Cmd + overlay.input, command = overlay.input.Update(message) + _ = command + return false +} + +func (overlay *agentAnswerOverlay) answer() string { + if overlay == nil { + return "" + } + return core.Trim(overlay.input.Value()) +} + +func (overlay *agentAnswerOverlay) View(width, height int, styles uiStyles) string { + if overlay == nil { + return "" + } + overlay.input.SetWidth(max(12, width-6)) + return fitPane(core.Join("\n", "Answer agent question", "", overlay.question, "", overlay.input.View(), "", "enter submits · esc cancels"), width, height, styles.panel) +} + +type changeAcceptanceOverlay struct { + review agentReview + acknowledged bool + final bool + viewport viewport.Model +} + +func newChangeAcceptanceOverlay(review agentReview) *changeAcceptanceOverlay { + return &changeAcceptanceOverlay{review: review, viewport: viewport.New(1, 1)} +} + +func (overlay *changeAcceptanceOverlay) Update(message tea.KeyMsg) bool { + if overlay == nil { + return false + } + if message.String() == "a" { + if overlay.review.NeedsAcknowledgement { + overlay.acknowledged = true + } + return false + } + if message.String() != "enter" { + var command tea.Cmd + overlay.viewport, command = overlay.viewport.Update(message) + _ = command + return false + } + if !overlay.review.AcceptanceAllowed { + return false + } + if overlay.review.NeedsAcknowledgement && !overlay.acknowledged { + return false + } + if !overlay.final { + overlay.final = true + return false + } + return true +} + +func (overlay *changeAcceptanceOverlay) View(width, height int, styles uiStyles) string { + if overlay == nil { + return "" + } + prompt := "enter continues · esc cancels" + if overlay.review.NeedsAcknowledgement && !overlay.acknowledged { + prompt = "a acknowledges no validation · enter continues · esc cancels" + } + if overlay.final { + prompt = "enter applies this exact reviewed receipt · esc cancels" + } + overlay.viewport.Width, overlay.viewport.Height = max(1, width-4), max(1, height-6) + overlay.viewport.SetContent(overlay.review.Body) + return fitPane(core.Join("\n", overlay.review.Title, overlay.review.Warning, "", overlay.viewport.View(), "", prompt), width, height, styles.panel) +} + +func newLaunchReviewOverlay(review agentReview, provider, model string) *launchReviewOverlay { + provider = core.Trim(provider) + model = core.Trim(model) + providerInput := textinput.New() + providerInput.Prompt = "" + providerInput.Placeholder = "default provider" + providerInput.SetValue(provider) + providerInput.Focus() + modelInput := textinput.New() + modelInput.Prompt = "" + modelInput.Placeholder = "default model" + modelInput.SetValue(model) + return &launchReviewOverlay{review: review, provider: provider, model: model, providerInput: providerInput, modelInput: modelInput} +} + +func newAgentSelectionOverlay(provider, model string) *launchReviewOverlay { + overlay := newLaunchReviewOverlay(agentReview{ + Feature: agentFeatureDispatch, Title: "Select native agent", ConfirmRequired: true, + Warning: "Select the provider and model before reviewing project registration.", + }, provider, model) + overlay.editable = true + return overlay +} + +// Update returns true only for an explicit confirmation; Escape is always a +// cancellation and never starts a process. +func (overlay *launchReviewOverlay) Update(message tea.KeyMsg) bool { + if overlay == nil { + return false + } + switch message.String() { + case "enter": + overlay.provider, overlay.model = overlay.selection() + overlay.confirmed = true + return true + case "esc": + overlay.confirmed = false + return false + case "tab": + if !overlay.editable { + return false + } + overlay.focus = (overlay.focus + 1) % 2 + overlay.applyFocus() + return false + case "shift+tab": + if !overlay.editable { + return false + } + overlay.focus = (overlay.focus + 1) % 2 + overlay.applyFocus() + return false + } + if !overlay.editable { + return false + } + var command tea.Cmd + if overlay.focus == 0 { + overlay.providerInput, command = overlay.providerInput.Update(message) + } else { + overlay.modelInput, command = overlay.modelInput.Update(message) + } + _ = command + overlay.provider, overlay.model = overlay.selection() + return false +} + +func (overlay *launchReviewOverlay) applyFocus() { + if overlay == nil { + return + } + overlay.providerInput.Blur() + overlay.modelInput.Blur() + if overlay.focus == 0 { + overlay.providerInput.Focus() + } else { + overlay.modelInput.Focus() + } +} + +func (overlay *launchReviewOverlay) selection() (string, string) { + if overlay == nil { + return "", "" + } + return core.Trim(overlay.providerInput.Value()), core.Trim(overlay.modelInput.Value()) +} + +func (overlay *launchReviewOverlay) View(width, height int, styles uiStyles) string { + if overlay == nil { + return "" + } + provider, model := overlay.selection() + if provider == "" { + provider = "default provider" + } + if model == "" { + model = "default model" + } + if !overlay.editable { + return fitPane(core.Join("\n", overlay.review.Title, "", "Provider: "+provider, "Model: "+model, "", overlay.review.Warning, "", overlay.review.Body, "", "enter confirms · esc cancels"), width, height, styles.panel) + } + fieldWidth := max(12, width-6) + overlay.providerInput.Width = fieldWidth + overlay.modelInput.Width = fieldWidth + return fitPane(core.Join("\n", overlay.review.Title, "", "Provider", overlay.providerInput.View(), "Model", overlay.modelInput.View(), "", overlay.review.Body, "", overlay.review.Warning, "", "tab selects provider/model · enter confirms · esc cancels"), width, height, styles.panel) +} + +type agentActionMsg struct { + operationID uint64 + feature agentFeature + stage agentReviewStage + request agentRequest + result core.Result +} diff --git a/cli/tui/agentoverlay_test.go b/cli/tui/agentoverlay_test.go new file mode 100644 index 000000000..977e2b142 --- /dev/null +++ b/cli/tui/agentoverlay_test.go @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package tui + +import ( + "context" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + core "dappco.re/go" +) + +func TestWorkEditor_KeyboardAndLayout(t *testing.T) { + editor := newWorkEditor(workItemRecord{Title: "Initial", Task: "Full task", Repo: "/tmp/repo with spaces"}) + if editor.title.Value() != "Initial" || editor.task.Value() != "Full task" || editor.repository.Value() != "/tmp/repo with spaces" { + t.Fatalf("editor values = %#v", editor) + } + for _, message := range []tea.KeyMsg{{Type: tea.KeyTab}, {Type: tea.KeyTab}, {Type: tea.KeyShiftTab}} { + editor.Update(message) + } + for _, width := range []int{48, 120} { + view := editor.View(width, 16, newUIStyles(midnightTheme())) + for line, text := range strings.Split(view, "\n") { + if got := lipgloss.Width(text); got > width { + t.Fatalf("width %d line %d overflows at %d", width, line, got) + } + } + if !strings.Contains(view, "Work title") || !strings.Contains(view, "Repository") || !strings.Contains(view, "ctrl+s saves") { + t.Fatalf("width %d editor view:\n%s", width, view) + } + } +} + +func TestWorkEditor_MultilineValidation(t *testing.T) { + editor := newWorkEditor(workItemRecord{Title: "Title", Task: "First line", Repo: "/tmp/repo"}) + editor.focus = 1 + editor.applyFocus() + editor.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if !strings.Contains(editor.task.Value(), "\n") { + t.Fatalf("task enter did not create a newline: %q", editor.task.Value()) + } + app := newApp("", 0, 64) + repository := openTestDuckRepository(t) + defer closeTestDuckRepository(t, repository) + if result := app.attachWork(repository, newUnavailableAgentProvider(defaultAgentUnavailableReason)); !result.OK { + t.Fatalf("attachWork: %v", result.Value) + } + app.workEditor = newWorkEditor(workItemRecord{}) + if result := app.saveWorkEditor(); result.OK || app.workEditor.validation == "" { + t.Fatalf("empty editor result=%#v validation=%q", result, app.workEditor.validation) + } +} + +func TestLaunchReview_ConfirmCancelAndRedaction(t *testing.T) { + overlay := newLaunchReviewOverlay(agentReview{ + Feature: agentFeatureDispatch, Title: "Review native agent launch", + Body: "Command: codex exec --token [REDACTED] --model gpt-5\nSource: /tmp/repo", + Warning: "Native agent execution has host access.", ConfirmRequired: true, + }, "codex", "gpt-5") + if view := overlay.View(72, 18, newUIStyles(midnightTheme())); !strings.Contains(view, "Command: codex exec --token [REDACTED] --model gpt-5") || !strings.Contains(view, "Native agent execution has host access.") || strings.Contains(view, "token secret") { + t.Fatalf("launch review redaction:\n%s", view) + } + if accepted := overlay.Update(tea.KeyMsg{Type: tea.KeyEnter}); !accepted { + t.Fatal("enter did not confirm launch review") + } + if !overlay.confirmed { + t.Fatal("launch review confirmation was not retained") + } + if cancelled := overlay.Update(tea.KeyMsg{Type: tea.KeyEsc}); cancelled { + t.Fatal("escape should cancel rather than confirm") + } +} + +func TestLaunchReview_ProviderAndModelSelection(t *testing.T) { + overlay := newAgentSelectionOverlay("codex", "gpt-5") + overlay.Update(tea.KeyMsg{Type: tea.KeyTab}) + overlay.Update(tea.KeyMsg{Type: tea.KeyBackspace}) + overlay.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}}) + provider, model := overlay.selection() + if provider != "codex" || model != "gpt-x" { + t.Fatalf("launch selection = %q/%q", provider, model) + } +} + +func TestAgentAnswerAndAcceptanceOverlays_RequireExplicitConfirmation(t *testing.T) { + answer := newAgentAnswerOverlay("run-9", "question-2", "Which target?") + answer.input.SetValue("release") + if accepted := answer.Update(tea.KeyMsg{Type: tea.KeyEnter}); !accepted || answer.answer() != "release" { + t.Fatalf("answer confirmation = %v/%q", accepted, answer.answer()) + } + review := agentReview{Feature: agentFeatureChangesReview, Title: "Review agent changes", Body: "Diff:\n+change", Warning: "No validation command is configured; acknowledge this explicitly.", ConfirmRequired: true, NeedsAcknowledgement: true, AcceptanceAllowed: true} + confirm := newChangeAcceptanceOverlay(review) + if confirm.Update(tea.KeyMsg{Type: tea.KeyEnter}) { + t.Fatal("accepted without acknowledgement") + } + confirm.acknowledged = true + if confirm.Update(tea.KeyMsg{Type: tea.KeyEnter}) || !confirm.final { + t.Fatal("acknowledged review did not open final confirmation") + } + if !confirm.Update(tea.KeyMsg{Type: tea.KeyEnter}) { + t.Fatal("final confirmation was not explicit") + } +} + +func TestAgentOverlay_ChangeReviewViewportScrollsWithoutDroppingContent(t *testing.T) { + lines := make([]string, 80) + for index := range lines { + lines[index] = core.Sprintf("review line %03d", index+1) + } + overlay := newChangeAcceptanceOverlay(agentReview{ + Feature: agentFeatureChangesReview, Title: "Review agent changes", + Body: core.Join("\n", lines...), AcceptanceAllowed: true, + }) + overlay.View(48, 12, newUIStyles(midnightTheme())) + if got := overlay.viewport.TotalLineCount(); got != len(lines) { + t.Fatalf("viewport line count = %d, want %d", got, len(lines)) + } + lineOffset := overlay.viewport.YOffset + overlay.Update(tea.KeyMsg{Type: tea.KeyDown}) + if overlay.viewport.YOffset <= lineOffset { + t.Fatalf("line down offset = %d, want > %d", overlay.viewport.YOffset, lineOffset) + } + pageOffset := overlay.viewport.YOffset + overlay.Update(tea.KeyMsg{Type: tea.KeyPgDown}) + if overlay.viewport.YOffset <= pageOffset { + t.Fatalf("page down offset = %d, want > %d", overlay.viewport.YOffset, pageOffset) + } + if got := overlay.viewport.TotalLineCount(); got != len(lines) { + t.Fatalf("scrolled viewport line count = %d, want %d", got, len(lines)) + } +} + +type launchReviewProvider struct { + caps []agentCapability + reviews []agentReview + reviewRequests []agentReviewRequest + runs []agentRequest +} + +func (provider *launchReviewProvider) Capabilities() []agentCapability { + return append([]agentCapability(nil), provider.caps...) +} +func (*launchReviewProvider) Snapshot(context.Context) core.Result { return core.Ok(agentSnapshot{}) } +func (provider *launchReviewProvider) Review(_ context.Context, request agentReviewRequest) core.Result { + provider.reviewRequests = append(provider.reviewRequests, request) + if len(provider.reviews) == 0 { + return core.Fail(core.E("test.launchReviewProvider.Review", "no review fixture remains", nil)) + } + review := provider.reviews[0] + provider.reviews = provider.reviews[1:] + return core.Ok(review) +} +func (provider *launchReviewProvider) Run(_ context.Context, request agentRequest) core.Result { + provider.runs = append(provider.runs, request) + if len(provider.runs) == 1 { + return core.Ok(agentReview{Feature: agentFeatureDispatch, Title: "Review native agent launch", Body: "Command: codex exec --api-key [REDACTED]", Warning: "Native agent execution has host access.", ConfirmRequired: true}) + } + return core.Ok(agentActionReceipt{Feature: agentFeatureDispatch, WorkID: request.WorkID, Status: "queued"}) +} +func (*launchReviewProvider) Close() core.Result { return core.Ok(nil) } diff --git a/cli/tui/agentstore.go b/cli/tui/agentstore.go new file mode 100644 index 000000000..e2db53a3f --- /dev/null +++ b/cli/tui/agentstore.go @@ -0,0 +1,641 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package tui + +import ( + "database/sql" + "sync" + "time" + + core "dappco.re/go" + "dappco.re/go/inference/agent/orchestrator" + "dappco.re/go/inference/agent/work" +) + +type duckAgentStore struct { + connection *sql.DB + mu sync.Mutex + writeMu *sync.Mutex +} + +var _ orchestrator.Store = (*duckAgentStore)(nil) + +func newDuckAgentStore(repository workspaceRepository) core.Result { + provider, ok := repository.(workspaceConnectionProvider) + if !ok { + return core.Fail(core.E("tui.newDuckAgentStore", "workspace repository has no open SQL connection", nil)) + } + connection := provider.workspaceConnection() + writeMutex := provider.workspaceWriteMutex() + if connection == nil || writeMutex == nil { + return core.Fail(core.E("tui.newDuckAgentStore", "workspace repository has no open SQL connection", nil)) + } + return core.Ok(&duckAgentStore{connection: connection, writeMu: writeMutex}) +} + +func (store *duckAgentStore) Recover(at time.Time) core.Result { + if result := store.ready("Recover"); !result.OK { + return result + } + if at.IsZero() { + return agentStoreFailure("Recover", "recovery time is required", nil) + } + store.writeMu.Lock() + defer store.writeMu.Unlock() + store.mu.Lock() + defer store.mu.Unlock() + result, err := store.connection.Exec(`UPDATE agent_runs + SET status = ?, finished_at = ?, updated_at = ? + WHERE status IN (?, ?, ?, ?)`, + work.RunInterrupted, at.UTC(), at.UTC(), work.RunQueued, work.RunPreparing, work.RunRunning, work.RunCancelling) + if err != nil { + return agentStoreFailure("Recover", "interrupt active runs", err) + } + count, err := result.RowsAffected() + if err != nil { + return agentStoreFailure("Recover", "count interrupted runs", err) + } + return core.Ok(int(count)) +} + +func (store *duckAgentStore) Commit(commit orchestrator.Commit) core.Result { + if result := store.ready("Commit"); !result.OK { + return result + } + if commit.Project == nil && commit.Run == nil && commit.Event == nil && len(commit.Logs) == 0 && commit.Question == nil && commit.Answer == nil && commit.Acceptance == nil && commit.Queue == nil && commit.Provider == nil { + return agentStoreFailure("Commit", "commit requires at least one record", nil) + } + if commit.CreateRun && (commit.Run == nil || commit.ExpectedStatus != nil) { + return agentStoreFailure("Commit", "run creation requires a run and no expected status", nil) + } + if !commit.CreateRun && commit.Run != nil && commit.ExpectedStatus == nil { + return agentStoreFailure("Commit", "run update requires expected status", nil) + } + if commit.Run == nil && commit.ExpectedStatus != nil { + return agentStoreFailure("Commit", "expected status requires a run update", nil) + } + previous := int64(0) + for _, chunk := range commit.Logs { + if chunk.Sequence <= previous { + return agentStoreFailure("Commit", "log sequences must be positive and increasing", nil) + } + previous = chunk.Sequence + } + + store.writeMu.Lock() + defer store.writeMu.Unlock() + store.mu.Lock() + defer store.mu.Unlock() + transaction, err := store.connection.Begin() + if err != nil { + return agentStoreFailure("Commit", "begin transaction", err) + } + rollback := func(operation string, cause error) core.Result { + rollbackWorkspaceMigration(transaction) + return agentStoreFailure("Commit", operation, cause) + } + + if project := commit.Project; project != nil { + _, err = transaction.Exec(`INSERT INTO agent_projects + (id, source_path, repository_root, source_branch, source_revision, repository_name, clone_path, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (id) DO UPDATE SET source_path = excluded.source_path, repository_root = excluded.repository_root, + source_branch = excluded.source_branch, source_revision = excluded.source_revision, + repository_name = excluded.repository_name, clone_path = excluded.clone_path, updated_at = excluded.updated_at`, + project.ID, project.SourcePath, project.RepositoryRoot, project.SourceBranch, project.SourceRevision, + project.RepositoryName, project.ClonePath, project.CreatedAt.UTC(), project.UpdatedAt.UTC()) + if err != nil { + return rollback("write project", err) + } + } + if run := commit.Run; run != nil { + if commit.CreateRun { + _, err = transaction.Exec(`INSERT INTO agent_runs + (id, work_id, project_id, parent_run_id, provider, model, source_revision, durable_revision, + execution_revision, accepted_revision, branch, worktree, command_receipt, run_number, attempt, + process_id, status, exit_code, failure_reason, queued_at, started_at, finished_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, runValues(run)...) + if err != nil { + return rollback("create run", err) + } + } else { + arguments := runValues(run) + arguments = append(arguments, run.ID, *commit.ExpectedStatus) + updated, updateErr := transaction.Exec(`UPDATE agent_runs SET + id = ?, work_id = ?, project_id = ?, parent_run_id = ?, provider = ?, model = ?, source_revision = ?, + durable_revision = ?, execution_revision = ?, accepted_revision = ?, branch = ?, worktree = ?, + command_receipt = ?, run_number = ?, attempt = ?, process_id = ?, status = ?, exit_code = ?, + failure_reason = ?, queued_at = ?, started_at = ?, finished_at = ?, updated_at = ? + WHERE id = ? AND status = ?`, arguments...) + if updateErr != nil { + return rollback("update run", updateErr) + } + count, countErr := updated.RowsAffected() + if countErr != nil { + return rollback("count updated runs", countErr) + } + if count != 1 { + return rollback("compare-and-swap run status", core.NewError("stale expected run status")) + } + } + } + if event := commit.Event; event != nil { + _, err = transaction.Exec(`INSERT INTO agent_events + (id, run_id, work_id, kind, title, detail, detail_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + event.ID, event.RunID, event.WorkID, event.Kind, event.Title, event.Detail, event.DetailJSON, event.CreatedAt.UTC()) + if err != nil { + return rollback("write event", err) + } + } + for _, chunk := range commit.Logs { + var maximum int64 + if err = transaction.QueryRow("SELECT COALESCE(MAX(sequence), 0) FROM agent_log_chunks WHERE run_id = ?", chunk.RunID).Scan(&maximum); err != nil { + return rollback("read log sequence", err) + } + if chunk.Sequence <= maximum { + return rollback("write log chunk", core.NewError("log sequence is not monotonic")) + } + _, err = transaction.Exec(`INSERT INTO agent_log_chunks + (run_id, sequence, stream, text, created_at) VALUES (?, ?, ?, ?, ?)`, + chunk.RunID, chunk.Sequence, chunk.Stream, chunk.Text, chunk.CreatedAt.UTC()) + if err != nil { + return rollback("write log chunk", err) + } + } + if question := commit.Question; question != nil { + _, err = transaction.Exec(`INSERT INTO agent_questions + (id, run_id, text, created_at) VALUES (?, ?, ?, ?)`, question.ID, question.RunID, question.Text, question.CreatedAt.UTC()) + if err != nil { + return rollback("write question", err) + } + } + if answer := commit.Answer; answer != nil { + _, err = transaction.Exec(`INSERT INTO agent_answers + (id, question_id, resume_run_id, text, created_at) VALUES (?, ?, ?, ?, ?)`, + answer.ID, answer.QuestionID, answer.ResumeRunID, answer.Text, answer.CreatedAt.UTC()) + if err != nil { + return rollback("write answer", err) + } + } + if acceptance := commit.Acceptance; acceptance != nil { + _, err = transaction.Exec(`INSERT INTO agent_acceptances + (id, work_id, run_id, source_base, agent_base, agent_tip, integration_branch, integration_worktree, + result_revision, status, validation_json, failure_reason, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, acceptance.ID, acceptance.WorkID, acceptance.RunID, + acceptance.SourceBase, acceptance.AgentBase, acceptance.AgentTip, acceptance.IntegrationBranch, + acceptance.IntegrationWorktree, acceptance.ResultRevision, acceptance.Status, acceptance.ValidationJSON, + acceptance.FailureReason, acceptance.CreatedAt.UTC(), acceptance.UpdatedAt.UTC()) + if err != nil { + return rollback("write acceptance", err) + } + } + if queue := commit.Queue; queue != nil { + _, err = transaction.Exec(`INSERT INTO agent_queue_state (id, status, reason, updated_at) VALUES (?, ?, ?, ?) + ON CONFLICT (id) DO UPDATE SET status = excluded.status, reason = excluded.reason, updated_at = excluded.updated_at`, + queue.ID, queue.Status, queue.Reason, queue.UpdatedAt.UTC()) + if err != nil { + return rollback("write queue state", err) + } + } + if provider := commit.Provider; provider != nil { + _, err = transaction.Exec(`INSERT INTO agent_provider_state + (provider, backoff_reason, last_run_id, backoff_until, last_started_at, window_started_at, window_admissions, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (provider) DO UPDATE SET backoff_reason = excluded.backoff_reason, last_run_id = excluded.last_run_id, + backoff_until = excluded.backoff_until, last_started_at = excluded.last_started_at, + window_started_at = excluded.window_started_at, window_admissions = excluded.window_admissions, + updated_at = excluded.updated_at`, provider.Provider, provider.BackoffReason, provider.LastRunID, + provider.BackoffUntil.UTC(), provider.LastStartedAt.UTC(), provider.WindowStartedAt.UTC(), + provider.WindowAdmissions, provider.UpdatedAt.UTC()) + if err != nil { + return rollback("write provider state", err) + } + } + if err = transaction.Commit(); err != nil { + return rollback("commit transaction", err) + } + return core.Ok(nil) +} + +func (store *duckAgentStore) Project(id string) core.Result { + if result := store.ready("Project"); !result.OK { + return result + } + if core.Trim(id) == "" { + return agentStoreFailure("Project", "project ID is required", nil) + } + store.mu.Lock() + defer store.mu.Unlock() + return scanAgentProject(store.connection.QueryRow(`SELECT id, source_path, repository_root, source_branch, + source_revision, repository_name, clone_path, created_at, updated_at FROM agent_projects WHERE id = ?`, id), "Project") +} + +func (store *duckAgentStore) ProjectBySource(source string) core.Result { + if result := store.ready("ProjectBySource"); !result.OK { + return result + } + if core.Trim(source) == "" { + return agentStoreFailure("ProjectBySource", "source path is required", nil) + } + store.mu.Lock() + defer store.mu.Unlock() + var project work.Project + err := store.connection.QueryRow(`SELECT id, source_path, repository_root, source_branch, + source_revision, repository_name, clone_path, created_at, updated_at FROM agent_projects WHERE source_path = ?`, source).Scan( + &project.ID, &project.SourcePath, &project.RepositoryRoot, &project.SourceBranch, &project.SourceRevision, + &project.RepositoryName, &project.ClonePath, &project.CreatedAt, &project.UpdatedAt) + if err == sql.ErrNoRows { + return core.Ok(nil) + } + if err != nil { + return agentStoreFailure("ProjectBySource", "scan project", err) + } + return core.Ok(project) +} + +func (store *duckAgentStore) Run(id string) core.Result { + if result := store.ready("Run"); !result.OK { + return result + } + if core.Trim(id) == "" { + return agentStoreFailure("Run", "run ID is required", nil) + } + store.mu.Lock() + defer store.mu.Unlock() + return scanAgentRun(store.connection.QueryRow(runSelect+" WHERE id = ?", id), "Run") +} + +func (store *duckAgentStore) NextRunNumber(workID string) core.Result { + if result := store.ready("NextRunNumber"); !result.OK { + return result + } + if core.Trim(workID) == "" { + return agentStoreFailure("NextRunNumber", "work ID is required", nil) + } + store.mu.Lock() + defer store.mu.Unlock() + var next int + if err := store.connection.QueryRow("SELECT COALESCE(MAX(run_number), 0) + 1 FROM agent_runs WHERE work_id = ?", workID).Scan(&next); err != nil { + return agentStoreFailure("NextRunNumber", "derive next run number", err) + } + return core.Ok(next) +} + +func (store *duckAgentStore) Continuation(runID string) core.Result { + if result := store.ready("Continuation"); !result.OK { + return result + } + if core.Trim(runID) == "" { + return agentStoreFailure("Continuation", "run ID is required", nil) + } + store.mu.Lock() + defer store.mu.Unlock() + runResult := scanAgentRun(store.connection.QueryRow(runSelect+" WHERE id = ?", runID), "Continuation") + if !runResult.OK { + return runResult + } + continuation := work.Continuation{Run: runResult.Value.(work.Run)} + var task string + err := store.connection.QueryRow(`SELECT detail FROM agent_events WHERE run_id = ? AND kind = 'queued' + ORDER BY created_at, id LIMIT 1`, runID).Scan(&task) + if err != nil && err != sql.ErrNoRows { + return agentStoreFailure("Continuation", "read task event", err) + } + continuation.Task = task + logs, err := queryAgentLogs(store.connection, "WHERE run_id = ? ORDER BY sequence", runID) + if err != nil { + return agentStoreFailure("Continuation", "read logs", err) + } + continuation.Logs = logs + err = store.connection.QueryRow(`SELECT id, run_id, text, created_at FROM agent_questions WHERE run_id = ?`, runID).Scan( + &continuation.Question.ID, &continuation.Question.RunID, &continuation.Question.Text, &continuation.Question.CreatedAt) + if err != nil && err != sql.ErrNoRows { + return agentStoreFailure("Continuation", "read question", err) + } + if continuation.Question.ID != "" { + err = store.connection.QueryRow(`SELECT id, question_id, resume_run_id, text, created_at FROM agent_answers WHERE question_id = ?`, continuation.Question.ID).Scan( + &continuation.Answer.ID, &continuation.Answer.QuestionID, &continuation.Answer.ResumeRunID, &continuation.Answer.Text, &continuation.Answer.CreatedAt) + if err != nil && err != sql.ErrNoRows { + return agentStoreFailure("Continuation", "read answer", err) + } + } + return core.Ok(continuation) +} + +func (store *duckAgentStore) Snapshot(workID string) core.Result { + if result := store.ready("Snapshot"); !result.OK { + return result + } + store.mu.Lock() + defer store.mu.Unlock() + snapshot := work.Snapshot{} + projects, err := queryAgentProjects(store.connection) + if err != nil { + return agentStoreFailure("Snapshot", "read projects", err) + } + snapshot.Projects = projects + runs, err := queryAgentRuns(store.connection, workID) + if err != nil { + return agentStoreFailure("Snapshot", "read runs", err) + } + snapshot.Runs = runs + events, err := queryAgentEvents(store.connection, workID) + if err != nil { + return agentStoreFailure("Snapshot", "read events", err) + } + answerEvents, err := queryAgentAnswerEvents(store.connection, workID) + if err != nil { + return agentStoreFailure("Snapshot", "read answer projections", err) + } + events = append(events, answerEvents...) + core.SliceSortFunc(events, func(left, right work.Event) bool { + if left.CreatedAt.Equal(right.CreatedAt) { + return left.ID < right.ID + } + return left.CreatedAt.Before(right.CreatedAt) + }) + snapshot.Events = events + logs, err := querySnapshotLogs(store.connection, workID) + if err != nil { + return agentStoreFailure("Snapshot", "read logs", err) + } + snapshot.Logs = logs + questions, err := queryAgentQuestions(store.connection, workID) + if err != nil { + return agentStoreFailure("Snapshot", "read questions", err) + } + snapshot.Questions = questions + acceptances, err := queryAgentAcceptances(store.connection, workID) + if err != nil { + return agentStoreFailure("Snapshot", "read acceptances", err) + } + snapshot.Acceptances = acceptances + err = store.connection.QueryRow(`SELECT id, status, reason, updated_at FROM agent_queue_state WHERE id = 'default'`).Scan( + &snapshot.Queue.ID, &snapshot.Queue.Status, &snapshot.Queue.Reason, &snapshot.Queue.UpdatedAt) + if err != nil && err != sql.ErrNoRows { + return agentStoreFailure("Snapshot", "read queue state", err) + } + providers, err := queryAgentProviders(store.connection) + if err != nil { + return agentStoreFailure("Snapshot", "read provider states", err) + } + snapshot.Providers = providers + return core.Ok(snapshot) +} + +type agentAnswerProjection struct { + AnswerID string `json:"answer_id"` + QuestionID string `json:"question_id"` + ResumeRunID string `json:"resume_run_id"` +} + +const runSelect = `SELECT id, work_id, project_id, parent_run_id, provider, model, source_revision, + durable_revision, execution_revision, accepted_revision, branch, worktree, command_receipt, run_number, + attempt, process_id, status, exit_code, failure_reason, queued_at, started_at, finished_at, updated_at FROM agent_runs` + +func runValues(run *work.Run) []any { + return []any{run.ID, run.WorkID, run.ProjectID, run.ParentRunID, run.Provider, run.Model, run.SourceRevision, + run.DurableRevision, run.ExecutionRevision, run.AcceptedRevision, run.Branch, run.Worktree, run.CommandReceipt, + run.Number, run.Attempt, run.ProcessID, run.Status, run.ExitCode, run.FailureReason, run.QueuedAt.UTC(), + run.StartedAt.UTC(), run.FinishedAt.UTC(), run.UpdatedAt.UTC()} +} + +type rowScanner interface { + Scan(...any) error +} + +func scanAgentProject(row rowScanner, operation string) core.Result { + var project work.Project + if err := row.Scan(&project.ID, &project.SourcePath, &project.RepositoryRoot, &project.SourceBranch, + &project.SourceRevision, &project.RepositoryName, &project.ClonePath, &project.CreatedAt, &project.UpdatedAt); err != nil { + return agentStoreFailure(operation, "scan project", err) + } + return core.Ok(project) +} + +func scanAgentRun(row rowScanner, operation string) core.Result { + var run work.Run + if err := row.Scan(&run.ID, &run.WorkID, &run.ProjectID, &run.ParentRunID, &run.Provider, &run.Model, + &run.SourceRevision, &run.DurableRevision, &run.ExecutionRevision, &run.AcceptedRevision, &run.Branch, + &run.Worktree, &run.CommandReceipt, &run.Number, &run.Attempt, &run.ProcessID, &run.Status, &run.ExitCode, + &run.FailureReason, &run.QueuedAt, &run.StartedAt, &run.FinishedAt, &run.UpdatedAt); err != nil { + return agentStoreFailure(operation, "scan run", err) + } + return core.Ok(run) +} + +func queryAgentProjects(connection *sql.DB) ([]work.Project, error) { + rows, err := connection.Query(`SELECT id, source_path, repository_root, source_branch, source_revision, + repository_name, clone_path, created_at, updated_at FROM agent_projects ORDER BY id`) + if err != nil { + return nil, err + } + defer closeAgentRows(rows) + projects := make([]work.Project, 0) + for rows.Next() { + result := scanAgentProject(rows, "Snapshot") + if !result.OK { + return nil, result.Err() + } + projects = append(projects, result.Value.(work.Project)) + } + return projects, rows.Err() +} + +func queryAgentRuns(connection *sql.DB, workID string) ([]work.Run, error) { + query := runSelect + arguments := make([]any, 0, 1) + if workID != "" { + query += " WHERE work_id = ?" + arguments = append(arguments, workID) + } + query += " ORDER BY queued_at, id" + rows, err := connection.Query(query, arguments...) + if err != nil { + return nil, err + } + defer closeAgentRows(rows) + runs := make([]work.Run, 0) + for rows.Next() { + result := scanAgentRun(rows, "Snapshot") + if !result.OK { + return nil, result.Err() + } + runs = append(runs, result.Value.(work.Run)) + } + return runs, rows.Err() +} + +func queryAgentEvents(connection *sql.DB, workID string) ([]work.Event, error) { + query := "SELECT id, run_id, work_id, kind, title, detail, detail_json, created_at FROM agent_events" + arguments := make([]any, 0, 1) + if workID != "" { + query += " WHERE work_id = ?" + arguments = append(arguments, workID) + } + query += " ORDER BY created_at, id" + rows, err := connection.Query(query, arguments...) + if err != nil { + return nil, err + } + defer closeAgentRows(rows) + values := make([]work.Event, 0) + for rows.Next() { + var value work.Event + if err := rows.Scan(&value.ID, &value.RunID, &value.WorkID, &value.Kind, &value.Title, &value.Detail, &value.DetailJSON, &value.CreatedAt); err != nil { + return nil, err + } + values = append(values, value) + } + return values, rows.Err() +} + +func queryAgentAnswerEvents(connection *sql.DB, workID string) ([]work.Event, error) { + query := `SELECT agent_answers.id, agent_answers.question_id, agent_answers.resume_run_id, + agent_questions.run_id, agent_runs.work_id, agent_answers.created_at + FROM agent_answers + INNER JOIN agent_questions ON agent_questions.id = agent_answers.question_id + INNER JOIN agent_runs ON agent_runs.id = agent_questions.run_id` + arguments := make([]any, 0, 1) + if workID != "" { + query += " WHERE agent_runs.work_id = ?" + arguments = append(arguments, workID) + } + query += " ORDER BY agent_answers.created_at, agent_answers.id" + rows, err := connection.Query(query, arguments...) + if err != nil { + return nil, err + } + defer closeAgentRows(rows) + values := make([]work.Event, 0) + for rows.Next() { + var projection agentAnswerProjection + var runID, answerWorkID string + var createdAt time.Time + if err := rows.Scan(&projection.AnswerID, &projection.QuestionID, &projection.ResumeRunID, &runID, &answerWorkID, &createdAt); err != nil { + return nil, err + } + values = append(values, work.Event{ + ID: core.Concat("answer:", projection.AnswerID), RunID: runID, WorkID: answerWorkID, + Kind: "answered", Title: "Agent question answered", DetailJSON: core.JSONMarshalString(projection), CreatedAt: createdAt, + }) + } + return values, rows.Err() +} + +func queryAgentLogs(connection *sql.DB, suffix string, arguments ...any) ([]work.LogChunk, error) { + rows, err := connection.Query("SELECT run_id, sequence, stream, text, created_at FROM agent_log_chunks "+suffix, arguments...) + if err != nil { + return nil, err + } + defer closeAgentRows(rows) + values := make([]work.LogChunk, 0) + for rows.Next() { + var value work.LogChunk + if err := rows.Scan(&value.RunID, &value.Sequence, &value.Stream, &value.Text, &value.CreatedAt); err != nil { + return nil, err + } + values = append(values, value) + } + return values, rows.Err() +} + +func querySnapshotLogs(connection *sql.DB, workID string) ([]work.LogChunk, error) { + if workID == "" { + return queryAgentLogs(connection, "ORDER BY sequence, run_id") + } + return queryAgentLogs(connection, `INNER JOIN agent_runs ON agent_runs.id = agent_log_chunks.run_id + WHERE agent_runs.work_id = ? ORDER BY agent_log_chunks.sequence, agent_log_chunks.run_id`, workID) +} + +func queryAgentQuestions(connection *sql.DB, workID string) ([]work.Question, error) { + query := `SELECT agent_questions.id, agent_questions.run_id, agent_questions.text, agent_questions.created_at + FROM agent_questions` + arguments := make([]any, 0, 1) + if workID != "" { + query += " INNER JOIN agent_runs ON agent_runs.id = agent_questions.run_id WHERE agent_runs.work_id = ?" + arguments = append(arguments, workID) + } + query += " ORDER BY agent_questions.created_at, agent_questions.id" + rows, err := connection.Query(query, arguments...) + if err != nil { + return nil, err + } + defer closeAgentRows(rows) + values := make([]work.Question, 0) + for rows.Next() { + var value work.Question + if err := rows.Scan(&value.ID, &value.RunID, &value.Text, &value.CreatedAt); err != nil { + return nil, err + } + values = append(values, value) + } + return values, rows.Err() +} + +func queryAgentAcceptances(connection *sql.DB, workID string) ([]work.Acceptance, error) { + query := `SELECT id, work_id, run_id, source_base, agent_base, agent_tip, integration_branch, + integration_worktree, result_revision, status, validation_json, failure_reason, created_at, updated_at + FROM agent_acceptances` + arguments := make([]any, 0, 1) + if workID != "" { + query += " WHERE work_id = ?" + arguments = append(arguments, workID) + } + query += " ORDER BY updated_at, id" + rows, err := connection.Query(query, arguments...) + if err != nil { + return nil, err + } + defer closeAgentRows(rows) + values := make([]work.Acceptance, 0) + for rows.Next() { + var value work.Acceptance + if err := rows.Scan(&value.ID, &value.WorkID, &value.RunID, &value.SourceBase, &value.AgentBase, + &value.AgentTip, &value.IntegrationBranch, &value.IntegrationWorktree, &value.ResultRevision, + &value.Status, &value.ValidationJSON, &value.FailureReason, &value.CreatedAt, &value.UpdatedAt); err != nil { + return nil, err + } + values = append(values, value) + } + return values, rows.Err() +} + +func queryAgentProviders(connection *sql.DB) ([]work.ProviderState, error) { + rows, err := connection.Query(`SELECT provider, backoff_reason, last_run_id, backoff_until, last_started_at, + window_started_at, window_admissions, updated_at FROM agent_provider_state ORDER BY provider`) + if err != nil { + return nil, err + } + defer closeAgentRows(rows) + values := make([]work.ProviderState, 0) + for rows.Next() { + var value work.ProviderState + if err := rows.Scan(&value.Provider, &value.BackoffReason, &value.LastRunID, &value.BackoffUntil, + &value.LastStartedAt, &value.WindowStartedAt, &value.WindowAdmissions, &value.UpdatedAt); err != nil { + return nil, err + } + values = append(values, value) + } + return values, rows.Err() +} + +func closeAgentRows(rows *sql.Rows) { + if rows == nil { + return + } + if err := rows.Close(); err != nil { + core.Warn("tui.agentstore.rows_close", "error", err) + } +} + +func (store *duckAgentStore) ready(operation string) core.Result { + if store == nil || store.connection == nil || store.writeMu == nil { + return agentStoreFailure(operation, "agent store is closed", nil) + } + return core.Ok(nil) +} + +func agentStoreFailure(operation, message string, cause error) core.Result { + return core.Fail(core.E(core.Concat("tui.duckAgentStore.", operation), message, cause)) +} diff --git a/cli/tui/agentstore_test.go b/cli/tui/agentstore_test.go new file mode 100644 index 000000000..2a07626d2 --- /dev/null +++ b/cli/tui/agentstore_test.go @@ -0,0 +1,589 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package tui + +import ( + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + core "dappco.re/go" + "dappco.re/go/inference/agent/orchestrator" + "dappco.re/go/inference/agent/work" + "dappco.re/go/inference/agent/workspace" +) + +func TestAgentStore_Good(t *testing.T) { + _, repository, agentStore := openTestAgentStore(t) + defer closeTestDuckRepository(t, repository) + at := time.Date(2026, time.July, 18, 9, 0, 0, 0, time.UTC) + project := testAgentProject(at) + run := testAgentRun("run-1", work.RunQueued, at) + event := work.Event{ID: "event-queued", RunID: run.ID, WorkID: run.WorkID, Kind: "queued", Title: "queued", Detail: "implement task 11", CreatedAt: at} + logs := []work.LogChunk{ + {RunID: run.ID, Sequence: 1, Stream: "stdout", Text: "first", CreatedAt: at}, + {RunID: run.ID, Sequence: 2, Stream: "stderr", Text: "second", CreatedAt: at.Add(time.Second)}, + } + queue := work.QueueState{ID: "default", Status: work.QueueAccepting, Reason: "ready", UpdatedAt: at} + provider := work.ProviderState{Provider: "codex", LastRunID: run.ID, WindowStartedAt: at, WindowAdmissions: 1, UpdatedAt: at} + if result := agentStore.Commit(orchestrator.Commit{Project: &project, Run: &run, CreateRun: true, Event: &event, Logs: logs, Queue: &queue, Provider: &provider}); !result.OK { + t.Fatalf("create durable run: %v", result.Value) + } + + if got := requireAgentValue[work.Project](t, "Project", agentStore.Project(project.ID)); got != project { + t.Fatalf("Project = %#v, want %#v", got, project) + } + if got := requireAgentValue[work.Project](t, "ProjectBySource", agentStore.ProjectBySource(project.SourcePath)); got != project { + t.Fatalf("ProjectBySource = %#v, want %#v", got, project) + } + gotRun := requireAgentValue[work.Run](t, "Run", agentStore.Run(run.ID)) + if gotRun.DurableRevision != run.DurableRevision { + t.Fatalf("Run durable revision = %q, want %q", gotRun.DurableRevision, run.DurableRevision) + } + if next := requireAgentValue[int](t, "NextRunNumber", agentStore.NextRunNumber(run.WorkID)); next != 2 { + t.Fatalf("NextRunNumber = %d, want 2", next) + } + + question := work.Question{ID: "question-1", RunID: run.ID, Text: "continue?", CreatedAt: at.Add(2 * time.Second)} + answer := work.Answer{ID: "answer-1", QuestionID: question.ID, ResumeRunID: run.ID, Text: "yes", CreatedAt: at.Add(3 * time.Second)} + if result := agentStore.Commit(orchestrator.Commit{Question: &question, Answer: &answer}); !result.OK { + t.Fatalf("commit continuation records: %v", result.Value) + } + continuation := requireAgentValue[work.Continuation](t, "Continuation", agentStore.Continuation(run.ID)) + if continuation.Run.DurableRevision != run.DurableRevision || continuation.Task != event.Detail || len(continuation.Logs) != 2 || continuation.Logs[0].Sequence != 1 || continuation.Question != question || continuation.Answer != answer { + t.Fatalf("Continuation = %#v", continuation) + } + + acceptanceTime := at.Add(4 * time.Second) + reviews := []struct { + id string + status string + review workspace.ChangeReview + }{ + {id: "acceptance-a", status: "prepared", review: testChangeReview(run, nil, true)}, + {id: "acceptance-b", status: "conflicted", review: testChangeReview(run, []string{"README.md"}, true)}, + {id: "acceptance-c", status: "validation_failed", review: testChangeReview(run, nil, false)}, + } + for _, index := range []int{2, 0, 1} { + fixture := reviews[index] + encoded := core.JSONMarshalString(fixture.review) + receipt := work.Acceptance{ID: fixture.id, WorkID: run.WorkID, RunID: run.ID, SourceBase: "source", AgentBase: "base", AgentTip: "tip", IntegrationBranch: "integration", IntegrationWorktree: "/tmp/integration", ResultRevision: "result", Status: fixture.status, ValidationJSON: encoded, CreatedAt: acceptanceTime, UpdatedAt: acceptanceTime} + if result := agentStore.Commit(orchestrator.Commit{Acceptance: &receipt}); !result.OK { + t.Fatalf("commit %s acceptance: %v", fixture.status, result.Value) + } + } + snapshot := requireAgentValue[work.Snapshot](t, "Snapshot", agentStore.Snapshot(run.WorkID)) + if len(snapshot.Runs) != 1 || snapshot.Runs[0].DurableRevision != run.DurableRevision || len(snapshot.Acceptances) != 3 { + t.Fatalf("Snapshot = %#v", snapshot) + } + for index, fixture := range reviews { + if snapshot.Acceptances[index].ID != fixture.id { + t.Fatalf("acceptance order[%d] = %q, want %q", index, snapshot.Acceptances[index].ID, fixture.id) + } + encoded := core.JSONMarshalString(fixture.review) + if snapshot.Acceptances[index].ValidationJSON != encoded { + t.Fatalf("%s ChangeReview JSON changed\ngot: %s\nwant: %s", fixture.status, snapshot.Acceptances[index].ValidationJSON, encoded) + } + var decoded workspace.ChangeReview + if result := core.JSONUnmarshalString(snapshot.Acceptances[index].ValidationJSON, &decoded); !result.OK { + t.Fatalf("decode %s ChangeReview: %v", fixture.status, result.Value) + } + if decoded.WorkID != fixture.review.WorkID || decoded.RunID != fixture.review.RunID || decoded.Diff != fixture.review.Diff || len(decoded.Validation) != len(fixture.review.Validation) || len(decoded.Conflicts) != len(fixture.review.Conflicts) { + t.Fatalf("decoded %s ChangeReview = %#v, want %#v", fixture.status, decoded, fixture.review) + } + } + if snapshot.Queue != queue || len(snapshot.Providers) != 1 || snapshot.Providers[0] != provider { + t.Fatalf("queue/provider snapshot = %#v / %#v", snapshot.Queue, snapshot.Providers) + } +} + +func TestAgentStoreSnapshotIncludesDurableAnswerResumeProjection(t *testing.T) { + _, repository, agentStore := openTestAgentStore(t) + defer closeTestDuckRepository(t, repository) + at := time.Date(2026, time.July, 19, 9, 0, 0, 0, time.UTC) + run := testAgentRun("waiting-run", work.RunWaiting, at) + question := work.Question{ID: "question-reopen", RunID: run.ID, Text: "Which target?", CreatedAt: at.Add(time.Second)} + answer := work.Answer{ID: "answer-reopen", QuestionID: question.ID, ResumeRunID: "resume-run", Text: "Target A", CreatedAt: at.Add(2 * time.Second)} + if result := agentStore.Commit(orchestrator.Commit{Run: &run, CreateRun: true, Question: &question, Answer: &answer}); !result.OK { + t.Fatalf("commit answered waiting run: %s", result.Error()) + } + + snapshot := requireAgentValue[work.Snapshot](t, "answered Snapshot", agentStore.Snapshot(run.WorkID)) + for _, event := range snapshot.Events { + if event.RunID == run.ID && event.Kind == "answered" && strings.Contains(event.DetailJSON, answer.ID) && strings.Contains(event.DetailJSON, answer.ResumeRunID) { + return + } + } + t.Fatalf("snapshot events omit durable answer/resume projection: %#v", snapshot.Events) +} + +func TestAgentStore_OrderedSnapshot(t *testing.T) { + _, repository, agentStore := openTestAgentStore(t) + defer closeTestDuckRepository(t, repository) + at := time.Date(2026, time.July, 18, 9, 30, 0, 0, time.UTC) + + projectZ := testAgentProject(at) + projectZ.ID = "project-z" + projectZ.SourcePath = "/source-z" + projectZ.RepositoryRoot = "/source-z" + projectZ.RepositoryName = "source-z.git" + projectZ.ClonePath = "/private/source-z.git" + projectA := projectZ + projectA.ID = "project-a" + projectA.SourcePath = "/source-a" + projectA.RepositoryRoot = "/source-a" + projectA.RepositoryName = "source-a.git" + projectA.ClonePath = "/private/source-a.git" + + runZ := testAgentRun("run-z", work.RunQueued, at) + runZ.ProjectID = projectZ.ID + runA := testAgentRun("run-a", work.RunQueued, at) + runA.ProjectID = projectA.ID + runA.Number = 2 + + eventZ := work.Event{ID: "event-z", RunID: runZ.ID, WorkID: runZ.WorkID, Kind: "ordered", Title: "z", CreatedAt: at} + eventA := work.Event{ID: "event-a", RunID: runA.ID, WorkID: runA.WorkID, Kind: "ordered", Title: "a", CreatedAt: at} + logZ := work.LogChunk{RunID: runZ.ID, Sequence: 1, Stream: "stdout", Text: "z", CreatedAt: at} + logA := work.LogChunk{RunID: runA.ID, Sequence: 1, Stream: "stdout", Text: "a", CreatedAt: at} + questionZ := work.Question{ID: "question-z", RunID: runZ.ID, Text: "z", CreatedAt: at} + questionA := work.Question{ID: "question-a", RunID: runA.ID, Text: "a", CreatedAt: at} + acceptanceZ := work.Acceptance{ID: "acceptance-z", WorkID: runZ.WorkID, RunID: runZ.ID, Status: "prepared", ValidationJSON: "{}", CreatedAt: at, UpdatedAt: at} + acceptanceA := work.Acceptance{ID: "acceptance-a", WorkID: runA.WorkID, RunID: runA.ID, Status: "prepared", ValidationJSON: "{}", CreatedAt: at, UpdatedAt: at} + providerZ := work.ProviderState{Provider: "provider-z", WindowStartedAt: at, UpdatedAt: at} + providerA := work.ProviderState{Provider: "provider-a", WindowStartedAt: at, UpdatedAt: at} + + for _, commit := range []orchestrator.Commit{ + {Project: &projectZ, Run: &runZ, CreateRun: true, Event: &eventZ, Logs: []work.LogChunk{logZ}, Question: &questionZ, Acceptance: &acceptanceZ, Provider: &providerZ}, + {Project: &projectA, Run: &runA, CreateRun: true, Event: &eventA, Logs: []work.LogChunk{logA}, Question: &questionA, Acceptance: &acceptanceA, Provider: &providerA}, + } { + if result := agentStore.Commit(commit); !result.OK { + t.Fatalf("commit adversarial snapshot fixture: %v", result.Value) + } + } + + snapshot := requireAgentValue[work.Snapshot](t, "ordered Snapshot", agentStore.Snapshot(runA.WorkID)) + if got := []string{snapshot.Projects[0].ID, snapshot.Projects[1].ID}; got[0] != "project-a" || got[1] != "project-z" { + t.Fatalf("project order = %#v, want [project-a project-z]", got) + } + if got := []string{snapshot.Runs[0].ID, snapshot.Runs[1].ID}; got[0] != "run-a" || got[1] != "run-z" { + t.Fatalf("run order = %#v, want [run-a run-z]", got) + } + if got := []string{snapshot.Events[0].ID, snapshot.Events[1].ID}; got[0] != "event-a" || got[1] != "event-z" { + t.Fatalf("event order = %#v, want [event-a event-z]", got) + } + if got := []string{snapshot.Logs[0].RunID, snapshot.Logs[1].RunID}; got[0] != "run-a" || got[1] != "run-z" { + t.Fatalf("log order = %#v, want [run-a run-z]", got) + } + if got := []string{snapshot.Questions[0].ID, snapshot.Questions[1].ID}; got[0] != "question-a" || got[1] != "question-z" { + t.Fatalf("question order = %#v, want [question-a question-z]", got) + } + if got := []string{snapshot.Acceptances[0].ID, snapshot.Acceptances[1].ID}; got[0] != "acceptance-a" || got[1] != "acceptance-z" { + t.Fatalf("acceptance order = %#v, want [acceptance-a acceptance-z]", got) + } + if got := []string{snapshot.Providers[0].Provider, snapshot.Providers[1].Provider}; got[0] != "provider-a" || got[1] != "provider-z" { + t.Fatalf("provider order = %#v, want [provider-a provider-z]", got) + } +} + +func TestAgentStore_Bad(t *testing.T) { + _, repository, agentStore := openTestAgentStore(t) + defer closeTestDuckRepository(t, repository) + at := time.Date(2026, time.July, 18, 10, 0, 0, 0, time.UTC) + orphanExpected := work.RunQueued + orphanQueue := work.QueueState{ID: "default", Status: work.QueueFrozen, Reason: "must roll back", UpdatedAt: at} + if result := agentStore.Commit(orchestrator.Commit{ExpectedStatus: &orphanExpected, Queue: &orphanQueue}); result.OK { + t.Fatal("commit accepted expected status without a run") + } + orphanSnapshot := requireAgentValue[work.Snapshot](t, "Snapshot after orphan expected status", agentStore.Snapshot("")) + if orphanSnapshot.Queue.ID != "" { + t.Fatalf("orphan expected-status commit persisted queue state: %#v", orphanSnapshot.Queue) + } + project := testAgentProject(at) + run := testAgentRun("run-cas", work.RunQueued, at) + if result := agentStore.Commit(orchestrator.Commit{Project: &project, Run: &run, CreateRun: true}); !result.OK { + t.Fatalf("create run: %v", result.Value) + } + firstLog := work.LogChunk{RunID: run.ID, Sequence: 1, Stream: "stdout", Text: "first", CreatedAt: at} + if result := agentStore.Commit(orchestrator.Commit{Logs: []work.LogChunk{firstLog}}); !result.OK { + t.Fatalf("commit first log: %v", result.Value) + } + + updated := run + updated.Status = work.RunRunning + updated.UpdatedAt = at.Add(time.Minute) + expected := work.RunQueued + duplicate := work.LogChunk{RunID: run.ID, Sequence: 1, Stream: "stdout", Text: "duplicate", CreatedAt: at} + if result := agentStore.Commit(orchestrator.Commit{Run: &updated, ExpectedStatus: &expected, Logs: []work.LogChunk{duplicate}}); result.OK { + t.Fatal("atomic commit with duplicate log sequence succeeded") + } + if got := requireAgentValue[work.Run](t, "Run after rollback", agentStore.Run(run.ID)); got.Status != work.RunQueued { + t.Fatalf("run status after rollback = %q, want queued", got.Status) + } + + wrong := work.RunPreparing + if result := agentStore.Commit(orchestrator.Commit{Run: &updated, ExpectedStatus: &wrong}); result.OK { + t.Fatal("stale compare-and-swap update succeeded") + } + if result := agentStore.Commit(orchestrator.Commit{Run: &updated, ExpectedStatus: &expected}); !result.OK { + t.Fatalf("valid compare-and-swap update: %v", result.Value) + } + if result := agentStore.Commit(orchestrator.Commit{Run: &run, CreateRun: true}); result.OK { + t.Fatal("duplicate run creation succeeded") + } + if result := agentStore.Project("missing"); result.OK { + t.Fatal("missing project lookup succeeded") + } + missing := agentStore.ProjectBySource("/missing") + if !missing.OK || missing.Value != nil { + t.Fatalf("missing ProjectBySource = %#v (%v), want successful nil", missing.Value, missing.Err()) + } +} + +func TestAgentStore_LegacyDurableRevisionRemainsEmpty(t *testing.T) { + _, repository, agentStore := openTestAgentStore(t) + defer closeTestDuckRepository(t, repository) + at := time.Date(2026, time.July, 18, 10, 30, 0, 0, time.UTC) + connection := repository.(workspaceConnectionProvider).workspaceConnection() + _, err := connection.Exec(`INSERT INTO agent_runs ( + id, work_id, project_id, parent_run_id, provider, model, source_revision, + execution_revision, accepted_revision, branch, worktree, command_receipt, + run_number, attempt, process_id, status, exit_code, failure_reason, + queued_at, started_at, finished_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + "legacy-run", "legacy-work", "project-agent", "", "codex", "", "source", "", "", "branch", "worktree", "", + 1, 1, 0, work.RunQueued, 0, "", at, time.Time{}, time.Time{}, at) + if err != nil { + t.Fatalf("insert legacy run: %v", err) + } + if run := requireAgentValue[work.Run](t, "legacy Run", agentStore.Run("legacy-run")); run.DurableRevision != "" { + t.Fatalf("legacy Run durable revision = %q, want empty", run.DurableRevision) + } + if continuation := requireAgentValue[work.Continuation](t, "legacy Continuation", agentStore.Continuation("legacy-run")); continuation.Run.DurableRevision != "" { + t.Fatalf("legacy Continuation durable revision = %q, want empty", continuation.Run.DurableRevision) + } + snapshot := requireAgentValue[work.Snapshot](t, "legacy Snapshot", agentStore.Snapshot("legacy-work")) + if len(snapshot.Runs) != 1 || snapshot.Runs[0].DurableRevision != "" { + t.Fatalf("legacy Snapshot runs = %#v, want empty durable revision", snapshot.Runs) + } +} + +func TestAgentStore_UglyRecoveryAndReopen(t *testing.T) { + root, repository, agentStore := openTestAgentStore(t) + at := time.Date(2026, time.July, 18, 11, 0, 0, 0, time.UTC) + project := testAgentProject(at) + if result := agentStore.Commit(orchestrator.Commit{Project: &project}); !result.OK { + t.Fatalf("commit project: %v", result.Value) + } + statuses := []work.RunStatus{work.RunQueued, work.RunPreparing, work.RunRunning, work.RunCancelling, work.RunCompleted} + for index, status := range statuses { + run := testAgentRun(core.Sprintf("recover-%d", index), status, at.Add(time.Duration(index)*time.Second)) + run.Number = index + 1 + if result := agentStore.Commit(orchestrator.Commit{Run: &run, CreateRun: true}); !result.OK { + t.Fatalf("commit %s run: %v", status, result.Value) + } + } + queue := work.QueueState{ID: "default", Status: work.QueueDraining, Reason: "shutdown", UpdatedAt: at} + provider := work.ProviderState{Provider: "codex", BackoffReason: "rate", BackoffUntil: at.Add(time.Hour), WindowStartedAt: at, WindowAdmissions: 4, UpdatedAt: at} + if result := agentStore.Commit(orchestrator.Commit{Queue: &queue, Provider: &provider}); !result.OK { + t.Fatalf("commit reopen state: %v", result.Value) + } + recoveredAt := at.Add(2 * time.Hour) + if count := requireAgentValue[int](t, "Recover", agentStore.Recover(recoveredAt)); count != 4 { + t.Fatalf("Recover count = %d, want 4", count) + } + if result := repository.Close(); !result.OK { + t.Fatalf("close repository: %v", result.Value) + } + + reopenedResult := openDuckRepository(root + "/lem.duckdb") + if !reopenedResult.OK { + t.Fatalf("reopen repository: %v", reopenedResult.Value) + } + reopened := reopenedResult.Value.(workspaceRepository) + defer closeTestDuckRepository(t, reopened) + reopenedStore := requireAgentValue[*duckAgentStore](t, "newDuckAgentStore reopened", newDuckAgentStore(reopened)) + snapshot := requireAgentValue[work.Snapshot](t, "reopened Snapshot", reopenedStore.Snapshot("work-agent")) + if snapshot.Queue != queue || len(snapshot.Providers) != 1 || snapshot.Providers[0] != provider { + t.Fatalf("reopened queue/provider = %#v / %#v", snapshot.Queue, snapshot.Providers) + } + for _, run := range snapshot.Runs { + if run.Status == work.RunCompleted { + continue + } + if run.Status != work.RunInterrupted || !run.FinishedAt.Equal(recoveredAt) || !run.UpdatedAt.Equal(recoveredAt) || run.DurableRevision != "durable-revision" { + t.Fatalf("recovered run = %#v", run) + } + } +} + +type agentStoreCleanupRecoveryReceipt struct { + Kind string + ProjectID string + WorkID string + RunID string + RunNumber int + WorkspaceRunID string + ReviewID string + Branch string + Worktree string +} + +type agentStoreCleanupRecoveryOutcome struct { + RecoveryEventID string + Receipt agentStoreCleanupRecoveryReceipt + Error string +} + +func TestAgentStoreCleanupRecoveryReceiptsSurviveReopen(t *testing.T) { + root, repository, agentStore := openTestAgentStore(t) + at := time.Date(2026, time.July, 19, 12, 0, 0, 0, time.UTC) + project := testAgentProject(at) + run := testAgentRun("cleanup-recovery-run", work.RunPreparing, at) + workspaceRoot := core.PathJoin(root, "workspaces") + run.Branch = "lem/work/work-agent/run-1" + run.Worktree = core.PathJoin(workspaceRoot, project.ID, "runs", run.ID, "worktree") + runReceipt := agentStoreCleanupRecoveryReceipt{ + Kind: "run", ProjectID: project.ID, WorkID: run.WorkID, RunID: run.ID, + RunNumber: run.Number, WorkspaceRunID: run.ID, Branch: run.Branch, Worktree: run.Worktree, + } + reviewReceipt := agentStoreCleanupRecoveryReceipt{ + Kind: "review", ProjectID: project.ID, WorkID: run.WorkID, RunID: run.ID, ReviewID: "cleanup-review", + RunNumber: run.Number, + Branch: "lem/integration/cleanup-recovery-run/cleanup-review", + Worktree: core.PathJoin(workspaceRoot, project.ID, "reviews", run.ID, "cleanup-review", "worktree"), + } + runFailure := agentStoreCleanupRecoveryOutcome{RecoveryEventID: "cleanup-recovery-run-event", Receipt: runReceipt, Error: "worktree remove failed"} + reviewSuccess := agentStoreCleanupRecoveryOutcome{RecoveryEventID: "cleanup-recovery-review-event", Receipt: reviewReceipt} + events := []work.Event{ + {ID: "cleanup-recovery-run-event", RunID: run.ID, WorkID: run.WorkID, Kind: "workspace_cleanup_retained", Title: "provisional workspace cleanup retained", Detail: runReceipt.Worktree, DetailJSON: core.JSONMarshalString(runReceipt), CreatedAt: at}, + {ID: "cleanup-recovery-review-event", RunID: run.ID, WorkID: run.WorkID, Kind: "review_cleanup_retained", Title: "provisional workspace cleanup retained", Detail: reviewReceipt.Worktree, DetailJSON: core.JSONMarshalString(reviewReceipt), CreatedAt: at.Add(time.Second)}, + {ID: "cleanup-recovery-run-failed", RunID: run.ID, WorkID: run.WorkID, Kind: "cleanup_recovery_failed", Title: "retained cleanup failed", Detail: runReceipt.Worktree, DetailJSON: core.JSONMarshalString(runFailure), CreatedAt: at.Add(2 * time.Second)}, + {ID: "cleanup-recovery-review-succeeded", RunID: run.ID, WorkID: run.WorkID, Kind: "cleanup_recovery_succeeded", Title: "retained cleanup succeeded", Detail: reviewReceipt.Worktree, DetailJSON: core.JSONMarshalString(reviewSuccess), CreatedAt: at.Add(3 * time.Second)}, + } + if result := agentStore.Commit(orchestrator.Commit{Project: &project, Run: &run, CreateRun: true, Event: &events[0]}); !result.OK { + t.Fatalf("commit run cleanup recovery: %s", result.Error()) + } + for index := 1; index < len(events); index++ { + if result := agentStore.Commit(orchestrator.Commit{Event: &events[index]}); !result.OK { + t.Fatalf("commit cleanup recovery event %d: %s", index, result.Error()) + } + } + if result := repository.Close(); !result.OK { + t.Fatalf("close repository: %s", result.Error()) + } + + reopenedResult := openDuckRepository(core.PathJoin(root, "lem.duckdb")) + core.AssertTrue(t, reopenedResult.OK, reopenedResult.Error()) + reopened := reopenedResult.Value.(workspaceRepository) + defer closeTestDuckRepository(t, reopened) + reopenedStore := requireAgentValue[*duckAgentStore](t, "newDuckAgentStore reopened cleanup recovery", newDuckAgentStore(reopened)) + snapshot := requireAgentValue[work.Snapshot](t, "reopened cleanup recovery Snapshot", reopenedStore.Snapshot(run.WorkID)) + core.AssertEqual(t, 1, len(snapshot.Runs)) + core.AssertEqual(t, run.Branch, snapshot.Runs[0].Branch) + core.AssertEqual(t, run.Worktree, snapshot.Runs[0].Worktree) + + wantJSON := map[string]string{ + "workspace_cleanup_retained": core.JSONMarshalString(runReceipt), + "review_cleanup_retained": core.JSONMarshalString(reviewReceipt), + "cleanup_recovery_failed": core.JSONMarshalString(runFailure), + "cleanup_recovery_succeeded": core.JSONMarshalString(reviewSuccess), + } + for _, event := range snapshot.Events { + want, exists := wantJSON[event.Kind] + if !exists { + continue + } + core.AssertEqual(t, want, event.DetailJSON) + delete(wantJSON, event.Kind) + } + core.AssertEqual(t, 0, len(wantJSON)) + mapped := mapAgentSnapshot(snapshot) + core.AssertEqual(t, 1, len(mapped.Work)) + core.AssertEqual(t, 1, mapped.Work[0].RecoveryCount) + core.AssertEqual(t, events[0].ID, mapped.Work[0].Recovery.EventID) +} + +func TestAgentStore_ConcurrentWriterSerialization(t *testing.T) { + _, repository, agentStore := openTestAgentStore(t) + defer closeTestDuckRepository(t, repository) + stores := []*duckAgentStore{agentStore} + for len(stores) < 8 { + stores = append(stores, requireAgentValue[*duckAgentStore](t, "newDuckAgentStore concurrent", newDuckAgentStore(repository))) + } + for index, candidate := range stores[1:] { + if candidate.writeMu != agentStore.writeMu { + t.Fatalf("store %d has independent write mutex", index+1) + } + } + at := time.Date(2026, time.July, 18, 12, 0, 0, 0, time.UTC) + run := testAgentRun("run-concurrent", work.RunQueued, at) + if result := agentStore.Commit(orchestrator.Commit{Run: &run, CreateRun: true}); !result.OK { + t.Fatalf("create run: %v", result.Value) + } + expected := work.RunQueued + var wait sync.WaitGroup + results := make(chan bool, 8) + for index := 0; index < 8; index++ { + wait.Add(1) + go func(index int) { + defer wait.Done() + candidate := run + candidate.Status = work.RunPreparing + candidate.UpdatedAt = at.Add(time.Duration(index+1) * time.Second) + results <- stores[index].Commit(orchestrator.Commit{Run: &candidate, ExpectedStatus: &expected}).OK + }(index) + } + wait.Wait() + close(results) + succeeded := 0 + for ok := range results { + if ok { + succeeded++ + } + } + if succeeded != 1 { + t.Fatalf("concurrent CAS successes = %d, want 1", succeeded) + } + if next := requireAgentValue[int](t, "NextRunNumber after concurrency", agentStore.NextRunNumber(run.WorkID)); next != 2 { + t.Fatalf("next run number = %d, want 2", next) + } +} + +func TestAgentStore_NoSecretOrRepositoryStateFiles(t *testing.T) { + root, repository, agentStore := openTestAgentStore(t) + repositoryRoot := root + "/source-repository" + if err := os.MkdirAll(repositoryRoot, 0o700); err != nil { + t.Fatalf("create source repository fixture: %v", err) + } + if err := os.WriteFile(repositoryRoot+"/README.md", []byte("ordinary repository documentation\n"), 0o600); err != nil { + t.Fatalf("write ordinary repository fixture: %v", err) + } + at := time.Date(2026, time.July, 18, 13, 0, 0, 0, time.UTC) + rawSecret := "provider-token-7f993" + rawReceipt := core.Concat("Authorization: Bearer ", rawSecret) + redactedSentinel := "[REDACTED:provider-credential]" + redactedReceipt := strings.ReplaceAll(rawReceipt, rawSecret, redactedSentinel) + if !strings.Contains(rawReceipt, rawSecret) || strings.Contains(redactedReceipt, rawSecret) || !strings.Contains(redactedReceipt, redactedSentinel) { + t.Fatalf("invalid redaction fixture: raw=%q redacted=%q", rawReceipt, redactedReceipt) + } + project := testAgentProject(at) + run := testAgentRun("run-no-secret", work.RunQueued, at) + run.CommandReceipt = redactedReceipt + event := work.Event{ID: "event-no-secret", RunID: run.ID, WorkID: run.WorkID, Kind: "receipt", Title: "redacted", DetailJSON: redactedReceipt, CreatedAt: at} + log := work.LogChunk{RunID: run.ID, Sequence: 1, Stream: "stdout", Text: redactedReceipt, CreatedAt: at} + question := work.Question{ID: "question-no-secret", RunID: run.ID, Text: redactedReceipt, CreatedAt: at} + answer := work.Answer{ID: "answer-no-secret", QuestionID: question.ID, ResumeRunID: run.ID, Text: redactedReceipt, CreatedAt: at} + acceptance := work.Acceptance{ID: "acceptance-no-secret", WorkID: run.WorkID, RunID: run.ID, Status: "prepared", ValidationJSON: core.Concat(`{"receipt":`, core.JSONMarshalString(redactedReceipt), `}`), CreatedAt: at, UpdatedAt: at} + queue := work.QueueState{ID: "default", Status: work.QueueFrozen, Reason: redactedReceipt, UpdatedAt: at} + provider := work.ProviderState{Provider: "codex", BackoffReason: redactedReceipt, WindowStartedAt: at, UpdatedAt: at} + if result := agentStore.Commit(orchestrator.Commit{Project: &project, Run: &run, CreateRun: true, Event: &event, Logs: []work.LogChunk{log}, Question: &question, Answer: &answer, Acceptance: &acceptance, Queue: &queue, Provider: &provider}); !result.OK { + t.Fatalf("commit redacted records: %v", result.Value) + } + connection := repository.(workspaceConnectionProvider).workspaceConnection() + tables := []struct { + name string + columns string + }{ + {name: "agent_projects", columns: "id, source_path, repository_root, source_branch, source_revision, repository_name, clone_path"}, + {name: "agent_runs", columns: "id, work_id, project_id, parent_run_id, provider, model, source_revision, durable_revision, execution_revision, accepted_revision, branch, worktree, command_receipt, status, failure_reason"}, + {name: "agent_events", columns: "id, run_id, work_id, kind, title, detail, detail_json"}, + {name: "agent_log_chunks", columns: "run_id, stream, text"}, + {name: "agent_questions", columns: "id, run_id, text"}, + {name: "agent_answers", columns: "id, question_id, resume_run_id, text"}, + {name: "agent_acceptances", columns: "id, work_id, run_id, source_base, agent_base, agent_tip, integration_branch, integration_worktree, result_revision, status, validation_json, failure_reason"}, + {name: "agent_queue_state", columns: "id, status, reason"}, + {name: "agent_provider_state", columns: "provider, backoff_reason, last_run_id"}, + } + redactedRows := 0 + for _, table := range tables { + query := core.Sprintf("SELECT COUNT(*) FROM %s WHERE strpos(concat_ws('|', %s), ?) > 0", table.name, table.columns) + var rawRows int + if err := connection.QueryRow(query, rawSecret).Scan(&rawRows); err != nil { + t.Fatalf("inspect raw values in %s: %v", table.name, err) + } + if rawRows != 0 { + t.Fatalf("%s persisted %d rows containing raw secret", table.name, rawRows) + } + var tableRedactedRows int + if err := connection.QueryRow(query, redactedSentinel).Scan(&tableRedactedRows); err != nil { + t.Fatalf("inspect redacted values in %s: %v", table.name, err) + } + redactedRows += tableRedactedRows + } + if redactedRows < 8 { + t.Fatalf("persisted rows containing redacted receipt = %d, want at least 8", redactedRows) + } + if result := repository.Close(); !result.OK { + t.Fatalf("close repository: %v", result.Value) + } + databaseBytes, err := os.ReadFile(root + "/lem.duckdb") + if err != nil { + t.Fatalf("read DuckDB file: %v", err) + } + if strings.Contains(string(databaseBytes), rawSecret) { + t.Fatal("DuckDB contains test secret") + } + err = filepath.WalkDir(repositoryRoot, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() { + return nil + } + name := strings.ToLower(entry.Name()) + stateSuffix := strings.HasSuffix(name, ".md") || strings.HasSuffix(name, ".json") || strings.HasSuffix(name, ".yaml") || strings.HasSuffix(name, ".yml") || strings.HasSuffix(name, ".status") + if name == ".lem" || strings.Contains(name, "lem") && stateSuffix { + t.Errorf("unexpected repository state file: %s", path) + } + return nil + }) + if err != nil { + t.Fatalf("scan repository directory: %v", err) + } +} + +func openTestAgentStore(t *testing.T) (string, workspaceRepository, *duckAgentStore) { + t.Helper() + root := t.TempDir() + opened := openDuckRepository(root + "/lem.duckdb") + if !opened.OK { + t.Fatalf("open repository: %v", opened.Value) + } + repository := opened.Value.(workspaceRepository) + storeResult := newDuckAgentStore(repository) + if !storeResult.OK { + t.Fatalf("newDuckAgentStore: %v", storeResult.Value) + } + agentStore, ok := storeResult.Value.(*duckAgentStore) + if !ok { + t.Fatalf("newDuckAgentStore value = %T, want *duckAgentStore", storeResult.Value) + } + return root, repository, agentStore +} + +func testAgentProject(at time.Time) work.Project { + return work.Project{ID: "project-agent", SourcePath: "/source", RepositoryRoot: "/source", SourceBranch: "main", SourceRevision: "source-revision", RepositoryName: "source.git", ClonePath: "/private/source.git", CreatedAt: at, UpdatedAt: at} +} + +func testAgentRun(id string, status work.RunStatus, at time.Time) work.Run { + return work.Run{ID: id, WorkID: "work-agent", ProjectID: "project-agent", Provider: "codex", Model: "gpt-5", SourceRevision: "source-revision", DurableRevision: "durable-revision", ExecutionRevision: "execution-revision", AcceptedRevision: "accepted-revision", Branch: "lem/work-agent/run-1", Worktree: "/private/run-1", CommandReceipt: "receipt", Status: status, Number: 1, Attempt: 1, ProcessID: 123, QueuedAt: at, StartedAt: at, FinishedAt: at, UpdatedAt: at} +} + +func testChangeReview(run work.Run, conflicts []string, passed bool) workspace.ChangeReview { + return workspace.ChangeReview{WorkID: run.WorkID, RunID: run.ID, SourceBranch: "main", SourceRevision: "source", AgentBase: "base", AgentTip: "tip", IntegrationBranch: "integration", IntegrationPath: "/tmp/integration", ResultRevision: "result", Diff: "diff --git a/a b/a\n", CommitLog: "abc\tchange", Validation: []workspace.ValidationResult{{Command: workspace.Command{Dir: "/tmp/integration", Executable: "go", Args: []string{"test", "./..."}, Environment: []string{"SAFE=1"}}, ExitCode: 0, Output: "ok", Receipt: "validation-receipt", Passed: passed}}, Conflicts: append([]string(nil), conflicts...)} +} + +func requireAgentValue[T any](t *testing.T, operation string, result core.Result) T { + t.Helper() + if !result.OK { + t.Fatalf("%s failed: %v", operation, result.Value) + } + value, ok := result.Value.(T) + if !ok { + t.Fatalf("%s value = %T, want %T", operation, result.Value, *new(T)) + } + return value +} diff --git a/cli/tui/app.go b/cli/tui/app.go index d5a4f6bdb..edf3e30dc 100644 --- a/cli/tui/app.go +++ b/cli/tui/app.go @@ -3,8 +3,11 @@ package tui import ( - "strings" + "context" + "sync" + "time" + "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textarea" @@ -14,51 +17,330 @@ import ( core "dappco.re/go" "dappco.re/go/inference" + "dappco.re/go/inference/dataset" "dappco.re/go/inference/decode/parser" ) // turn is one rendered element in the transcript. type turn struct { + id string role string // "user" | "assistant" | "tool" + model string thought string text string calls []string // rendered tool-call receipts on an assistant turn } type app struct { - activeTab tabID + boot bootState + workspaceLoader func() core.Result + resources *workspaceResources + lifecycle *appLifecycle + warnings []string + runtimeDetector runtimeDetector + knowledgeScan knowledgeScanner + knowledgeMounts []knowledgeMount + knowledgeLimit int64 + recentSessionLimit int - picker list.Model - spin spinner.Model - input textarea.Model - view viewport.Model - width int - height int - ready bool - loading string // model path mid-load ("" = idle) + activePanel panelID + inspectorOpen bool + styles uiStyles + keys keyMap + markdown *markdownRenderer + activeOverlay overlayKind + palette *commandPalette + switcher *sessionSwitcher + search *historySearch + help *helpOverlay + sessions *sessionManager + repository workspaceRepository + preferences preferenceStore + inspector inspectorState + agent agentProvider + work *workPanel + workEditor *workEditor + data *dataPanel + dataEditor *dataItemEditor + dataNote *dataNoteOverlay + dataBulk *dataBulkOverlay + dataFilter *dataFilterOverlay + dataInitialSlug string + launchReview *launchReviewOverlay + answerOverlay *agentAnswerOverlay + changeOverlay *changeAcceptanceOverlay + agentReview agentReview + agentRequest agentRequest + agentCommand tea.Cmd + agentStage agentReviewStage + agentOperationID uint64 + agentOperationNext uint64 + agentInFlight bool + agentRefreshArmed bool + agentSnapshotNext uint64 + agentSnapshotCurrent uint64 + agentSnapshotInFlight bool + agentSnapshotPending bool + knowledge *knowledgeLibrary + attachments []attachmentRecord + + picker list.Model + spin spinner.Model + input textarea.Model + view viewport.Model + width int + height int + ready bool + loading string // model path mid-load ("" = idle) + pendingModel string + modelLoader func(path string, contextLength int) tea.Cmd model inference.TextModel modelName string + lane *modelLane + jobs *jobManager + sessionID string cfg settings modes modeState tools toolState svc serviceState - turns []turn - gen *generation - generating bool - toolHops int // auto-continuations this turn chain (bounded) - lastTokS float64 - errText string + turns []turn + gen *generation + generating bool + sessionJobs map[string]*sessionGeneration + follow bool + newOutput bool + refreshAt time.Time + toolHops int // auto-continuations this turn chain (bounded) + lastTokS float64 + errText string } type loadedMsg struct { model inference.TextModel name string } + +type agentReviewStage uint8 + +const ( + agentReviewNone agentReviewStage = iota + agentReviewProject + agentReviewLaunch +) + type loadErrMsg struct{ err error } +type bootPhase uint8 + +const ( + bootReady bootPhase = iota + bootLoading + bootFailed +) + +type bootState struct { + phase bootPhase + err error +} + +type workspaceReadyMsg struct{ resources *workspaceResources } +type workspaceFailedMsg struct{ err error } +type runtimeDetectedMsg struct{ result core.Result } +type knowledgeDiscoveredMsg struct{ result core.Result } +type agentSnapshotMsg struct { + requestID uint64 + result core.Result +} +type agentRefreshMsg struct{} + +type sessionGeneration struct { + generation *generation + answer turnRecord + job generationJobRecord + started bool + cancelled bool + persistErr string + dirty bool + flushAt time.Time +} + +type appLifecycle struct { + shutdownMu sync.Mutex + shutdownComplete bool + result core.Result + mu sync.Mutex + stopped bool + cancel context.CancelFunc + context context.Context + workers sync.WaitGroup + stopCh chan struct{} + nextID uint64 + pending map[uint64]tea.Msg +} + +type lifecycleStoppedMsg struct{} +type lifecycleResultMsg struct{ id uint64 } + +func newAppLifecycle(ctx context.Context, cancel context.CancelFunc) *appLifecycle { + if ctx == nil { + ctx = context.Background() + } + return &appLifecycle{ + result: core.Ok(nil), + cancel: cancel, + context: ctx, + stopCh: make(chan struct{}), + pending: make(map[uint64]tea.Msg), + } +} + +// command joins a resource-producing Bubble Tea command to the app lifetime. +// A result arriving after quit is closed here instead of being sent to a dead +// update loop. +func (lifecycle *appLifecycle) command(command tea.Cmd) tea.Cmd { + if command == nil { + return nil + } + return func() tea.Msg { + if !lifecycle.beginWorker() { + return lifecycleStoppedMsg{} + } + defer lifecycle.workers.Done() + message := command() + return lifecycle.adopt(message) + } +} + +func (lifecycle *appLifecycle) adopt(message tea.Msg) tea.Msg { + if lifecycle == nil { + closeLifecycleMessage(message) + return lifecycleStoppedMsg{} + } + lifecycle.mu.Lock() + if lifecycle.stopped { + lifecycle.mu.Unlock() + closeLifecycleMessage(message) + return lifecycleStoppedMsg{} + } + if !lifecycleOwnsMessage(message) { + lifecycle.mu.Unlock() + return message + } + lifecycle.nextID++ + id := lifecycle.nextID + lifecycle.pending[id] = message + lifecycle.mu.Unlock() + return lifecycleResultMsg{id: id} +} + +func (lifecycle *appLifecycle) claim(id uint64) (tea.Msg, bool) { + if lifecycle == nil || id == 0 { + return nil, false + } + lifecycle.mu.Lock() + defer lifecycle.mu.Unlock() + message, ok := lifecycle.pending[id] + if ok { + delete(lifecycle.pending, id) + } + return message, ok +} + +func (lifecycle *appLifecycle) closePending() { + if lifecycle == nil { + return + } + lifecycle.mu.Lock() + messages := make([]tea.Msg, 0, len(lifecycle.pending)) + for id, message := range lifecycle.pending { + messages = append(messages, message) + delete(lifecycle.pending, id) + } + lifecycle.mu.Unlock() + for _, message := range messages { + closeLifecycleMessage(message) + } +} + +func lifecycleOwnsMessage(message tea.Msg) bool { + switch message.(type) { + case loadedMsg, workspaceReadyMsg, agentActionMsg, agentSnapshotMsg: + return true + default: + return false + } +} + +func (lifecycle *appLifecycle) beginWorker() bool { + if lifecycle == nil { + return false + } + lifecycle.mu.Lock() + defer lifecycle.mu.Unlock() + if lifecycle.stopped { + return false + } + lifecycle.workers.Add(1) + return true +} + +func (lifecycle *appLifecycle) isStopped() bool { + if lifecycle == nil { + return true + } + lifecycle.mu.Lock() + defer lifecycle.mu.Unlock() + return lifecycle.stopped +} + +func (lifecycle *appLifecycle) stop() { + if lifecycle == nil { + return + } + lifecycle.mu.Lock() + if lifecycle.stopped { + lifecycle.mu.Unlock() + return + } + lifecycle.stopped = true + cancel := lifecycle.cancel + if lifecycle.stopCh != nil { + close(lifecycle.stopCh) + } + lifecycle.mu.Unlock() + if cancel != nil { + cancel() + } +} + +func (lifecycle *appLifecycle) wait() { + if lifecycle != nil { + lifecycle.workers.Wait() + } +} + +func closeLifecycleMessage(message tea.Msg) { + switch value := message.(type) { + case loadedMsg: + if value.model != nil { + if result := value.model.Close(); !result.OK { + core.Warn("tui.lifecycle.close_late_model", "error", result.Value) + } + } + case workspaceReadyMsg: + if value.resources != nil { + if result := value.resources.Close(); !result.OK { + core.Warn("tui.lifecycle.close_late_workspace", "error", result.Value) + } + } + case agentActionMsg: + // Agent action messages hold no resource; they are intentionally dropped. + } +} + // loadModel loads the checkpoint through the registered engine as a tea.Cmd. func loadModel(path string, ctxLen int) tea.Cmd { return func() tea.Msg { @@ -78,6 +360,11 @@ func loadModel(path string, ctxLen int) tea.Cmd { } func newApp(modelPath string, ctxLen, maxTokens int) app { + ctx, cancel := context.WithCancel(context.Background()) + lifecycle := newAppLifecycle(ctx, cancel) + styles := newUIStyles(midnightTheme()) + keys := newKeyMap() + agent := newUnavailableAgentProvider(defaultAgentUnavailableReason) sp := spinner.New() sp.Spinner = spinner.MiniDot @@ -100,289 +387,2503 @@ func newApp(modelPath string, ctxLen, maxTokens int) app { } a := app{ - activeTab: tabModels, - picker: newPicker(), - spin: sp, - input: in, - cfg: cfg, - modes: modeState{}, - tools: newTools(), - svc: newService(), + boot: bootState{phase: bootReady}, + lifecycle: lifecycle, + runtimeDetector: newContainerRuntimeDetector(), + knowledgeScan: newKnowledgeScanner(), + knowledgeLimit: knowledgeSystemMessageMaxBytes, + recentSessionLimit: defaultPreferenceValues().RecentSessionLimit, + modelLoader: loadModel, + activePanel: panelChat, + styles: styles, + keys: keys, + markdown: newMarkdownRenderer(styles.theme.name), + palette: newCommandPalette(styles), + help: newHelpOverlay(keys, styles), + inspector: newInspector(), + agent: agent, + picker: newPicker(styles), + spin: sp, + input: in, + cfg: cfg, + modes: modeState{}, + tools: newTools(), + svc: newService(), + jobs: newJobManager(ctx), + sessionJobs: make(map[string]*sessionGeneration), + sessionID: newRecordID(), + follow: true, } if modelPath != "" { - a.activeTab = tabChat + a.activePanel = panelChat a.loading = modelPath } return a } -func (a app) Init() tea.Cmd { - cmds := []tea.Cmd{a.spin.Tick, discoverModels} - if a.loading != "" { - cmds = append(cmds, loadModel(a.loading, a.cfg.contextLen())) +func newWorkspaceApp(modelPath string, ctxLen, maxTokens int, loader func() core.Result) app { + a := newApp(modelPath, ctxLen, maxTokens) + a.boot = bootState{phase: bootLoading} + a.workspaceLoader = loader + a.width = 100 + a.height = 30 + return a +} + +func workspaceBootstrap(loader func() core.Result) tea.Cmd { + return func() tea.Msg { + if loader == nil { + return workspaceFailedMsg{err: core.E("tui.workspaceBootstrap", "workspace loader is unavailable", nil)} + } + result := loader() + if !result.OK { + return workspaceFailedMsg{err: workspaceOpenError(result, "open workspace")} + } + resources, ok := result.Value.(*workspaceResources) + if !ok || resources == nil { + return workspaceFailedMsg{err: core.E("tui.workspaceBootstrap", "invalid workspace result", nil)} + } + return workspaceReadyMsg{resources: resources} } - return tea.Batch(cmds...) } -func (a app) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - a.width, a.height = msg.Width, msg.Height - a.picker.SetSize(msg.Width-2, a.contentHeight()) - a.input.SetWidth(msg.Width - 6) - a.view = viewport.New(msg.Width-2, a.transcriptHeight()) - a.view.SetContent(a.renderTranscript()) - a.view.GotoBottom() - a.ready = true - return a, nil +func (a *app) attachWork(repository workspaceRepository, provider agentProvider) core.Result { + if a == nil { + return core.Fail(core.E("tui.app.attachWork", "application is unavailable", nil)) + } + if provider == nil { + provider = newUnavailableAgentProvider(defaultAgentUnavailableReason) + } + opened := newWorkPanel(repository, provider, nil, nil) + if !opened.OK { + return opened + } + a.repository = repository + a.agent = provider + a.work = opened.Value.(*workPanel) + a.refreshAgentPalette() + return core.Ok(a.work) +} - case discoveredMsg: - return a, a.picker.SetItems(msg.items) +// attachData wires the Data panel to store — the connected workspace's +// resources.DatasetStore (opened best-effort in openWorkspaceWithContext, +// bootstrap.go), or nil when the dataset store could not open (a damaged +// or missing datasets.duckdb never blocks the rest of the workspace, per +// the design's blast-radius rationale). A nil store leaves a.data nil — +// panelView and the palette both render that honestly rather than +// panicking on a nil panel. When dataInitialSlug was set (RunDataReview's +// `lem data review ` seam, tui.go), it is consumed exactly once +// here to pre-filter the panel to that one dataset. +func (a *app) attachData(store dataset.Store) core.Result { + if a == nil { + return core.Fail(core.E("tui.app.attachData", "application is unavailable", nil)) + } + if store == nil { + a.data = nil + return core.Ok(nil) + } + opened := newDataPanel(store, a.markdown, nil, nil) + if !opened.OK { + return opened + } + a.data = opened.Value.(*dataPanel) + if a.dataInitialSlug != "" { + if result := a.data.SetFilter(dataFilterState{DatasetSlug: a.dataInitialSlug}); !result.OK { + a.warnings = append(a.warnings, core.Concat("data: initial dataset filter: ", result.Error())) + } + a.dataInitialSlug = "" + } + a.refreshDataPalette() + return core.Ok(a.data) +} - case loadedMsg: - if a.model != nil { - _ = a.model.Close() +func (a *app) refreshAgentPalette() { + if a == nil || a.palette == nil || a.agent == nil { + return + } + var selected *workItemRecord + if a.work != nil { + if record, ok := a.work.Selected(); ok { + selected = &record } - a.model, a.modelName = msg.model, msg.name - a.loading = "" - a.errText = "" - a.activeTab = tabChat - a.refreshTranscript() - return a, nil + } + state := agentWorkSnapshot{} + if a.work != nil { + state.QueueStatus, state.QueueReason = a.work.queueStatus, a.work.queueReason + } + if selected != nil { + state = a.work.AgentState(*selected) + state.QueueStatus, state.QueueReason = a.work.queueStatus, a.work.queueReason + } + a.palette.SetAgentContext(a.agent.Capabilities(), selected, state) + a.palette.SetWorkSelection(selected != nil) +} - case loadErrMsg: - a.errText = msg.err.Error() - a.loading = "" - a.activeTab = tabModels - return a, nil +func (a *app) openWorkEditor(record workItemRecord) core.Result { + if a == nil || a.work == nil { + return core.Fail(core.E("tui.app.openWorkEditor", "work panel is unavailable", nil)) + } + a.workEditor = newWorkEditor(record) + a.activeOverlay = overlayWorkEditor + return core.Ok(a.workEditor) +} - case spinner.TickMsg: - var cmd tea.Cmd - a.spin, cmd = a.spin.Update(msg) - return a, cmd +func (a *app) saveWorkEditor() core.Result { + if a == nil || a.work == nil || a.workEditor == nil { + return core.Fail(core.E("tui.app.saveWorkEditor", "work editor is unavailable", nil)) + } + title, task, repository := a.workEditor.values() + a.workEditor.validation = "" + var result core.Result + if a.workEditor.editingID == "" { + result = a.work.CreateWork(title, task, repository) + } else { + result = a.work.EditWork(a.workEditor.editingID, title, task, repository) + } + if !result.OK { + a.workEditor.validation = result.Error() + } + return result +} - case streamMsg: - return a.onStream(msg) +// refreshDataPalette rebuilds the "data."-prefixed palette entries from +// the Data panel's current Capabilities() — called after every action +// that could change availability (a selection change, a status change), +// mirroring refreshAgentPalette's precedent. Nil-safe: a.data.Capabilities +// on a nil *dataPanel returns the honest "not connected" catalogue. +func (a *app) refreshDataPalette() { + if a == nil || a.palette == nil { + return + } + a.palette.SetDataContext(a.data.Capabilities()) +} - case serviceMsg: - a.svc.finish(msg.ev.err) - return a, nil +// dataSelectedItemID reads the Data panel's current selection, or "" when +// nothing is selected — the single-item note overlays' itemID. +func dataSelectedItemID(panel *dataPanel) string { + selected, ok := panel.Selected() + if !ok { + return "" + } + return selected.Item.ID +} - case serviceTickMsg: - if a.svc.running { - return a, serviceTick() // re-arm: keeps the requests counter live +// applyDataAction runs a no-note single-item action (Approve/Reject) +// directly against the current selection — these need no overlay at all, +// matching workPanel's Complete/Reopen/Archive precedent (friction should +// scale with blast radius, and a single-item approve/reject is routine). +func (a *app) applyDataAction(action dataAction) core.Result { + if a == nil || a.data == nil { + return core.Fail(core.E("tui.app.applyDataAction", "data panel is unavailable", nil)) + } + selected, ok := a.data.Selected() + if !ok { + return core.Fail(core.E("tui.app.applyDataAction", "a selected item is required", nil)) + } + var result core.Result + switch action { + case dataActionApprove: + result = a.data.Approve(selected.Item.ID) + case dataActionReject: + result = a.data.Reject(selected.Item.ID) + default: + return core.Fail(core.E("tui.app.applyDataAction", "this action requires its own overlay", nil)) + } + if result.OK { + a.refreshDataPalette() + } + return result +} + +// openDataEditor opens the edit-as-derived editor seam over the current +// selection, pre-checking the ItemArchiver capability EditAsDerived will +// need, so an unsupported store fails loudly here rather than after the +// human has typed an edit. +func (a *app) openDataEditor() core.Result { + if a == nil || a.data == nil { + return core.Fail(core.E("tui.app.openDataEditor", "data panel is unavailable", nil)) + } + selected, ok := a.data.Selected() + if !ok { + return core.Fail(core.E("tui.app.openDataEditor", "a selected item is required", nil)) + } + if _, archiver := a.data.store.(ItemArchiver); !archiver { + return core.Fail(core.E("tui.app.openDataEditor", "the connected dataset store cannot archive the superseded original", nil)) + } + a.dataEditor = newDataItemEditor(selected.Item) + a.activeOverlay = overlayDataEditor + return core.Ok(a.dataEditor) +} + +func (a *app) saveDataEditor() core.Result { + if a == nil || a.data == nil || a.dataEditor == nil { + return core.Fail(core.E("tui.app.saveDataEditor", "data editor is unavailable", nil)) + } + prompt, response := a.dataEditor.values() + result := a.data.EditAsDerived(a.dataEditor.original, prompt, response) + if result.OK { + a.refreshDataPalette() + } + return result +} + +// dataNotePrompt builds the title/prompt/placeholder for a note overlay, +// worded for either a single item or the shared bulk note. +func dataNotePrompt(action dataAction, bulk bool) (title, prompt, placeholder string) { + scope := "this item" + if bulk { + scope = "every item matching the current filter" + } + if action == dataActionTag { + return "Tag", core.Concat("Tag label for ", scope), "label" + } + return "Clear quarantine", core.Concat("Why is the quarantine on ", scope, " being cleared?"), "note" +} + +// openDataNote opens the note overlay for action — itemID set is a +// single-item note (Tag/QuarantineClear applied directly on submit); +// itemID empty is a bulk action's shared note, which hands off to the +// count-confirm overlay on submit rather than writing anything yet (see +// submitDataNote). +func (a *app) openDataNote(action dataAction, itemID string) core.Result { + if a == nil || a.data == nil { + return core.Fail(core.E("tui.app.openDataNote", "data panel is unavailable", nil)) + } + if itemID == "" && a.data.FilteredCount() == 0 { + return core.Fail(core.E("tui.app.openDataNote", "no items match the current filter", nil)) + } + if itemID != "" { + selected, ok := a.data.Selected() + if !ok || selected.Item.ID != itemID { + return core.Fail(core.E("tui.app.openDataNote", "a selected item is required", nil)) } - return a, nil + } + title, prompt, placeholder := dataNotePrompt(action, itemID == "") + a.dataNote = newDataNoteOverlay(action, itemID, title, prompt, placeholder) + a.activeOverlay = overlayDataNote + return core.Ok(a.dataNote) +} - case tea.KeyMsg: - return a.onKey(msg) +// submitDataNote applies a completed single-item note (Tag/QuarantineClear +// with itemID set) directly, or — for a bulk note (itemID empty) — hands +// off into the count-confirm overlay rather than writing anything yet, so +// "no confirm, no writes" holds for bulk actions that also collect a note. +func (a *app) submitDataNote() core.Result { + if a == nil || a.data == nil || a.dataNote == nil { + return core.Fail(core.E("tui.app.submitDataNote", "data note overlay is unavailable", nil)) } - return a.route(msg) + note := a.dataNote.Value() + if note == "" { + return core.Fail(core.E("tui.app.submitDataNote", "a value is required", nil)) + } + if a.dataNote.Bulk() { + a.dataBulk = newDataBulkOverlay(a.dataNote.action, a.data.FilteredCount(), note) + a.dataNote = nil + a.activeOverlay = overlayDataBulk + return core.Ok(a.dataBulk) + } + action, itemID := a.dataNote.action, a.dataNote.itemID + var result core.Result + switch action { + case dataActionTag: + result = a.data.Tag(itemID, note) + case dataActionQuarantineClear: + result = a.data.QuarantineClear(itemID, note) + default: + result = core.Fail(core.E("tui.app.submitDataNote", "unknown note action", nil)) + } + if !result.OK { + return result + } + a.dataNote, a.activeOverlay = nil, overlayNone + a.refreshDataPalette() + return result } -// onStream folds one generation event into the live assistant turn; on done it -// runs the tool loop when armed. -func (a app) onStream(ev streamMsg) (tea.Model, tea.Cmd) { - if len(a.turns) > 0 { - last := &a.turns[len(a.turns)-1] - if last.role == "assistant" { - last.thought += ev.thought - last.text += ev.visible +// openDataBulk opens the bulk-apply-to-current-filter flow for action — +// straight to the count-confirm overlay when action needs no note +// (Approve/Reject), or via openDataNote first (itemID empty) to collect +// the shared note/label when it does (QuarantineClear/Tag). +func (a *app) openDataBulk(action dataAction) core.Result { + if a == nil || a.data == nil { + return core.Fail(core.E("tui.app.openDataBulk", "data panel is unavailable", nil)) + } + if action.needsNote() { + return a.openDataNote(action, "") + } + count := a.data.FilteredCount() + if count == 0 { + return core.Fail(core.E("tui.app.openDataBulk", "no items match the current filter", nil)) + } + a.dataBulk = newDataBulkOverlay(action, count, "") + a.activeOverlay = overlayDataBulk + return core.Ok(a.dataBulk) +} + +// confirmDataBulk applies a bulk action once its two-phase overlay has +// been explicitly confirmed (dataBulkOverlay.Confirm) — the only call +// site that reaches dataPanel.BulkApply, so an overlay dismissed by +// Escape (or one that never receives the second Enter) never writes +// anything. +func (a *app) confirmDataBulk() core.Result { + if a == nil || a.data == nil || a.dataBulk == nil { + return core.Fail(core.E("tui.app.confirmDataBulk", "bulk action overlay is unavailable", nil)) + } + result := a.data.BulkApply(a.dataBulk.action, a.dataBulk.note) + a.dataBulk, a.activeOverlay = nil, overlayNone + a.refreshDataPalette() + return result +} + +// openDataFilter opens the structural filter overlay, pre-filled from the +// panel's current filter. +func (a *app) openDataFilter() core.Result { + if a == nil || a.data == nil { + return core.Fail(core.E("tui.app.openDataFilter", "data panel is unavailable", nil)) + } + a.dataFilter = newDataFilterOverlay(a.data.FilterExpr()) + a.activeOverlay = overlayDataFilter + return core.Ok(a.dataFilter) +} + +func (a *app) applyDataFilter() core.Result { + if a == nil || a.data == nil || a.dataFilter == nil { + return core.Fail(core.E("tui.app.applyDataFilter", "data filter overlay is unavailable", nil)) + } + if result := a.data.SetFilterExpr(a.dataFilter.Value()); !result.OK { + return result + } + a.dataFilter, a.activeOverlay = nil, overlayNone + a.refreshDataPalette() + return core.Ok(nil) +} + +// runDataCommand is the palette invocation path for a "data."-prefixed +// command (dataWorkspaceCommandsForContext, palette.go) — dispatches to +// the exact same app methods the Data panel's own hotkeys call +// (applyDataAction/openDataEditor/openDataNote/openDataBulk), so the +// palette mirrors every available action rather than duplicating logic, +// and forces the Data panel into view first so the result (or the +// overlay it opens) is immediately visible. +func (a *app) runDataCommand(capability dataCapability) core.Result { + if a == nil || a.data == nil { + return core.Fail(core.E("tui.command.data", "data panel is unavailable", nil)) + } + a.activePanel = panelData + if capability.Bulk { + return a.openDataBulk(capability.Action) + } + switch capability.Action { + case dataActionApprove, dataActionReject: + return a.applyDataAction(capability.Action) + case dataActionQuarantineClear, dataActionTag: + return a.openDataNote(capability.Action, dataSelectedItemID(a.data)) + case dataActionEditAsDerived: + return a.openDataEditor() + default: + return core.Fail(core.E("tui.command.data", "unknown data action", nil)) + } +} + +func (a *app) queueAgentAction(feature agentFeature) core.Result { + if a == nil || a.work == nil || a.agent == nil { + return core.Fail(core.E("tui.app.queueAgentAction", "agent work is unavailable", nil)) + } + if a.agentStage != agentReviewNone || a.agentInFlight { + return core.Fail(core.E("tui.app.queueAgentAction", "an agent operation is already in progress", nil)) + } + capabilities := a.agent.Capabilities() + available := false + capability := agentCapability{Feature: feature} + for _, candidate := range capabilities { + if candidate.Feature == feature { + capability = candidate + available = candidate.Available + if !available { + return core.Fail(core.E("tui.app.queueAgentAction", core.Concat(agentFeatureTitle(feature), " is unavailable: ", candidate.Reason), nil)) + } + break } } - if ev.metrics != nil { - a.lastTokS = ev.metrics.DecodeTokensPerSec + if !available { + return core.Fail(core.E("tui.app.queueAgentAction", "agent action is unavailable", nil)) } - if ev.err != nil { - a.errText = ev.err.Error() + selected, hasSelected := a.work.Selected() + if agentFeatureNeedsWork(feature) && !hasSelected { + return core.Fail(core.E("tui.app.queueAgentAction", "select a Work item first", nil)) } - if !ev.done { - a.refreshTranscript() - return a, waitEvent(a.gen) + state := agentWorkSnapshot{QueueStatus: a.work.queueStatus, QueueReason: a.work.queueReason} + if hasSelected { + state = a.work.AgentState(selected) + state.QueueStatus, state.QueueReason = a.work.queueStatus, a.work.queueReason + } + if hasSelected || feature == agentFeatureQueueStart || feature == agentFeatureQueueStop { + var selection *workItemRecord + if hasSelected { + selection = &selected + } + if actionAvailable, reason := agentCommandAvailability(capability, selection, state); !actionAvailable { + return core.Fail(core.E("tui.app.queueAgentAction", core.Concat(agentFeatureTitle(feature), " is unavailable: ", reason), nil)) + } + } + request := agentRequest{Feature: feature} + if hasSelected { + request.WorkID = selected.ID + state := a.work.AgentState(selected) + request.RunID, request.QuestionID = state.NativeRunID, state.QuestionID + if feature == agentFeatureRecoveryAbandon { + request.Recovery = state.Recovery + request.RunID = state.Recovery.Receipt.RunID + } + if feature == agentFeatureResume { + request.Input = state.AnswerID + request.Provider, request.Model = state.Agent, state.Runtime + } + if feature == agentFeatureAccept { + request.Review = state.Review + } + request.Work = agentWorkRequest{ID: selected.ID, ExternalID: selected.ExternalID, Title: selected.Title, Task: selected.Task, Repository: selected.Repo} + } + a.beginAgentOperation(request, agentReviewNone) + if feature == agentFeatureAnswer { + a.answerOverlay = newAgentAnswerOverlay(request.RunID, request.QuestionID, selected.Question) + a.activeOverlay = overlayAgentAnswer + return core.Ok(nil) + } + if feature == agentFeatureAccept { + a.changeOverlay = newChangeAcceptanceOverlay(request.Review) + a.activeOverlay = overlayChangeReview + return core.Ok(nil) + } + if feature == agentFeatureDispatch || feature == agentFeatureRetry || feature == agentFeatureResume || feature == agentFeatureChangesReview || feature == agentFeatureRecoveryAbandon { + if feature == agentFeatureDispatch { + a.agentStage = agentReviewProject + a.launchReview = newAgentSelectionOverlay(request.Provider, request.Model) + a.activeOverlay = overlayAgentSelection + return core.Ok(nil) + } + a.agentInFlight = true + a.agentCommand = a.lifecycle.command(a.agentReviewCommand(a.agentOperationID, request, agentReviewNone)) + return core.Ok(nil) + } + a.agentInFlight = true + a.agentCommand = a.lifecycle.command(a.agentRunCommand(a.agentOperationID, request, agentReviewNone)) + return core.Ok(nil) +} + +func (a *app) beginAgentOperation(request agentRequest, stage agentReviewStage) { + if a == nil { + return + } + a.agentOperationNext++ + a.agentOperationID = a.agentOperationNext + a.agentRequest = request + a.agentStage = stage + a.agentInFlight = false +} + +func (a *app) abortAgentOperation() { + if a == nil || a.agentInFlight { + return + } + a.resetAgentOperation() +} + +// resetAgentOperation ends a completed or abandoned transaction without +// rewinding agentOperationNext; stale command results can never match a later +// operation ID. +func (a *app) resetAgentOperation() { + if a == nil { + return + } + a.agentOperationID = 0 + a.agentRequest = agentRequest{} + a.agentReview = agentReview{} + a.agentStage = agentReviewNone + a.agentCommand = nil + a.agentInFlight = false +} + +func (a *app) agentReviewCommand(operationID uint64, request agentRequest, stage agentReviewStage) tea.Cmd { + return func() tea.Msg { + workID := request.WorkID + if (request.Feature == agentFeatureChangesReview || request.Feature == agentFeatureRetry || request.Feature == agentFeatureResume) && request.RunID != "" { + workID = request.RunID + } + result := a.agent.Review(a.lifecycle.context, agentReviewRequest{ + Feature: request.Feature, WorkID: workID, Provider: request.Provider, + Model: request.Model, Input: request.Input, Work: request.Work, Recovery: request.Recovery, + }) + return agentActionMsg{operationID: operationID, feature: request.Feature, stage: stage, request: request, result: result} + } +} + +func (a *app) agentRunCommand(operationID uint64, request agentRequest, stage agentReviewStage) tea.Cmd { + return func() tea.Msg { + return agentActionMsg{operationID: operationID, feature: request.Feature, stage: stage, request: request, result: a.agent.Run(a.lifecycle.context, request)} + } +} + +func (a *app) queueAgentRun(confirmed, enableGit bool) { + request := a.agentRequest + request.Review = a.agentReview + request.Confirmed = confirmed + request.EnableGit = enableGit + a.agentRequest = request + a.agentStage = agentReviewLaunch + a.agentInFlight = true + a.agentCommand = a.lifecycle.command(a.agentRunCommand(a.agentOperationID, request, agentReviewLaunch)) +} + +func (a *app) takeAgentCommand() tea.Cmd { + if a == nil { + return nil + } + command := a.agentCommand + a.agentCommand = nil + return command +} + +func (a *app) requestAgentSnapshot() tea.Cmd { + if a == nil || a.lifecycle == nil { + return nil + } + if a.agentSnapshotInFlight { + a.agentSnapshotPending = true + return nil + } + a.agentSnapshotNext++ + a.agentSnapshotCurrent = a.agentSnapshotNext + a.agentSnapshotInFlight = true + return a.lifecycle.command(a.agentSnapshotCommand(a.agentSnapshotCurrent)) +} + +func (a *app) agentSnapshotCommand(requestID uint64) tea.Cmd { + return func() tea.Msg { + if a == nil || a.agent == nil { + return agentSnapshotMsg{requestID: requestID, result: core.Fail(core.E("tui.app.agentSnapshot", "agent provider is unavailable", nil))} + } + return agentSnapshotMsg{requestID: requestID, result: a.agent.Snapshot(a.lifecycle.context)} + } +} + +func (a *app) hasLiveAgentWork() bool { + if a == nil || a.work == nil { + return false + } + for _, record := range a.work.Items() { + state := a.work.AgentState(record) + if state.NativeRunID == "" { + continue + } + switch core.Lower(core.Trim(record.Status)) { + case "queued", "preparing", "running", "cancelling": + return true + } + } + return false +} + +func (a *app) armAgentRefresh() tea.Cmd { + if a == nil || a.lifecycle == nil || a.lifecycle.isStopped() || a.agentRefreshArmed || !a.hasLiveAgentWork() { + return nil + } + a.agentRefreshArmed = true + return a.lifecycle.command(func() tea.Msg { + timer := time.NewTimer(time.Second) + defer timer.Stop() + select { + case <-timer.C: + return agentRefreshMsg{} + case <-a.lifecycle.context.Done(): + return lifecycleStoppedMsg{} + } + }) +} + +func (a *app) confirmAgentSelection() core.Result { + if a == nil || a.launchReview == nil { + return core.Fail(core.E("tui.app.confirmAgentSelection", "agent selection is unavailable", nil)) + } + provider, model := a.launchReview.selection() + if provider == "" || model == "" { + return core.Fail(core.E("tui.app.confirmAgentSelection", "provider and model are required before project review", nil)) + } + a.agentRequest.Provider, a.agentRequest.Model = provider, model + a.agentInFlight = true + a.agentCommand = a.lifecycle.command(a.agentReviewCommand(a.agentOperationID, a.agentRequest, agentReviewProject)) + return core.Ok(nil) +} + +func (a app) applyAgentAction(message agentActionMsg) (tea.Model, tea.Cmd) { + if message.operationID == 0 || message.operationID != a.agentOperationID || !a.agentInFlight { + return a, nil + } + a.agentInFlight = false + if !message.result.OK { + a.errText = message.result.Error() + a.activeOverlay, a.launchReview = overlayNone, nil + a.resetAgentOperation() + return a, nil + } + if review, ok := message.result.Value.(agentReview); ok { + if message.feature == agentFeatureChangesReview { + a.agentRequest, a.agentReview = message.request, review + a.changeOverlay = newChangeAcceptanceOverlay(review) + a.activeOverlay = overlayChangeReview + command := a.requestAgentSnapshot() + return a, command + } + a.agentRequest, a.agentReview, a.agentStage = message.request, review, message.stage + switch message.stage { + case agentReviewProject: + a.activeOverlay = overlayProjectReview + case agentReviewLaunch: + a.launchReview = newLaunchReviewOverlay(review, a.agentRequest.Provider, a.agentRequest.Model) + a.activeOverlay = overlayLaunchReview + default: + a.launchReview = newLaunchReviewOverlay(review, a.agentRequest.Provider, a.agentRequest.Model) + a.activeOverlay = overlayLaunchReview + } + return a, nil + } + if receipt, ok := message.result.Value.(agentActionReceipt); ok && receipt.Feature == agentFeatureAnswer && a.work != nil { + state := a.work.agentWork[message.request.WorkID] + if core.Trim(message.request.RunID) != "" && state.NativeRunID == message.request.RunID { + state.AnswerID, state.ResumeRunID = receipt.Detail, receipt.RunID + a.work.agentWork[message.request.WorkID] = state + } + } + if receipt, ok := message.result.Value.(agentActionReceipt); ok && receipt.Feature == agentFeatureDispatch { + workID := core.Trim(message.request.WorkID) + if receiptWorkID := core.Trim(receipt.WorkID); receiptWorkID != "" && receiptWorkID != workID { + a.errText = core.Sprintf("dispatch receipt WorkID %q does not match requested local Work %q", receiptWorkID, workID) + } + if a.work != nil && workID != "" && core.Trim(receipt.Status) != "" { + if result := a.work.updateWork("AgentReceipt", workID, func(record *workItemRecord) { record.Status = core.Lower(core.Trim(receipt.Status)) }); !result.OK { + if a.errText == "" { + a.errText = result.Error() + } else { + a.errText = core.Concat(a.errText, "; ", result.Error()) + } + } + } + if a.work != nil && workID != "" && receipt.RunID != "" { + state := a.work.agentWork[workID] + state.NativeRunID, state.Status = receipt.RunID, receipt.Status + a.work.agentWork[workID] = state + } + } + a.activeOverlay, a.launchReview = overlayNone, nil + a.resetAgentOperation() + a.refreshAgentPalette() + return a, a.requestAgentSnapshot() +} + +func (a *app) attachKnowledge(repository workspaceRepository, maxBytes int64) core.Result { + if a == nil { + return core.Fail(core.E("tui.app.attachKnowledge", "application is unavailable", nil)) + } + opened := newKnowledgeLibrary(repository, maxBytes, nil, nil) + if !opened.OK { + return opened + } + a.knowledge = opened.Value.(*knowledgeLibrary) + return a.reloadKnowledgeAttachments() +} + +func (a *app) applyKnowledgeDiscovery(result core.Result) core.Result { + if a == nil { + return core.Fail(core.E("tui.app.applyKnowledgeDiscovery", "application is unavailable", nil)) + } + a.inspector.ApplyKnowledge(result) + if !result.OK || a.knowledge == nil || core.Trim(a.sessionID) == "" { + return result + } + discovery, ok := result.Value.(knowledgeDiscovery) + if !ok { + return core.Fail(core.E("tui.app.applyKnowledgeDiscovery", "invalid knowledge discovery result", nil)) + } + if refreshed := a.knowledge.RefreshStaleness(a.sessionID, discovery.Documents); !refreshed.OK { + return refreshed + } + return a.reloadKnowledgeAttachments() +} + +func (a *app) attachKnowledgeDocument(index int) core.Result { + if a == nil || a.knowledge == nil { + return core.Fail(core.E("tui.app.attachKnowledgeDocument", "knowledge library is unavailable", nil)) + } + if index < 0 || index >= len(a.inspector.knowledge.documents) { + return core.Fail(core.E("tui.app.attachKnowledgeDocument", "knowledge document selection is out of range", nil)) + } + result := a.knowledge.Attach(a.sessionID, a.inspector.knowledge.documents[index]) + if !result.OK { + return result + } + return a.reloadKnowledgeAttachments() +} + +func (a *app) detachKnowledgeAttachment(index int) core.Result { + if a == nil || a.knowledge == nil { + return core.Fail(core.E("tui.app.detachKnowledgeAttachment", "knowledge library is unavailable", nil)) + } + if index < 0 || index >= len(a.attachments) { + return core.Fail(core.E("tui.app.detachKnowledgeAttachment", "knowledge attachment selection is out of range", nil)) + } + if result := a.knowledge.Detach(a.sessionID, a.attachments[index].ID); !result.OK { + return result + } + return a.reloadKnowledgeAttachments() +} + +func (a *app) reloadKnowledgeAttachments() core.Result { + if a == nil || a.knowledge == nil || core.Trim(a.sessionID) == "" { + return core.Ok(nil) + } + result := a.knowledge.Attachments(a.sessionID) + if !result.OK { + return result + } + attachments, ok := result.Value.([]attachmentRecord) + if !ok { + return core.Fail(core.E("tui.app.reloadKnowledgeAttachments", "invalid attachment result", nil)) + } + a.attachments = append([]attachmentRecord(nil), attachments...) + return core.Ok(a.attachments) +} + +func (a *app) attachPreferences(preferences preferenceStore) { + if a == nil { + return + } + a.preferences = preferences + if preferences == nil { + return + } + values := preferences.Values() + a.cfg = a.cfg.withPreferenceValues(values) + selectedTheme := themeForName(values.Theme) + a.inspector.theme = selectedTheme.name + a.rebuildTheme(selectedTheme) +} + +func (a *app) rebuildTheme(selected theme) { + if a == nil { + return + } + a.styles = newUIStyles(selected) + a.markdown = newMarkdownRenderer(selected.name) + a.picker.Styles.Title = a.styles.title + if a.palette != nil { + a.palette.list.Styles.Title = a.styles.title + } + if a.switcher != nil { + a.switcher.list.Styles.Title = a.styles.title + } + if a.search != nil { + a.search.list.Styles.Title = a.styles.title + } + a.help = newHelpOverlay(a.keys, a.styles) + if a.ready { + a.refreshTranscript() + } +} + +func (a app) Init() tea.Cmd { + cmds := []tea.Cmd{a.spin.Tick} + if a.boot.phase == bootLoading { + cmds = append(cmds, a.lifecycle.command(workspaceBootstrap(a.workspaceLoader))) + return tea.Batch(cmds...) + } + cmds = append(cmds, discoverModels) + if a.loading != "" { + cmds = append(cmds, a.lifecycle.command(a.modelLoader(a.loading, a.cfg.contextLen()))) + } + return tea.Batch(cmds...) +} + +func (a app) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case workspaceReadyMsg: + if a.lifecycle.isStopped() { + closeLifecycleMessage(msg) + return a, nil + } + if result := a.connectWorkspace(msg.resources); !result.OK { + if msg.resources != nil { + _ = msg.resources.Close() + } + a.boot = bootState{phase: bootFailed, err: resultError(result)} + if a.boot.err == nil { + a.boot.err = core.E("tui.app.connectWorkspace", result.Error(), nil) + } + return a, nil + } + a.boot = bootState{phase: bootReady} + return a, a.workspaceReadyCommands() + + case workspaceFailedMsg: + a.boot = bootState{phase: bootFailed, err: msg.err} + return a, nil + + case runtimeDetectedMsg: + a.inspector.ApplyRuntime(msg.result) + return a, nil + + case knowledgeDiscoveredMsg: + if result := a.applyKnowledgeDiscovery(msg.result); !result.OK { + a.errText = result.Error() + } + return a, nil + + case agentActionMsg: + if a.lifecycle.isStopped() { + return a, nil + } + return a.applyAgentAction(msg) + + case agentRefreshMsg: + a.agentRefreshArmed = false + return a, a.requestAgentSnapshot() + + case agentSnapshotMsg: + if msg.requestID == 0 || msg.requestID != a.agentSnapshotCurrent { + return a, nil + } + a.agentSnapshotInFlight = false + pending := a.agentSnapshotPending + a.agentSnapshotPending = false + if !msg.result.OK { + a.errText = msg.result.Error() + if pending { + return a, a.requestAgentSnapshot() + } + return a, a.armAgentRefresh() + } + snapshot, ok := msg.result.Value.(agentSnapshot) + if !ok { + a.errText = "invalid agent snapshot" + if pending { + return a, a.requestAgentSnapshot() + } + return a, a.armAgentRefresh() + } + if a.work != nil { + if result := a.work.ApplyAgentSnapshot(snapshot); !result.OK { + a.errText = result.Error() + } + } + a.refreshAgentPalette() + if pending { + return a, a.requestAgentSnapshot() + } + return a, a.armAgentRefresh() + + case tea.WindowSizeMsg: + offset := a.view.YOffset + a.width, a.height = msg.Width, msg.Height + metrics := measureFrame(msg.Width, msg.Height, a.inspectorOpen) + a.picker.SetSize(max(1, metrics.mainWidth), max(1, metrics.mainHeight)) + a.input.SetWidth(max(1, metrics.mainWidth-4)) + a.view = viewport.New(max(1, metrics.mainWidth), a.transcriptHeight()) + a.view.SetContent(a.renderTranscript()) + if a.follow { + a.view.GotoBottom() + } else { + a.view.SetYOffset(offset) + } + a.ready = true + return a, nil + + case discoveredMsg: + return a, a.picker.SetItems(msg.items) + + case loadedMsg: + if a.lifecycle.isStopped() { + closeLifecycleMessage(msg) + return a, nil + } + laneResult := newModelLane(msg.model, msg.name) + if !laneResult.OK { + if msg.model != nil { + _ = msg.model.Close() + } + a.errText = laneResult.Error() + a.loading = "" + a.activePanel = panelModels + return a, nil + } + if a.lane != nil { + _ = a.lane.Close() + } + a.lane = laneResult.Value.(*modelLane) + a.model, a.modelName = a.lane.Model(), msg.name + a.loading = "" + a.errText = "" + a.activePanel = panelChat + a.refreshTranscript() + return a, nil + + case loadErrMsg: + a.errText = msg.err.Error() + a.loading = "" + a.activePanel = panelModels + return a, nil + + case lifecycleStoppedMsg: + return a, nil + + case lifecycleResultMsg: + message, ok := a.lifecycle.claim(msg.id) + if !ok { + return a, nil + } + return a.Update(message) + + case spinner.TickMsg: + var cmd tea.Cmd + a.spin, cmd = a.spin.Update(msg) + return a, cmd + + case streamMsg: + return a.onStream(msg) + + case streamRefreshMsg: + if managed := a.sessionJobs[msg.SessionID]; managed != nil && managed.generation != nil && managed.generation.JobID == msg.JobID { + a.flushManagedGeneration(managed) + return a, waitEvent(managed.generation) + } + if a.gen == nil || msg.SessionID != a.gen.SessionID || msg.JobID != a.gen.JobID { + return a, nil + } + a.refreshAt = time.Time{} + a.refreshTranscriptOutput() + return a, waitEvent(a.gen) + + case serviceMsg: + a.svc.finish(msg.ev.err) + if a.pendingModel != "" { + path := a.pendingModel + a.pendingModel = "" + return a, a.beginModelLoad(path) + } + return a, nil + + case serviceTickMsg: + if a.svc.running { + return a, serviceTick() // re-arm: keeps the requests counter live + } + return a, nil + + case tea.KeyMsg: + return a.onKey(msg) + + case tea.MouseMsg: + return a.onMouse(msg) + } + return a.route(msg) +} + +// onMouse gives left presses to the panel bar first: the bar's .ctml render +// exposes a box map, so a click resolves through teabox to the tab that +// painted at that cell (see tabs.go). The keyboard gates apply unchanged — +// during boot or under an overlay the bar takes no clicks — and every +// unclaimed mouse message keeps its existing route to the focused panel +// (wheel scrolling in the chat transcript). +func (a app) onMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + if a.boot.phase != bootReady || a.activeOverlay != overlayNone { + return a.route(msg) + } + if msg.Action != tea.MouseActionPress || msg.Button != tea.MouseButtonLeft { + return a.route(msg) + } + metrics := measureFrame(a.width, a.height, a.inspectorOpen) + _, boxes := renderPanelBarBoxes(a.activePanel, metrics.innerWidth, metrics.kind, a.styles) + panel, ok := panelBarHit(boxes, msg.X-frameInsetCols, msg.Y-frameInsetRows) + if !ok { + return a.route(msg) + } + return a, a.selectPanel(panel) +} + +// selectPanel activates target and returns the Models discovery command a +// first visit needs — the single switching path shared by the tab keys and +// panel-bar clicks, so both land with identical side effects. +func (a *app) selectPanel(target panelID) tea.Cmd { + a.activePanel = target + if target == panelModels && len(a.picker.Items()) == 0 { + return discoverModels + } + return nil +} + +func (a *app) connectWorkspace(resources *workspaceResources) core.Result { + if a == nil || resources == nil || resources.Repository == nil || resources.State == nil || resources.Preferences == nil { + return core.Fail(core.E("tui.app.connectWorkspace", "workspace resources are incomplete", nil)) + } + // activateManagedSession below unconditionally focuses Chat (a fresh + // or resumed session is always its default landing panel) — preserve + // an explicitly requested starting panel (e.g. RunDataReview's Data + // panel focus, tui.go) across that reset. Every other caller already + // starts on panelChat (newApp/newWorkspaceApp's own default), so this + // is a no-op for them. + requestedPanel := a.activePanel + managerResult := newSessionManager(resources.Repository, resources.State, nil, nil) + if !managerResult.OK { + return managerResult + } + manager := managerResult.Value.(*sessionManager) + if manager.Active() == nil { + created := manager.Create() + if !created.OK { + return created + } + } + a.resources = resources + a.repository = resources.Repository + a.sessions = manager + a.warnings = append([]string(nil), resources.Warnings...) + a.attachPreferences(resources.Preferences) + if result := a.attachWork(resources.Repository, resources.Agent); !result.OK { + return result + } + // A dataset store problem never blocks the rest of the workspace (the + // design's blast-radius rationale, bootstrap.go) — attachData already + // degrades a nil resources.DatasetStore to a nil a.data; any other + // failure here (e.g. the panel's own initial Refresh) is likewise a + // warning, not a connectWorkspace failure. + if result := a.attachData(resources.DatasetStore); !result.OK { + a.warnings = append(a.warnings, core.Concat("data: ", result.Error())) + } + values := resources.Preferences.Values() + a.knowledgeLimit = values.KnowledgeMaxBytes + a.recentSessionLimit = values.RecentSessionLimit + if result := a.attachKnowledge(resources.Repository, values.KnowledgeMaxBytes); !result.OK { + return result + } + a.knowledgeMounts = []knowledgeMount{{Name: "local", Root: resources.Paths.Packs, Medium: resources.Files}} + a.palette.SetExporter( + newWorkspaceSessionExporter(resources.Repository, values.ShowThinking, nil, nil), + resources.Files, + resources.Paths.Exports, + ) + a.activateManagedSession(manager.Active()) + if requestedPanel != panelChat { + a.activePanel = requestedPanel + } + return core.Ok(nil) +} + +func (a *app) workspaceReadyCommands() tea.Cmd { + commands := []tea.Cmd{discoverModels} + commands = append(commands, a.requestAgentSnapshot()) + if a.runtimeDetector != nil { + detector := a.runtimeDetector + commands = append(commands, func() tea.Msg { return runtimeDetectedMsg{result: detector.Detect()} }) + } + if a.knowledgeScan != nil { + scanner := a.knowledgeScan + mounts := append([]knowledgeMount(nil), a.knowledgeMounts...) + limit := a.knowledgeLimit + commands = append(commands, func() tea.Msg { + return knowledgeDiscoveredMsg{result: scanner.Discover(mounts, limit)} + }) + } + if a.loading != "" { + commands = append(commands, a.lifecycle.command(a.modelLoader(a.loading, a.cfg.contextLen()))) + } + return tea.Batch(commands...) +} + +// onStream folds one generation event into the live assistant turn; on done it +// runs the tool loop when armed. +func (a app) onStream(ev streamMsg) (tea.Model, tea.Cmd) { + if managed := a.sessionJobs[ev.SessionID]; managed != nil && managed.generation != nil && managed.generation.JobID == ev.JobID { + return a.onManagedStream(ev, managed) + } + return a.onEphemeralStream(ev) +} + +func (a app) onEphemeralStream(ev streamMsg) (tea.Model, tea.Cmd) { + if a.gen == nil || ev.SessionID != a.gen.SessionID || ev.JobID != a.gen.JobID { + return a, nil + } + if len(a.turns) > 0 { + last := &a.turns[len(a.turns)-1] + if last.role == "assistant" { + last.thought += ev.thought + last.text += ev.visible + } + } + if ev.metrics != nil { + a.lastTokS = ev.metrics.DecodeTokensPerSec + } + if ev.err != nil { + a.errText = ev.err.Error() + } + if !ev.done { + if a.refreshAt.IsZero() { + a.refreshAt = time.Now().Add(streamRefreshInterval) + } + return a, waitEventOrRefresh(a.gen, a.refreshAt) + } + a.refreshAt = time.Time{} + a.generating = false + a.gen = nil + if cmd := a.runToolLoop(); cmd != nil { + a.refreshTranscriptOutput() + return a, cmd + } + a.toolHops = 0 + a.refreshTranscriptOutput() + return a, nil +} + +func (a app) onManagedStream(ev streamMsg, managed *sessionGeneration) (tea.Model, tea.Cmd) { + if managed == nil || a.sessions == nil || a.repository == nil { + return a, nil + } + now := time.Now().UTC() + if !managed.started { + managed.started = true + managed.job.Status = "generating" + managed.job.StartedAt = now + a.recordManagedPersistence(managed, a.repository.SaveJob(managed.job)) + a.recordManagedPersistence(managed, a.sessions.MarkGenerating(ev.SessionID, ev.JobID)) + } + managed.answer.Visible += ev.visible + managed.answer.Thought += ev.thought + managed.answer.UpdatedAt = now + if ev.visible != "" || ev.thought != "" { + managed.dirty = true + if managed.flushAt.IsZero() { + managed.flushAt = now.Add(streamRefreshInterval) + } + } + if ev.metrics != nil { + managed.job.MetricsJSON = core.JSONMarshalString(ev.metrics) + if a.sessionID == ev.SessionID { + a.lastTokS = ev.metrics.DecodeTokensPerSec + } + } + if ev.err != nil { + managed.job.Error = ev.err.Error() + } + if ev.done || ev.err != nil || (!managed.flushAt.IsZero() && !now.Before(managed.flushAt)) { + a.flushManagedGeneration(managed) + } + if !ev.done { + if managed.dirty { + return a, waitEventOrRefresh(managed.generation, managed.flushAt) + } + return a, waitEvent(managed.generation) + } + managed.job.FinishedAt = now + switch { + case managed.persistErr != "": + managed.job.Status = "failed" + managed.job.Error = managed.persistErr + case managed.cancelled: + managed.job.Status = "cancelled" + case ev.err != nil: + managed.job.Status = "failed" + default: + managed.job.Status = "completed" + } + if result := a.repository.SaveJob(managed.job); !result.OK { + a.recordManagedPersistence(managed, result) + managed.job.Status = "failed" + managed.job.Error = managed.persistErr + } + a.jobs.finish(ev.SessionID, ev.JobID) + delete(a.sessionJobs, ev.SessionID) + var continuation tea.Cmd + if managed.job.Status == "failed" { + if result := a.sessions.FailGeneration(ev.SessionID, ev.JobID); !result.OK { + a.errText = result.Error() + } + if result := a.persistGenerationEvent(ev.SessionID, ev.JobID, "generation.failed", "failed", managed.job.Error); !result.OK { + a.errText = result.Error() + } + } else if managed.job.Status == "completed" { + continued := a.continueManagedToolLoop(ev.SessionID, managed) + if !continued.OK { + a.errText = continued.Error() + } else { + continuation, _ = continued.Value.(tea.Cmd) + } + if continuation == nil { + if result := a.sessions.Complete(ev.SessionID); !result.OK { + a.errText = result.Error() + } + } + } else { + if result := a.sessions.cancelGeneration(ev.SessionID, ev.JobID); !result.OK { + a.errText = result.Error() + } + if result := a.persistGenerationEvent(ev.SessionID, ev.JobID, "generation.cancelled", "cancelled", managed.job.Error); !result.OK { + a.errText = result.Error() + } + } + active := a.sessions.Active() + if active != nil && active.Record.ID == ev.SessionID { + a.syncManagedSession(active, false) + if continuation == nil { + a.gen = nil + a.generating = false + a.toolHops = 0 + } + a.refreshTranscriptOutput() + } + return a, continuation +} + +func (a *app) flushManagedGeneration(managed *sessionGeneration) { + if a == nil || managed == nil || a.sessions == nil { + return + } + if managed.dirty { + a.recordManagedPersistence(managed, a.sessions.AddTurn(managed.answer)) + managed.dirty = false + } + managed.flushAt = time.Time{} + active := a.sessions.Active() + if active != nil && active.Record.ID == managed.answer.SessionID { + a.syncManagedSession(active, false) + a.refreshTranscriptOutput() + } +} + +func (a *app) recordManagedPersistence(managed *sessionGeneration, result core.Result) { + if result.OK || managed == nil { + return + } + if managed.persistErr == "" { + managed.persistErr = result.Error() + } + managed.job.Error = managed.persistErr + a.errText = result.Error() +} + +func (a *app) persistGenerationEvent(sessionID, jobID, kind, status, detail string) core.Result { + if a == nil || a.repository == nil { + return core.Ok(nil) + } + return a.repository.SaveEvent(eventRecord{ + ID: newRecordID(), SessionID: sessionID, JobID: jobID, Kind: kind, Status: status, + Title: core.Replace(kind, ".", " "), Detail: detail, PayloadJSON: "{}", CreatedAt: time.Now().UTC(), + }) +} + +func (a *app) continueManagedToolLoop(sessionID string, managed *sessionGeneration) core.Result { + if a == nil || managed == nil || !a.tools.enabled { + return core.Ok(tea.Cmd(nil)) + } + session := a.sessions.sessions[sessionID] + if session == nil || session.ToolHops >= 2 { + return core.Ok(tea.Cmd(nil)) + } + calls, visible := parser.ParseGemmaToolCalls(managed.answer.Visible) + if len(calls) == 0 { + if !core.Contains(managed.answer.Visible, parser.ToolCallOpenMarker) { + return core.Ok(tea.Cmd(nil)) + } + managed.answer.Visible = core.Trim(visible) + managed.answer.UpdatedAt = time.Now().UTC() + if result := a.sessions.AddTurn(managed.answer); !result.OK { + return result + } + failure := "error: malformed tool call" + toolTurn := turn{id: newRecordID(), role: "tool", text: parser.RenderGemmaToolResponse(failure)} + if result := a.persistSessionToolInteraction(sessionID, toolTurn, nil, failure, "tool.parse", "failed"); !result.OK { + return result + } + return core.Ok(tea.Cmd(nil)) + } + + managed.answer.Visible = core.Trim(visible) + managed.answer.ToolCallJSON = core.JSONMarshalString(calls) + managed.answer.UpdatedAt = time.Now().UTC() + if result := a.sessions.AddTurn(managed.answer); !result.OK { + return result + } + promptTurnID := managed.answer.ID + for _, call := range calls { + output := a.tools.execute(call) + status := "completed" + if core.HasPrefix(output, "error:") { + status = "failed" + } + toolTurn := turn{id: newRecordID(), role: "tool", text: parser.RenderGemmaToolResponse(output)} + if result := a.persistSessionToolInteraction(sessionID, toolTurn, &call, output, "tool.call", status); !result.OK { + return result + } + promptTurnID = toolTurn.id + } + session.ToolHops++ + return a.startManagedContinuation(session, promptTurnID) +} + +func (a *app) startManagedContinuation(session *chatSession, promptTurnID string) core.Result { + if a == nil || session == nil { + return core.Fail(core.E("tui.app.startManagedContinuation", "session is unavailable", nil)) + } + now := time.Now().UTC() + answer := turnRecord{ + ID: newRecordID(), SessionID: session.Record.ID, Sequence: int64(len(session.Turns) + 1), Role: "assistant", + ToolCallJSON: "{}", ToolResultJSON: "{}", Model: a.modelName, CreatedAt: now, UpdatedAt: now, + } + if result := a.sessions.AddTurn(answer); !result.OK { + return result + } + job := generationJobRecord{ + ID: newRecordID(), SessionID: session.Record.ID, PromptTurnID: promptTurnID, AnswerTurnID: answer.ID, + Status: "queued", Model: a.modelName, MetricsJSON: "{}", CreatedAt: now, + StartedAt: unsetRecordTime(), FinishedAt: unsetRecordTime(), + } + if result := a.repository.SaveJob(job); !result.OK { + return result + } + if result := a.sessions.BeginGeneration(session.Record.ID, job.ID); !result.OK { + return result + } + started := a.jobs.Start(session.Record.ID, job.ID, a.chatModel(), a.historyForSession(session), a.generateOpts()) + if !started.OK { + job.Status = "failed" + job.Error = started.Error() + job.FinishedAt = time.Now().UTC() + _ = a.repository.SaveJob(job) + _ = a.sessions.FailGeneration(session.Record.ID, job.ID) + return started + } + generation := started.Value.(*generation) + a.sessionJobs[session.Record.ID] = &sessionGeneration{generation: generation, answer: answer, job: job} + if active := a.sessions.Active(); active != nil && active.Record.ID == session.Record.ID { + a.syncManagedSession(active, false) + } + return core.Ok(waitEvent(generation)) +} + +// runToolLoop parses the finished assistant turn for tool calls; when the +// Tools tab armed them it executes each locally, appends the wrapped tool +// results, and auto-continues the conversation (bounded hops). +func (a *app) runToolLoop() tea.Cmd { + if !a.tools.enabled || a.toolHops >= 2 || len(a.turns) == 0 { + return nil + } + last := &a.turns[len(a.turns)-1] + if last.role != "assistant" { + return nil + } + calls, visible := parser.ParseGemmaToolCalls(last.text) + if len(calls) == 0 { + if !core.Contains(last.text, parser.ToolCallOpenMarker) { + return nil + } + last.text = core.Trim(visible) + failure := "error: malformed tool call" + last.calls = append(last.calls, "malformed tool call → failed") + toolTurn := turn{id: newRecordID(), role: "tool", text: parser.RenderGemmaToolResponse(failure)} + a.turns = append(a.turns, toolTurn) + if result := a.persistToolInteraction(toolTurn, nil, failure, "tool.parse", "failed"); !result.OK { + a.errText = result.Error() + } + return nil + } + last.text = core.Trim(visible) + for _, call := range calls { + result := a.tools.execute(call) + last.calls = append(last.calls, call.Name+" → "+result) + toolTurn := turn{id: newRecordID(), role: "tool", text: parser.RenderGemmaToolResponse(result)} + a.turns = append(a.turns, toolTurn) + status := "completed" + if core.HasPrefix(result, "error:") { + status = "failed" + } + if persisted := a.persistToolInteraction(toolTurn, &call, result, "tool.call", status); !persisted.OK { + a.errText = persisted.Error() + return nil + } + } + a.turns = append(a.turns, turn{id: newRecordID(), role: "assistant", model: a.modelName}) + a.toolHops++ + return a.beginGeneration() +} + +func (a *app) persistToolInteraction( + toolTurn turn, + call *inference.ToolCall, + result string, + kind string, + status string, +) core.Result { + if a == nil { + return core.Ok(nil) + } + return a.persistSessionToolInteraction(a.sessionID, toolTurn, call, result, kind, status) +} + +func (a *app) persistSessionToolInteraction( + sessionID string, + toolTurn turn, + call *inference.ToolCall, + result string, + kind string, + status string, +) core.Result { + if a == nil || a.repository == nil || core.Trim(sessionID) == "" { + return core.Ok(nil) + } + now := time.Now().UTC() + toolName := "parse" + callJSON := "{}" + if call != nil { + toolName = call.Name + callJSON = core.JSONMarshalString(call) + } + sequence := int64(len(a.turns)) + if a.sessions != nil { + if session := a.sessions.sessions[sessionID]; session != nil { + sequence = int64(len(session.Turns) + 1) + } + } + record := turnRecord{ + ID: toolTurn.id, + SessionID: sessionID, + Sequence: sequence, + Role: toolTurn.role, + Visible: toolTurn.text, + ToolName: toolName, + ToolCallJSON: callJSON, + ToolResultJSON: core.JSONMarshalString(map[string]any{"result": result, "status": status}), + Model: a.modelName, + CreatedAt: now, + UpdatedAt: now, + } + var saved core.Result + if a.sessions != nil { + saved = a.sessions.AddTurn(record) + } else { + saved = a.repository.SaveTurn(record) + } + if !saved.OK { + return saved + } + event := eventRecord{ + ID: newRecordID(), + SessionID: sessionID, + Kind: kind, + Status: status, + Title: core.Concat("Tool ", toolName, " ", status), + Detail: result, + PayloadJSON: callJSON, + CreatedAt: now, + } + return a.repository.SaveEvent(event) +} + +func (a app) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if a.boot.phase != bootReady { + return a.onBootKey(msg) + } + if a.activeOverlay != overlayNone && msg.String() != "ctrl+c" { + return a.onOverlayKey(msg) + } + if key.Matches(msg, a.keys.CommandPalette) { + a.refreshAgentPalette() + // Mirrors refreshAgentPalette's own precedent immediately above: + // a selection/status change on the Data panel with no action yet + // (e.g. j/k navigation alone) never itself calls + // refreshDataPalette, so the palette must refresh availability + // itself right before it opens, not rely on stale state from the + // last write. + a.refreshDataPalette() + a.palette.Open() + a.activeOverlay = overlayCommands + return a, nil + } + if key.Matches(msg, a.keys.SwitchSession) { + if result := a.openSessionSwitcher(); !result.OK { + a.errText = result.Error() + } + return a, nil + } + if key.Matches(msg, a.keys.Search) { + if result := a.openHistorySearch(); !result.OK { + a.errText = result.Error() + } + return a, nil + } + if key.Matches(msg, a.keys.Help) { + a.activeOverlay = overlayHelp + return a, nil + } + if key.Matches(msg, a.keys.NewSession) { + if result := a.createSession(); !result.OK { + a.errText = result.Error() + } + return a, nil + } + if key.Matches(msg, a.keys.PreviousSession) { + if a.sessions == nil { + a.errText = "session workspace is not connected" + } else if result := a.sessions.Previous(); !result.OK { + a.errText = result.Error() + } else { + a.activateManagedSession(a.sessions.Active()) + } + return a, nil + } + if key.Matches(msg, a.keys.NextSession) { + if a.sessions == nil { + a.errText = "session workspace is not connected" + } else if result := a.sessions.Next(); !result.OK { + a.errText = result.Error() + } else { + a.activateManagedSession(a.sessions.Active()) + } + return a, nil + } + if key.Matches(msg, a.keys.Save) { + if result := a.inspector.Save(&a); !result.OK { + a.errText = result.Error() + } + return a, nil + } + if key.Matches(msg, a.keys.ToggleInspector) { + a.toggleInspector() + return a, nil + } + if a.inspectorOpen { + if a.activePanel == panelWork { + switch msg.String() { + case "up", "k": + if a.work != nil { + a.work.MoveAction(-1) + } + return a, nil + case "down", "j": + if a.work != nil { + a.work.MoveAction(1) + } + return a, nil + case "enter": + if a.work == nil { + a.errText = defaultAgentUnavailableReason + } else if result := a.queueAgentAction(a.work.SelectedAction().Feature); !result.OK { + a.errText = result.Error() + } + return a, a.takeAgentCommand() + case "left", "h", "right", "l": + // Work inspector actions do not edit Chat generation settings. + return a, nil + } + } + switch msg.String() { + case "up", "k": + a.inspector.Move(-1) + return a, nil + case "down", "j": + a.inspector.Move(1) + return a, nil + case "left", "h": + if result := a.inspector.Adjust(&a, -1); !result.OK { + a.errText = result.Error() + } + return a, nil + case "right", "l": + if result := a.inspector.Adjust(&a, 1); !result.OK { + a.errText = result.Error() + } + return a, nil + case "enter": + if result := a.inspector.Adjust(&a, 0); !result.OK { + a.errText = result.Error() + } + return a, nil + } + } + switch msg.String() { + case "ctrl+c": + _ = a.shutdown() + return a, tea.Quit + case "tab", "shift+tab": + if msg.String() == "tab" { + return a, a.selectPanel(a.activePanel.next()) + } + return a, a.selectPanel(a.activePanel.prev()) + case "esc": + if a.generating && a.gen != nil { + if managed := a.sessionJobs[a.gen.SessionID]; managed != nil { + managed.cancelled = true + } + _ = a.jobs.Cancel(a.gen.SessionID) // stream drains to tagged done + return a, nil + } + case "ctrl+t": + // quick thinking toggle: flips between explicit on and off + if a.cfg.thinkIdx == 2 { + a.cfg.thinkIdx = 1 + } else { + a.cfg.thinkIdx = 2 + } + return a, nil + case "end": + if a.activePanel == panelChat { + a.view.GotoBottom() + a.follow = true + a.newOutput = false + return a, nil + } + case "home": + if a.activePanel == panelChat { + a.view.GotoTop() + a.follow = false + return a, nil + } + } + + switch a.activePanel { + case panelModels: + if msg.String() == "enter" && a.loading == "" { + if item, ok := a.picker.SelectedItem().(modelItem); ok { + return a, a.requestModelLoad(item.path) + } + return a, nil + } + case panelService: + switch msg.String() { + case "enter": + if a.svc.running { + a.svc.stop() + return a, nil + } + return a, a.svc.start(a.model) + case "left", "h": + if !a.svc.running { + a.svc.addrIdx = (a.svc.addrIdx + len(serviceAddrs) - 1) % len(serviceAddrs) + } + return a, nil + case "right", "l": + if !a.svc.running { + a.svc.addrIdx = (a.svc.addrIdx + 1) % len(serviceAddrs) + } + return a, nil + } + case panelData: + // The keyboard idiom: j/k select (bubbles list default, reached via + // route() below for anything not matched here); lowercase acts on + // the selected item; uppercase runs the same action in bulk across + // the current filter. Every key below has a mirrored palette entry + // (dataWorkspaceCommandsForContext, palette.go) — this switch is a + // keyboard shortcut for the same dataPanel/app methods the palette + // invokes, never a second code path. + if a.data != nil { + switch msg.String() { + case "a": + if result := a.applyDataAction(dataActionApprove); !result.OK { + a.errText = result.Error() + } + return a, nil + case "r": + if result := a.applyDataAction(dataActionReject); !result.OK { + a.errText = result.Error() + } + return a, nil + case "c": + if result := a.openDataNote(dataActionQuarantineClear, dataSelectedItemID(a.data)); !result.OK { + a.errText = result.Error() + } + return a, nil + case "e": + if result := a.openDataEditor(); !result.OK { + a.errText = result.Error() + } + return a, nil + case "t": + if result := a.openDataNote(dataActionTag, dataSelectedItemID(a.data)); !result.OK { + a.errText = result.Error() + } + return a, nil + case "A": + if result := a.openDataBulk(dataActionApprove); !result.OK { + a.errText = result.Error() + } + return a, nil + case "R": + if result := a.openDataBulk(dataActionReject); !result.OK { + a.errText = result.Error() + } + return a, nil + case "C": + if result := a.openDataBulk(dataActionQuarantineClear); !result.OK { + a.errText = result.Error() + } + return a, nil + case "T": + if result := a.openDataBulk(dataActionTag); !result.OK { + a.errText = result.Error() + } + return a, nil + case "s": + if result := a.data.ToggleSort(); !result.OK { + a.errText = result.Error() + } + return a, nil + case "f": + if result := a.openDataFilter(); !result.OK { + a.errText = result.Error() + } + return a, nil + } + } + case panelChat: + if msg.Type == tea.KeyEnter && msg.Alt && !a.generating { + // Textarea treats Enter as a newline; the app reserves plain Enter + // for send, so explicitly forward the modified form to the editor. + return a.route(tea.KeyMsg{Type: tea.KeyEnter}) + } + if msg.String() == "enter" && !a.generating && a.model != nil { + prompt := core.Trim(a.input.Value()) + if prompt == "" { + return a, nil + } + result := a.sendPrompt(prompt) + if !result.OK { + a.errText = result.Error() + return a, nil + } + command, _ := result.Value.(tea.Cmd) + return a, command + } + } + return a.route(msg) +} + +func (a *app) toggleInspector() { + if a == nil { + return + } + a.inspectorOpen = !a.inspectorOpen + if !a.ready { + return + } + metrics := measureFrame(a.width, a.height, a.inspectorOpen) + a.picker.SetSize(max(1, metrics.mainWidth), max(1, metrics.mainHeight)) + a.input.SetWidth(max(1, metrics.mainWidth-4)) + a.view.Width = max(1, metrics.mainWidth) + a.view.Height = a.transcriptHeight() + a.refreshTranscript() +} + +func (a *app) requestModelLoad(path string) tea.Cmd { + if a == nil { + return nil + } + path = core.Trim(path) + if path == "" { + a.errText = "model path is empty" + return nil + } + if a.jobs != nil && a.jobs.ActiveCount() > 0 { + a.errText = "model change refused while session jobs are active" + return nil + } + if a.svc.running { + a.pendingModel = path + a.svc.note = "draining service before model change" + a.svc.stop() + return nil + } + return a.beginModelLoad(path) +} + +func (a *app) beginModelLoad(path string) tea.Cmd { + if a == nil { + return nil + } + if a.lane != nil { + if result := a.lane.Close(); !result.OK { + a.errText = result.Error() + return nil + } + } + a.lane = nil + a.model = nil + a.modelName = "" + a.loading = path + a.activePanel = panelModels + loader := a.modelLoader + if loader == nil { + loader = loadModel + } + return a.lifecycle.command(loader(path, a.cfg.contextLen())) +} + +func (a *app) sendPrompt(prompt string) core.Result { + if a == nil || a.model == nil { + return core.Fail(core.E("tui.app.sendPrompt", "a loaded model is required", nil)) + } + prompt = core.Trim(prompt) + if prompt == "" { + return core.Fail(core.E("tui.app.sendPrompt", "prompt is required", nil)) + } + if a.sessions == nil || a.repository == nil { + a.input.Reset() + a.turns = append(a.turns, + turn{id: newRecordID(), role: "user", text: prompt}, + turn{id: newRecordID(), role: "assistant", model: a.modelName}, + ) + a.follow = true + a.newOutput = false + a.toolHops = 0 + a.errText = "" + command := a.beginGeneration() + a.refreshTranscript() + return core.Ok(command) + } + session := a.sessions.Active() + if session == nil || session.Record.ID != a.sessionID { + return core.Fail(core.E("tui.app.sendPrompt", "active session is unavailable", nil)) + } + if a.sessionJobs[session.Record.ID] != nil || session.ActiveJobID != "" { + return core.Fail(core.E("tui.app.sendPrompt", "session already has a running generation", nil)) + } + now := time.Now().UTC() + sequence := int64(len(session.Turns) + 1) + userRecord := turnRecord{ + ID: newRecordID(), SessionID: session.Record.ID, Sequence: sequence, Role: "user", Visible: prompt, + ToolCallJSON: "{}", ToolResultJSON: "{}", CreatedAt: now, UpdatedAt: now, + } + answerRecord := turnRecord{ + ID: newRecordID(), SessionID: session.Record.ID, Sequence: sequence + 1, Role: "assistant", + ToolCallJSON: "{}", ToolResultJSON: "{}", Model: a.modelName, CreatedAt: now, UpdatedAt: now, + } + if result := a.sessions.AddTurn(userRecord); !result.OK { + return result + } + if result := a.sessions.AddTurn(answerRecord); !result.OK { + return result + } + a.syncManagedSession(session, false) + job := generationJobRecord{ + ID: newRecordID(), SessionID: session.Record.ID, PromptTurnID: userRecord.ID, AnswerTurnID: answerRecord.ID, + Status: "queued", Model: a.modelName, MetricsJSON: "{}", CreatedAt: now, + StartedAt: unsetRecordTime(), FinishedAt: unsetRecordTime(), + } + if result := a.repository.SaveJob(job); !result.OK { + return result + } + if result := a.sessions.BeginGeneration(session.Record.ID, job.ID); !result.OK { + return result + } + started := a.jobs.Start(session.Record.ID, job.ID, a.chatModel(), a.history(), a.generateOpts()) + if !started.OK { + job.Status = "failed" + job.Error = started.Error() + job.FinishedAt = time.Now().UTC() + _ = a.repository.SaveJob(job) + _ = a.sessions.FailGeneration(session.Record.ID, job.ID) + return started + } + generation := started.Value.(*generation) + a.sessionJobs[session.Record.ID] = &sessionGeneration{ + generation: generation, + answer: answerRecord, + job: job, + } + session.ActiveJobID = job.ID + a.gen = generation + a.generating = true + a.input.Reset() + _ = a.sessions.SetDraft(session.Record.ID, "") + a.follow = true + a.newOutput = false + a.toolHops = 0 + a.errText = "" + a.refreshTranscript() + return core.Ok(waitEvent(generation)) +} + +func (a app) onBootKey(message tea.KeyMsg) (tea.Model, tea.Cmd) { + switch message.String() { + case "ctrl+c", "q": + _ = a.shutdown() + return a, tea.Quit + case "r": + if a.boot.phase == bootFailed { + a.boot = bootState{phase: bootLoading} + return a, a.lifecycle.command(workspaceBootstrap(a.workspaceLoader)) + } + } + return a, nil +} + +func (a *app) shutdown() core.Result { + if a == nil { + return core.Ok(nil) + } + if a.lifecycle == nil { + a.lifecycle = newAppLifecycle(context.Background(), nil) + } + a.lifecycle.shutdownMu.Lock() + defer a.lifecycle.shutdownMu.Unlock() + if a.lifecycle.shutdownComplete { + return a.lifecycle.result + } + failures := make([]string, 0) + record := func(candidate core.Result) { + if !candidate.OK { + failures = append(failures, candidate.Error()) + } + } + a.lifecycle.stop() + if a.jobs != nil { + record(a.jobs.CancelAll()) + } + record(a.drainManagedGenerations()) + agentClosed := true + if a.resources != nil { + closeResult := a.resources.closeAgent() + record(closeResult) + agentClosed = closeResult.OK + } else if a.agent != nil { + closeResult := a.agent.Close() + record(closeResult) + agentClosed = closeResult.OK + if closeResult.OK { + a.agent = nil + } + } + a.svc.teardown("stopped (quit)") + if a.lane != nil { + record(a.lane.Close()) + a.lane = nil + a.model = nil + } + a.lifecycle.wait() + a.lifecycle.closePending() + if a.resources != nil && agentClosed { + record(a.resources.Close()) + } + if len(failures) > 0 { + a.lifecycle.result = core.Fail(core.E("tui.app.shutdown", core.Join("; ", failures...), nil)) + return a.lifecycle.result + } + a.lifecycle.result = core.Ok(nil) + a.lifecycle.shutdownComplete = true + return a.lifecycle.result +} + +// drainManagedGenerations folds every buffered delta and a terminal +// cancellation into durable state before the workspace database closes. +func (a *app) drainManagedGenerations() core.Result { + if a == nil || len(a.sessionJobs) == 0 { + return core.Ok(nil) + } + managedJobs := make([]*sessionGeneration, 0, len(a.sessionJobs)) + firstFailure := "" + for _, managed := range a.sessionJobs { + if managed != nil && managed.generation != nil { + managed.cancelled = true + managedJobs = append(managedJobs, managed) + } + } + for _, managed := range managedJobs { + generation := managed.generation + terminal := false + for event := range generation.events { + model, _ := a.onManagedStream(streamMsg(event), managed) + *a = model.(app) + if event.done { + terminal = true + } + } + if !terminal { + model, _ := a.onManagedStream(streamMsg{ + SessionID: generation.SessionID, + JobID: generation.JobID, + done: true, + }, managed) + *a = model.(app) + } + if managed.persistErr != "" && firstFailure == "" { + firstFailure = managed.persistErr + } + } + if firstFailure != "" { + return core.Fail(core.E("tui.app.drainManagedGenerations", "persist terminal generation state", core.NewError(firstFailure))) + } + return core.Ok(nil) +} + +func (a app) onOverlayKey(message tea.KeyMsg) (tea.Model, tea.Cmd) { + if message.String() == "esc" { + overlay := a.activeOverlay + if a.launchReview != nil { + a.launchReview.Update(message) + } + a.activeOverlay = overlayNone + a.workEditor, a.launchReview, a.answerOverlay, a.changeOverlay = nil, nil, nil, nil + a.dataEditor, a.dataNote, a.dataBulk, a.dataFilter = nil, nil, nil, nil + switch overlay { + case overlayAgentSelection, overlayProjectReview, overlayGitEnableReview, overlayLaunchReview, overlayAgentAnswer, overlayChangeReview: + a.abortAgentOperation() + } + return a, nil + } + if a.activeOverlay == overlayWorkEditor && message.String() == "enter" && a.workEditor != nil && a.workEditor.focus == 1 { + return a, a.workEditor.Update(message) + } + if a.activeOverlay == overlayWorkEditor && message.String() == "ctrl+s" { + if result := a.saveWorkEditor(); !result.OK { + a.errText = result.Error() + return a, nil + } + a.activeOverlay, a.workEditor = overlayNone, nil + return a, nil + } + if a.activeOverlay == overlayDataEditor && message.String() == "enter" { + // Both fields are multi-line textareas — Enter always inserts a + // newline in the focused one; only ctrl+s saves (unlike + // overlayWorkEditor, which mixes single-line and multi-line + // fields and so only forwards Enter for its one textarea field). + return a, a.dataEditor.Update(message) + } + if a.activeOverlay == overlayDataEditor && message.String() == "ctrl+s" { + if result := a.saveDataEditor(); !result.OK { + a.errText = result.Error() + return a, nil + } + a.activeOverlay, a.dataEditor = overlayNone, nil + return a, nil + } + if a.activeOverlay == overlayGitEnableReview && message.String() != "enter" { + if a.launchReview != nil { + a.launchReview.Update(message) + } + return a, nil + } + if a.activeOverlay == overlayAgentSelection && message.String() != "enter" { + if a.launchReview != nil { + a.launchReview.Update(message) + } + return a, nil + } + if message.String() == "enter" { + switch a.activeOverlay { + case overlayCommands: + id := a.palette.SelectedID() + a.activeOverlay = overlayNone + if result := a.palette.Invoke(id, &a); !result.OK { + a.errText = result.Error() + } + return a, a.takeAgentCommand() + case overlayWorkEditor: + if result := a.saveWorkEditor(); !result.OK { + a.errText = result.Error() + return a, nil + } + a.activeOverlay, a.workEditor = overlayNone, nil + case overlayAgentAnswer: + if a.answerOverlay == nil || !a.answerOverlay.Update(message) { + a.errText = "an answer is required" + return a, nil + } + a.agentRequest.Input = a.answerOverlay.answer() + a.agentInFlight = true + a.agentCommand = a.lifecycle.command(a.agentRunCommand(a.agentOperationID, a.agentRequest, agentReviewNone)) + a.activeOverlay, a.answerOverlay = overlayNone, nil + return a, a.takeAgentCommand() + case overlayChangeReview: + if a.changeOverlay == nil { + a.errText = "change review is unavailable" + return a, nil + } + if !a.changeOverlay.review.AcceptanceAllowed { + a.errText = "conflicts or failed validation prevent acceptance; reject this reviewed result or retry the run" + return a, nil + } + if a.changeOverlay.review.NeedsAcknowledgement && !a.changeOverlay.acknowledged { + a.errText = "acknowledge the missing validation before acceptance" + return a, nil + } + if !a.changeOverlay.final { + a.changeOverlay.final = true + return a, nil + } + request := a.agentRequest + request.Feature, request.Review, request.Confirmed = agentFeatureAccept, a.changeOverlay.review, true + a.agentRequest, a.agentInFlight = request, true + a.agentCommand = a.lifecycle.command(a.agentRunCommand(a.agentOperationID, request, agentReviewLaunch)) + a.activeOverlay, a.changeOverlay = overlayNone, nil + return a, a.takeAgentCommand() + case overlayAgentSelection: + if result := a.confirmAgentSelection(); !result.OK { + a.errText = result.Error() + return a, nil + } + a.activeOverlay, a.launchReview = overlayNone, nil + return a, a.takeAgentCommand() + case overlayProjectReview: + if a.agentReview.GitConfirmRequired { + a.launchReview = newLaunchReviewOverlay(agentReview{ + Feature: agentFeatureDispatch, Title: "Enable Git for this directory", Body: a.agentReview.Body, + Warning: "This creates Git metadata and an initial local commit only after confirmation.", ConfirmRequired: true, + }, a.agentRequest.Provider, a.agentRequest.Model) + a.activeOverlay = overlayGitEnableReview + return a, nil + } + a.queueAgentRun(true, false) + a.activeOverlay = overlayNone + return a, a.takeAgentCommand() + case overlayGitEnableReview: + if a.launchReview != nil { + a.launchReview.Update(message) + } + a.queueAgentRun(true, true) + a.activeOverlay, a.launchReview = overlayNone, nil + return a, a.takeAgentCommand() + case overlayLaunchReview: + if a.launchReview != nil { + a.launchReview.Update(message) + } + a.queueAgentRun(true, false) + a.activeOverlay, a.launchReview = overlayNone, nil + return a, a.takeAgentCommand() + case overlaySessions: + if a.switcher == nil { + a.errText = "session switcher is unavailable" + } else if result := a.switcher.ActivateSelected(); !result.OK { + a.errText = result.Error() + } else { + a.activateManagedSession(a.sessions.Active()) + a.activeOverlay = overlayNone + } + case overlaySearch: + if a.search == nil { + a.errText = "history search is unavailable" + } else if result := a.search.ActivateSelected(); !result.OK { + a.errText = result.Error() + } else { + a.activateManagedSession(a.sessions.Active()) + offset := a.transcriptTurnOffset(a.search.MatchTurnID()) + a.view.SetYOffset(offset) + a.follow = false + if result := a.sessions.SetViewport(a.sessionID, a.view.YOffset, false); !result.OK { + a.errText = result.Error() + } + a.activeOverlay = overlayNone + } + case overlayDataNote: + if a.dataNote == nil || !a.dataNote.Update(message) { + a.errText = "a value is required" + return a, nil + } + if result := a.submitDataNote(); !result.OK { + a.errText = result.Error() + } + case overlayDataFilter: + if a.dataFilter == nil || !a.dataFilter.Update(message) { + return a, nil + } + if result := a.applyDataFilter(); !result.OK { + a.errText = result.Error() + } + case overlayDataBulk: + if a.dataBulk == nil || !a.dataBulk.Confirm(message.String()) { + // The first Enter only arms the overlay (see + // dataBulkOverlay.Confirm) — nothing to apply yet, and the + // re-rendered View() shows the "confirm again" prompt. + return a, nil + } + if result := a.confirmDataBulk(); !result.OK { + a.errText = result.Error() + } + } + return a, nil + } + + var command tea.Cmd + switch a.activeOverlay { + case overlayCommands: + command = a.palette.Update(message) + case overlaySessions: + if a.switcher != nil { + command = a.switcher.Update(message) + } + case overlaySearch: + if a.search != nil { + command = a.search.Update(message) + } + case overlayHelp: + // Help is read-only; every key except Escape is intentionally consumed. + case overlayWorkEditor: + if a.workEditor != nil { + command = a.workEditor.Update(message) + } + case overlayDataEditor: + if a.dataEditor != nil { + command = a.dataEditor.Update(message) + } + case overlayDataNote: + if a.dataNote != nil { + a.dataNote.Update(message) + } + case overlayDataFilter: + if a.dataFilter != nil { + a.dataFilter.Update(message) + } + case overlayDataBulk: + // Deliberately consumes keys until an explicit confirm or cancel — + // Confirm only ever fires from the "enter" branch above. + case overlayAgentAnswer: + if a.answerOverlay != nil { + a.answerOverlay.Update(message) + } + case overlayChangeReview: + if a.changeOverlay != nil { + if message.String() == "a" { + a.changeOverlay.acknowledged = true + } else { + var ignored tea.Cmd + a.changeOverlay.viewport, ignored = a.changeOverlay.viewport.Update(message) + _ = ignored + } + } + case overlayProjectReview, overlayGitEnableReview, overlayLaunchReview, overlayAgentSelection: + // Reviews deliberately consume keys until an explicit confirm or cancel. + } + return a, command +} + +func (a *app) createSession() core.Result { + if a.sessions != nil { + result := a.sessions.Create() + if !result.OK { + return result + } + a.activateManagedSession(result.Value.(*chatSession)) + return core.Ok(result.Value) + } + if a.generating { + return core.Fail(core.E("tui.app.createSession", "connect the persistent workspace before creating a session during generation", nil)) + } + a.sessionID = newRecordID() + a.turns = nil + a.attachments = nil + a.input.Reset() + a.follow = true + a.newOutput = false + a.activePanel = panelChat + a.refreshTranscript() + return core.Ok(a.sessionID) +} + +func (a *app) openSessionSwitcher() core.Result { + if a.sessions == nil { + return core.Fail(core.E("tui.app.openSessionSwitcher", "session workspace is not connected", nil)) + } + metrics := measureFrame(a.width, a.height, a.inspectorOpen) + result := newSessionSwitcher(a.sessions, a.styles, metrics.mainWidth, metrics.mainHeight) + if !result.OK { + return result + } + a.switcher = result.Value.(*sessionSwitcher) + a.activeOverlay = overlaySessions + return core.Ok(a.switcher) +} + +func (a *app) openHistorySearch() core.Result { + if a.sessions == nil || a.repository == nil { + return core.Fail(core.E("tui.app.openHistorySearch", "history workspace is not connected", nil)) + } + metrics := measureFrame(a.width, a.height, a.inspectorOpen) + result := newHistorySearch(a.repository, a.sessions, a.styles, metrics.mainWidth, metrics.mainHeight) + if !result.OK { + return result + } + a.search = result.Value.(*historySearch) + a.activeOverlay = overlaySearch + return core.Ok(a.search) +} + +func (a *app) activateManagedSession(session *chatSession) { + if a == nil || session == nil { + return + } + a.syncManagedSession(session, true) + a.attachments = nil + if result := a.reloadKnowledgeAttachments(); !result.OK { + a.errText = result.Error() + } + a.refreshTranscript() + if !a.follow { + a.view.SetYOffset(session.ViewportOffset) + } +} + +func (a *app) syncManagedSession(session *chatSession, activatePanel bool) { + if a == nil || session == nil { + return + } + a.sessionID = session.Record.ID + a.turns = make([]turn, 0, len(session.Turns)) + for _, record := range session.Turns { + a.turns = append(a.turns, turn{ + id: record.ID, + role: record.Role, + model: record.Model, + thought: record.Thought, + text: record.Visible, + calls: persistedTurnCalls(record), + }) + } + a.input.SetValue(session.Draft) + a.follow = session.Follow + a.newOutput = session.Attention + a.toolHops = session.ToolHops + if managed := a.sessionJobs[session.Record.ID]; managed != nil { + a.gen = managed.generation + a.generating = true + } else { + a.gen = nil + a.generating = false } - a.generating = false - a.gen = nil - if cmd := a.runToolLoop(); cmd != nil { - a.refreshTranscript() - return a, cmd + if activatePanel { + a.activePanel = panelChat } - a.toolHops = 0 - a.refreshTranscript() - return a, nil } -// runToolLoop parses the finished assistant turn for tool calls; when the -// Tools tab armed them it executes each locally, appends the wrapped tool -// results, and auto-continues the conversation (bounded hops). -func (a *app) runToolLoop() tea.Cmd { - if !a.tools.enabled || a.toolHops >= 2 || len(a.turns) == 0 { - return nil +func persistedTurnCalls(record turnRecord) []string { + if record.Role == "tool" && record.ToolName != "" { + return []string{record.ToolName} } - last := &a.turns[len(a.turns)-1] - if last.role != "assistant" { + if record.Role != "assistant" || record.ToolCallJSON == "" || record.ToolCallJSON == "{}" { return nil } - calls, visible := parser.ParseGemmaToolCalls(last.text) - if len(calls) == 0 { + var calls []inference.ToolCall + if result := core.JSONUnmarshalString(record.ToolCallJSON, &calls); !result.OK { return nil } - last.text = strings.TrimSpace(visible) + receipts := make([]string, 0, len(calls)) for _, call := range calls { - result := a.tools.execute(call) - last.calls = append(last.calls, call.Name+" → "+result) - a.turns = append(a.turns, turn{role: "tool", text: parser.RenderGemmaToolResponse(result)}) + receipts = append(receipts, core.Concat(call.Name, " → requested")) } - a.turns = append(a.turns, turn{role: "assistant"}) - a.toolHops++ - a.generating = true - a.gen = startGeneration(a.chatModel(), a.history(), a.generateOpts()) - return waitEvent(a.gen) + return receipts } -func (a app) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "ctrl+c": - if a.gen != nil { - a.gen.cancel() - } - a.svc.teardown("stopped (quit)") - if a.model != nil { - _ = a.model.Close() - } - return a, tea.Quit - case "tab", "shift+tab": - if msg.String() == "tab" { - a.activeTab = a.activeTab.next() - } else { - a.activeTab = a.activeTab.prev() - } - if a.activeTab == tabModels && len(a.picker.Items()) == 0 { - return a, discoverModels +// route hands the message to the focused component for the active panel. +func (a app) route(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + switch a.activePanel { + case panelModels: + a.picker, cmd = a.picker.Update(msg) + case panelWork: + if a.work != nil { + cmd = a.work.Update(msg) } - return a, nil - case "esc": - if a.generating { - a.gen.cancel() // stops the turn; the stream drains to done - return a, nil + case panelData: + if a.data != nil { + cmd = a.data.Update(msg) } - case "ctrl+t": - // quick thinking toggle: flips between explicit on and off - if a.cfg.thinkIdx == 2 { - a.cfg.thinkIdx = 1 + case panelChat: + _, mouse := msg.(tea.MouseMsg) + if a.generating || mouse || isTranscriptPageKey(msg) { + cmd = a.updateTranscriptViewport(msg) } else { - a.cfg.thinkIdx = 2 - } - return a, nil - } - - switch a.activeTab { - case tabModels: - if msg.String() == "enter" && a.loading == "" { - if item, ok := a.picker.SelectedItem().(modelItem); ok { - // the service serves THIS model's weights — stop it before they change - a.svc.teardown("stopped — model changing") - a.loading = item.path - return a, tea.Batch(a.spin.Tick, loadModel(item.path, a.cfg.contextLen())) - } - return a, nil - } - case tabService: - switch msg.String() { - case "enter": - if a.svc.running { - if a.generating { - a.svc.note = "a reply is streaming — esc it or let it finish, then stop" - return a, nil + before := a.input.Value() + a.input, cmd = a.input.Update(msg) + if a.sessions != nil && a.input.Value() != before { + if result := a.sessions.SetDraft(a.sessionID, a.input.Value()); !result.OK { + a.errText = result.Error() } - a.svc.stop() - return a, nil - } - return a, a.svc.start(a.model) - case "left", "h": - if !a.svc.running { - a.svc.addrIdx = (a.svc.addrIdx + len(serviceAddrs) - 1) % len(serviceAddrs) - } - return a, nil - case "right", "l": - if !a.svc.running { - a.svc.addrIdx = (a.svc.addrIdx + 1) % len(serviceAddrs) } - return a, nil - } - case tabSettings: - switch msg.String() { - case "up", "k": - a.cfg = a.cfg.move(-1) - return a, nil - case "down", "j": - a.cfg = a.cfg.move(1) - return a, nil - case "left", "h": - a.cfg = a.cfg.adjust(-1) - return a, nil - case "right", "l", "enter": - a.cfg = a.cfg.adjust(1) - return a, nil } - case tabModes: - switch msg.String() { - case "up", "k": - a.modes = a.modes.move(-1) - return a, nil - case "down", "j": - a.modes = a.modes.move(1) - return a, nil - } - case tabTools: - if msg.String() == "enter" { - a.tools.enabled = !a.tools.enabled - return a, nil + } + return a, cmd +} + +func (a *app) updateTranscriptViewport(msg tea.Msg) tea.Cmd { + before := a.view.YOffset + var cmd tea.Cmd + a.view, cmd = a.view.Update(msg) + if a.view.YOffset < before || scrollsTranscriptUp(msg) { + a.follow = false + } + if a.view.AtBottom() && scrollsTranscriptDown(msg) { + a.follow = true + a.newOutput = false + } + if a.sessions != nil { + if result := a.sessions.SetViewport(a.sessionID, a.view.YOffset, a.follow); !result.OK { + a.errText = result.Error() } - case tabChat: - if msg.String() == "enter" && !a.generating && a.model != nil { - prompt := strings.TrimSpace(a.input.Value()) - if prompt == "" { - return a, nil - } - a.input.Reset() - a.turns = append(a.turns, turn{role: "user", text: prompt}, turn{role: "assistant"}) - a.generating = true - a.toolHops = 0 - a.errText = "" - a.gen = startGeneration(a.chatModel(), a.history(), a.generateOpts()) - a.refreshTranscript() - return a, waitEvent(a.gen) + } + return cmd +} + +func isTranscriptPageKey(msg tea.Msg) bool { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return false + } + switch keyMsg.String() { + case "pgup", "pgdown", "ctrl+u", "ctrl+d": + return true + default: + return false + } +} + +func scrollsTranscriptUp(msg tea.Msg) bool { + switch value := msg.(type) { + case tea.KeyMsg: + switch value.String() { + case "up", "k", "pgup", "ctrl+u": + return true } + case tea.MouseMsg: + return value.Action == tea.MouseActionPress && value.Button == tea.MouseButtonWheelUp && !value.Shift } - return a.route(msg) + return false } -// route hands the message to the focused component for the active tab. -func (a app) route(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - switch a.activeTab { - case tabModels: - a.picker, cmd = a.picker.Update(msg) - case tabChat: - if a.generating { - a.view, cmd = a.view.Update(msg) - } else { - a.input, cmd = a.input.Update(msg) +func scrollsTranscriptDown(msg tea.Msg) bool { + switch value := msg.(type) { + case tea.KeyMsg: + switch value.String() { + case "down", "j", "pgdown", "ctrl+d": + return true } + case tea.MouseMsg: + return value.Action == tea.MouseActionPress && value.Button == tea.MouseButtonWheelDown && !value.Shift } - return a, cmd + return false } -// chatModel is the model TUI turns generate on: the service's serial lane -// while the API is up (so terminal turns and HTTP requests queue, never race), -// the raw model otherwise. +// chatModel is always the application's shared serial lane. Starting or +// stopping the HTTP service cannot change generation ownership or ordering. func (a app) chatModel() inference.TextModel { - if a.svc.running && a.svc.sched != nil { - return a.svc.sched - } return a.model } +// beginGeneration registers the active session turn with the job manager and +// returns Bubble Tea's first wait command. +func (a *app) beginGeneration() tea.Cmd { + result := a.jobs.Start(a.sessionID, newRecordID(), a.chatModel(), a.history(), a.generateOpts()) + if !result.OK { + a.errText = result.Error() + a.generating = false + a.gen = nil + return nil + } + a.gen = result.Value.(*generation) + a.generating = true + return waitEvent(a.gen) +} + // generateOpts folds the Modes preset with the Settings overrides — an // explicit Settings thinking choice wins over the preset's. func (a app) generateOpts() []inference.GenerateOption { @@ -398,10 +2899,13 @@ func (a app) generateOpts() []inference.GenerateOption { // slice IS the memory, exactly like a stateless API client. Tool declarations // ride the system turn when the Tools tab armed them (the serve convention). func (a app) history() []inference.Message { - msgs := make([]inference.Message, 0, len(a.turns)+1) - if decl := a.tools.declarations(); decl != "" { - msgs = append(msgs, inference.Message{Role: "system", Content: decl}) + if a.sessions != nil { + if session := a.sessions.sessions[a.sessionID]; session != nil { + return a.historyForSession(session) + } } + msgs := make([]inference.Message, 0, len(a.turns)+1) + msgs = append(msgs, a.systemHistory(a.attachments)...) for _, t := range a.turns { if t.role == "assistant" && t.text == "" && t.thought == "" { continue // the live, still-empty turn @@ -411,25 +2915,86 @@ func (a app) history() []inference.Message { return msgs } +func (a app) historyForSession(session *chatSession) []inference.Message { + if session == nil { + return nil + } + attachments := a.attachments + if session.Record.ID != a.sessionID && a.knowledge != nil { + if result := a.knowledge.Attachments(session.Record.ID); result.OK { + attachments, _ = result.Value.([]attachmentRecord) + } + } + msgs := make([]inference.Message, 0, len(session.Turns)+1) + msgs = append(msgs, a.systemHistory(attachments)...) + for _, record := range session.Turns { + content := record.Visible + if record.Role == "assistant" && record.ToolCallJSON != "" && record.ToolCallJSON != "{}" { + var calls []inference.ToolCall + if result := core.JSONUnmarshalString(record.ToolCallJSON, &calls); result.OK { + for _, call := range calls { + content += parser.RenderGemmaToolCall(call.Name, call.ArgumentsJSON) + } + } + } + if record.Role == "assistant" && content == "" && record.Thought == "" { + continue + } + msgs = append(msgs, inference.Message{Role: record.Role, Content: content}) + } + return msgs +} + +func (a app) systemHistory(attachments []attachmentRecord) []inference.Message { + systemParts := make([]string, 0, 2) + knowledgeLimit := knowledgeSystemMessageMaxBytes + if a.knowledge != nil { + knowledgeLimit = int(a.knowledge.maxBytes) + } + if knowledge := knowledgeSystemMessageBounded(attachments, knowledgeLimit); knowledge != "" { + systemParts = append(systemParts, knowledge) + } + if decl := a.tools.declarations(); decl != "" { + systemParts = append(systemParts, decl) + } + if len(systemParts) > 0 { + return []inference.Message{{Role: "system", Content: core.Join("\n\n", systemParts...)}} + } + return nil +} + func (a *app) refreshTranscript() { + a.updateTranscript(false) +} + +func (a *app) refreshTranscriptOutput() { + a.updateTranscript(true) +} + +func (a *app) updateTranscript(hasOutput bool) { if !a.ready { return } + offset := a.view.YOffset a.view.Height = a.transcriptHeight() a.view.SetContent(a.renderTranscript()) - a.view.GotoBottom() + if a.follow { + a.view.GotoBottom() + a.newOutput = false + return + } + a.view.SetYOffset(offset) + if hasOutput { + a.newOutput = true + } } func (a app) contentHeight() int { - h := a.height - 4 // tab bar + status - if h < 3 { - h = 3 - } - return h + return max(1, measureFrame(a.width, a.height, a.inspectorOpen).mainHeight) } func (a app) transcriptHeight() int { - h := a.height - a.input.Height() - 8 // tab bar + input border + status + h := a.contentHeight() - a.input.Height() - 2 // composer border if h < 3 { h = 3 } @@ -437,38 +3002,123 @@ func (a app) transcriptHeight() int { } func (a app) renderTranscript() string { - var b strings.Builder + var b core.Builder + notice := a.sessionTranscriptNotice() + if notice != "" { + b.WriteString(a.styles.attention.Render(notice)) + } for i, t := range a.turns { - if i > 0 { + if i > 0 || notice != "" { b.WriteString("\n\n") } switch t.role { case "user": - b.WriteString(styleUser.Render("you ") + styleAnswer.Render(t.text)) + b.WriteString(a.styles.user.Render("you ") + a.styles.answer.Render(t.text)) case "tool": - b.WriteString(styleThought.Render("tool result fed back")) + label := "tool result" + if len(t.calls) > 0 { + label = core.Concat("tool · ", t.calls[0]) + } + b.WriteString(a.styles.thought.Render(label)) + if result := visibleToolResult(t.text); result != "" { + b.WriteString("\n" + a.styles.answer.Render(result)) + } default: if t.thought != "" { - b.WriteString(styleThought.Render("· thinking · "+strings.TrimSpace(t.thought)) + "\n") + b.WriteString(a.styles.thought.Render("· thinking · "+core.Trim(t.thought)) + "\n") + } + label := t.model + if label == "" { + label = a.modelName + } + if label == "" { + label = "assistant" + } + b.WriteString(a.styles.assistant.Render(label)) + streaming := a.generating && i == len(a.turns)-1 + if t.text != "" { + b.WriteString("\n") + if streaming { + b.WriteString(a.styles.answer.Render(t.text)) + } else { + turnID := t.id + if turnID == "" { + turnID = core.Sprintf("transcript-%d", i) + } + b.WriteString(a.markdown.Render(turnID, t.text, max(1, a.view.Width-2))) + } } - b.WriteString(styleAccent.Render(a.modelName+" ") + styleAnswer.Render(t.text)) for _, c := range t.calls { - b.WriteString("\n" + styleThought.Render("→ "+c)) + b.WriteString("\n" + a.styles.thought.Render("→ "+c)) } if t.text == "" && t.thought == "" && a.generating && i == len(a.turns)-1 { - b.WriteString(styleThought.Render(a.spin.View() + " …")) + b.WriteString(a.styles.thought.Render(a.spin.View() + " …")) } } } return lipgloss.NewStyle().Width(a.view.Width).Render(b.String()) } +func (a app) transcriptTurnOffset(turnID string) int { + turnID = core.Trim(turnID) + if turnID == "" { + return 0 + } + index := -1 + for candidate, item := range a.turns { + if item.id == turnID { + index = candidate + break + } + } + if index < 0 { + return 0 + } + if index == 0 && a.sessionTranscriptNotice() == "" { + return 0 + } + prefix := a + prefix.turns = a.turns[:index] + return core.Count(prefix.renderTranscript(), "\n") + 2 +} + +func (a app) sessionTranscriptNotice() string { + if a.sessions == nil || a.sessions.Active() == nil { + return "" + } + switch a.sessions.Active().Record.Status { + case "interrupted": + return "! Generation interrupted · partial output preserved" + case "cancelled": + return "! Generation cancelled · partial output preserved" + case "failed": + return "! Generation failed · inspect the error before retrying" + default: + return "" + } +} + +func visibleToolResult(value string) string { + value = core.TrimPrefix(value, parser.ToolResponseOpenMarker) + value = core.TrimSuffix(value, parser.ToolResponseCloseMarker) + return core.Trim(value) +} + func (a app) statusLine() string { parts := []string{} + if len(a.warnings) > 0 { + parts = append(parts, a.styles.attention.Render("warning: "+a.warnings[0])) + } + if a.errText != "" { + parts = append(parts, a.styles.err.Render("error: "+a.errText)) + } + if a.newOutput { + parts = append(parts, a.styles.attention.Render("↓ new output")) + } if a.modelName != "" { parts = append(parts, a.modelName) } else { - parts = append(parts, "no model — pick one in Models") + parts = append(parts, "○ no model") } parts = append(parts, "mode "+a.modes.current().name, "thinking "+thinkNames[a.cfg.thinkIdx]) if a.tools.enabled { @@ -486,37 +3136,285 @@ func (a app) statusLine() string { if a.generating { parts = append(parts, a.spin.View()+" generating (esc cancels)") } - if a.errText != "" { - parts = append(parts, styleErr.Render(a.errText)) - } - return styleStatus.Render(strings.Join(parts, " · ")) + return a.styles.status.Render(core.Join(" · ", parts...)) } func (a app) View() string { - bar := renderTabBar(a.activeTab, a.width) - var content string - switch a.activeTab { - case tabModels: - content = a.picker.View() - case tabService: - content = a.svc.view(a.modelName, a.width) - case tabSettings: - content = a.cfg.view(a.width) - case tabTools: - content = a.tools.view(a.width) - case tabModes: - content = a.modes.view(a.width) + content := a.panelView() + return renderFrame(frameSpec{ + Width: a.width, + Height: a.height, + Active: a.activePanel, + SessionStrip: a.sessionStrip(), + Main: content, + Inspector: a.inspectorView(), + Footer: a.footerLine(), + InspectorOpen: a.inspectorOpen, + }, a.styles) +} + +func (a app) panelView() string { + if a.boot.phase != bootReady { + return a.bootView() + } + if a.activeOverlay != overlayNone { + return a.overlayView() + } + switch a.activePanel { + case panelModels: + return a.picker.View() + case panelService: + return a.svc.view(a.modelName, a.width, a.styles) + case panelWork: + if a.work == nil { + return lipgloss.JoinVertical(lipgloss.Left, + a.styles.title.Render("Work"), + "", + a.styles.status.Render("○ No work yet"), + a.styles.thought.Render("Local work becomes durable when the workspace store connects."), + "", + a.styles.attention.Render("Agent actions unavailable"), + a.styles.thought.Render(defaultAgentUnavailableReason), + ) + } + metrics := measureFrame(a.width, a.height, a.inspectorOpen) + return a.work.View(metrics.mainWidth, metrics.mainHeight, a.styles) + case panelData: + if a.data == nil { + return lipgloss.JoinVertical(lipgloss.Left, + a.styles.title.Render("Data"), + "", + a.styles.status.Render("○ No dataset store connected"), + a.styles.thought.Render("datasets.duckdb becomes available when the workspace store connects."), + "", + a.styles.attention.Render("Review actions unavailable"), + a.styles.thought.Render("open ~/.lem/datasets.duckdb via `lem data create`/`lem data import` first"), + ) + } + metrics := measureFrame(a.width, a.height, a.inspectorOpen) + return a.data.View(metrics.mainWidth, metrics.mainHeight, a.styles) default: // chat if a.model == nil && a.loading == "" { - content = "\n " + styleStatus.Render("no model loaded — press tab to reach Models and pick one") - } else if a.model == nil { - content = "\n " + a.spin.View() + styleStatus.Render(" loading "+displayName(a.loading)+" …") + return "\n " + a.styles.status.Render("○ no model loaded — open Models and choose one") + } + if a.model == nil { + return "\n " + a.spin.View() + a.styles.status.Render(" loading "+displayName(a.loading)+" …") + } + return lipgloss.JoinVertical(lipgloss.Left, + a.view.View(), + a.styles.inputBorder.Render(a.input.View()), + ) + } +} + +func (a app) bootView() string { + if a.boot.phase == bootFailed { + reason := "workspace storage is unavailable" + if a.boot.err != nil { + reason = a.boot.err.Error() + } + return lipgloss.JoinVertical(lipgloss.Left, + a.styles.err.Render("Workspace could not open"), + "", + a.styles.answer.Render(reason), + "", + a.styles.accent.Render("R Retry"), + a.styles.status.Render("Q Quit without changing storage"), + ) + } + return lipgloss.JoinVertical(lipgloss.Left, + a.spin.View()+a.styles.title.Render(" Opening ~/.lem workspace"), + "", + a.styles.status.Render("Preparing durable sessions, preferences, work, and local knowledge…"), + ) +} + +func (a app) overlayView() string { + metrics := measureFrame(a.width, a.height, a.inspectorOpen) + width := max(1, metrics.mainWidth) + height := max(1, metrics.mainHeight) + bodyWidth := max(1, min(68, width-8)) + bodyHeight := max(5, min(24, height-4)) + var body string + switch a.activeOverlay { + case overlayCommands: + body = a.palette.View(bodyWidth, bodyHeight) + case overlaySessions: + if a.switcher == nil { + body = overlayEmpty("Recent sessions", "session workspace is not connected") } else { - content = lipgloss.JoinVertical(lipgloss.Left, - a.view.View(), - styleInputBorder.Render(a.input.View()), - ) + body = a.switcher.View(bodyWidth, bodyHeight) + } + case overlaySearch: + if a.search == nil { + body = overlayEmpty("History search", "history workspace is not connected") + } else { + body = a.search.View(bodyWidth, bodyHeight) + } + case overlayHelp: + body = a.help.View(bodyWidth) + case overlayWorkEditor: + if a.workEditor == nil { + body = overlayEmpty("Work editor", "work editor is unavailable") + } else { + body = a.workEditor.View(bodyWidth, bodyHeight, a.styles) + } + case overlayDataEditor: + if a.dataEditor == nil { + body = overlayEmpty("Edit as derived", "data editor is unavailable") + } else { + body = a.dataEditor.View(bodyWidth, bodyHeight, a.styles) + } + case overlayDataNote: + if a.dataNote == nil { + body = overlayEmpty("Data note", "note overlay is unavailable") + } else { + body = a.dataNote.View(bodyWidth, bodyHeight, a.styles) + } + case overlayDataFilter: + if a.dataFilter == nil { + body = overlayEmpty("Filter", "filter overlay is unavailable") + } else { + body = a.dataFilter.View(bodyWidth, bodyHeight, a.styles) + } + case overlayDataBulk: + if a.dataBulk == nil { + body = overlayEmpty("Bulk action", "bulk action overlay is unavailable") + } else { + body = a.dataBulk.View(bodyWidth, bodyHeight, a.styles) + } + case overlayAgentAnswer: + if a.answerOverlay == nil { + body = overlayEmpty("Answer agent question", "question is unavailable") + } else { + body = a.answerOverlay.View(bodyWidth, bodyHeight, a.styles) + } + case overlayChangeReview: + if a.changeOverlay == nil { + body = overlayEmpty("Review agent changes", "review receipt is unavailable") + } else { + body = a.changeOverlay.View(bodyWidth, bodyHeight, a.styles) + } + case overlayProjectReview: + body = newLaunchReviewOverlay(a.agentReview, a.agentRequest.Provider, a.agentRequest.Model).View(bodyWidth, bodyHeight, a.styles) + case overlayGitEnableReview, overlayLaunchReview, overlayAgentSelection: + if a.launchReview == nil { + body = overlayEmpty("Launch review", "review is unavailable") + } else { + body = a.launchReview.View(bodyWidth, bodyHeight, a.styles) + } + } + return renderOverlay(body, width, height, a.styles) +} + +func (a app) sessionStrip() string { + if a.sessions == nil || len(a.sessions.Recent()) == 0 { + title := newSessionTitle + for _, item := range a.turns { + if item.role == "user" && core.Trim(item.text) != "" { + title = compactStripTitle(item.text, 24) + break + } + } + marker := "●" + if a.generating { + marker = "◉" + } + return core.Concat(marker, " ", title) + } + + recent := a.sessions.Recent() + limit := a.recentSessionLimit + if limit < 1 { + limit = defaultPreferenceValues().RecentSessionLimit + } + shown := min(len(recent), limit) + parts := make([]string, 0, shown+1) + for _, session := range recent[:shown] { + title := core.Trim(session.Record.Title) + if title == "" { + title = newSessionTitle + } + parts = append(parts, core.Concat(sessionStripMarker(session, a.sessions.activeID), " ", compactStripTitle(title, 24))) + } + hidden := len(recent) - shown + maxWidth := a.width - 14 + if maxWidth < 20 { + maxWidth = 20 + } + for len(parts) > 1 { + candidate := append([]string(nil), parts...) + if hidden > 0 { + candidate = append(candidate, core.Sprintf("+%d", hidden)) } + if lipgloss.Width(core.Join(" · ", candidate...)) <= maxWidth { + break + } + parts = parts[:len(parts)-1] + hidden++ + } + if hidden > 0 { + parts = append(parts, core.Sprintf("+%d", hidden)) + } + return core.Join(" · ", parts...) +} + +func sessionStripMarker(session *chatSession, activeID string) string { + if session == nil { + return "○" + } + if session.Record.Status == "queued" || session.Record.Status == "generating" || session.ActiveJobID != "" { + return "◉" + } + terminal := session.Record.Status == "failed" || session.Record.Status == "interrupted" || session.Record.Status == "cancelled" + if session.Record.ID == activeID && terminal { + return "!" + } + if session.Record.ID == activeID { + return "●" + } + if session.Attention { + return "◆" + } + if terminal { + return "!" + } + return "○" +} + +func compactStripTitle(value string, maxCells int) string { + value = core.Trim(value) + if maxCells < 2 || lipgloss.Width(value) <= maxCells { + return value + } + runes := []rune(value) + for len(runes) > 1 && lipgloss.Width(string(runes)+"…") > maxCells { + runes = runes[:len(runes)-1] + } + return string(runes) + "…" +} + +func (a app) inspectorView() string { + metrics := measureFrame(a.width, a.height, a.inspectorOpen) + width := metrics.inspectorWidth + height := metrics.inspectorHeight + if width <= 0 { + width = metrics.innerWidth + } + if height <= 0 { + height = metrics.regionHeight + } + return a.inspector.View(a, width, height) +} + +func (a app) footerLine() string { + keys := "tab panels · ctrl+k commands · ctrl+o inspector · f1 help" + if chooseLayout(a.width) == layoutNarrow { + keys = "tab panels · ^K commands · ^O info · F1 help" + } + status := a.statusLine() + if status == "" { + return keys } - return lipgloss.JoinVertical(lipgloss.Left, bar, content, a.statusLine()) + return status + " │ " + keys } diff --git a/cli/tui/app_test.go b/cli/tui/app_test.go index 079c728c1..8377a3322 100644 --- a/cli/tui/app_test.go +++ b/cli/tui/app_test.go @@ -3,16 +3,863 @@ package tui import ( + "context" "io" "net/http" "os" "strings" + "sync" "testing" "time" + "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + + core "dappco.re/go" + "dappco.re/go/inference/agent/orchestrator" + "dappco.re/go/inference/agent/provider" + "dappco.re/go/inference/agent/work" + "dappco.re/go/inference/agent/workspace" + "dappco.re/go/inference/dataset" + "dappco.re/go/inference/decode/parser" ) +func TestAppBootstrap_Good(t *testing.T) { + resources := openAppTestWorkspace(t) + resources.Warnings = append(resources.Warnings, "reactive state: degraded fixture") + a := newWorkspaceApp("", 0, 64, func() core.Result { return core.Ok(resources) }) + if a.activePanel != panelChat || a.boot.phase != bootLoading { + t.Fatalf("initial workspace app = panel %d boot %#v", a.activePanel, a.boot) + } + message := workspaceBootstrap(a.workspaceLoader)() + m, command := a.Update(message) + a = m.(app) + if a.boot.phase != bootReady || a.resources != resources || a.sessions == nil || a.sessions.Active() == nil { + t.Fatalf("ready app = boot %#v resources=%p sessions=%#v", a.boot, a.resources, a.sessions) + } + if a.repository == nil || a.preferences == nil || a.work == nil || a.knowledge == nil { + t.Fatalf("composition incomplete: repository=%v preferences=%v work=%v knowledge=%v", a.repository != nil, a.preferences != nil, a.work != nil, a.knowledge != nil) + } + if command == nil || !strings.Contains(a.statusLine(), "degraded fixture") { + t.Fatalf("ready command/warning = %v / %q", command != nil, a.statusLine()) + } + if result := a.shutdown(); !result.OK { + t.Fatalf("shutdown: %v", result.Value) + } +} + +func TestAppBootstrap_Bad(t *testing.T) { + attempts := 0 + var resources *workspaceResources + loader := func() core.Result { + attempts++ + if attempts == 1 { + return core.Fail(core.E("test.bootstrap", "/tmp/lem.duckdb is unavailable", nil)) + } + resources = openAppTestWorkspace(t) + return core.Ok(resources) + } + a := newWorkspaceApp("", 0, 64, loader) + m, _ := a.Update(workspaceBootstrap(loader)()) + a = m.(app) + if a.boot.phase != bootFailed || !strings.Contains(a.View(), "/tmp/lem.duckdb") || !strings.Contains(a.View(), "Retry") { + t.Fatalf("blocking storage view:\n%s", a.View()) + } + m, command := a.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}}) + a = m.(app) + if a.boot.phase != bootLoading || command == nil { + t.Fatalf("retry state = %#v command=%v", a.boot, command != nil) + } + m, _ = a.Update(command()) + a = m.(app) + if a.boot.phase != bootReady || attempts != 2 || a.resources != resources { + t.Fatalf("retry result = boot %#v attempts=%d", a.boot, attempts) + } + _ = a.shutdown() +} + +func TestAppBootstrap_Ugly(t *testing.T) { + a := newWorkspaceApp("", 0, 64, func() core.Result { + return core.Fail(core.E("test.bootstrap", "storage offline", nil)) + }) + m, _ := a.Update(workspaceBootstrap(a.workspaceLoader)()) + a = m.(app) + m, command := a.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}) + a = m.(app) + if command == nil { + t.Fatal("quit from blocking storage screen returned no command") + } + if _, ok := command().(tea.QuitMsg); !ok { + t.Fatalf("quit command produced %T", command()) + } +} + +func TestAppLifecycleLoaders_Ugly(t *testing.T) { + t.Run("workspace", func(t *testing.T) { + resources := openAppTestWorkspace(t) + started := make(chan struct{}) + release := make(chan struct{}) + a := newWorkspaceApp("", 0, 64, func() core.Result { + close(started) + <-release + return core.Ok(resources) + }) + command := a.lifecycle.command(workspaceBootstrap(a.workspaceLoader)) + message := make(chan tea.Msg, 1) + go func() { message <- command() }() + <-started + shutdown := make(chan core.Result, 1) + go func() { shutdown <- a.shutdown() }() + <-a.lifecycle.stopCh + close(release) + if result := <-shutdown; !result.OK { + t.Fatalf("shutdown workspace loader: %s", result.Error()) + } + if _, ok := (<-message).(lifecycleStoppedMsg); !ok { + t.Fatal("late workspace load was delivered after shutdown") + } + if resources.Repository != nil || resources.State != nil { + t.Fatal("late workspace resources were not closed") + } + }) + + t.Run("model", func(t *testing.T) { + base := newFakeTextModel(nil) + started := make(chan struct{}) + release := make(chan struct{}) + a := newApp("", 0, 64) + command := a.lifecycle.command(func() tea.Msg { + close(started) + <-release + return loadedMsg{model: base, name: "late"} + }) + message := make(chan tea.Msg, 1) + go func() { message <- command() }() + <-started + shutdown := make(chan core.Result, 1) + go func() { shutdown <- a.shutdown() }() + <-a.lifecycle.stopCh + close(release) + if result := <-shutdown; !result.OK { + t.Fatalf("shutdown model loader: %s", result.Error()) + } + if _, ok := (<-message).(lifecycleStoppedMsg); !ok { + t.Fatal("late model load was delivered after shutdown") + } + if base.closes.Load() != 1 { + t.Fatalf("late model close count = %d", base.closes.Load()) + } + }) + + t.Run("completed but undelivered model", func(t *testing.T) { + base := newFakeTextModel(nil) + a := newApp("", 0, 64) + message := a.lifecycle.command(func() tea.Msg { + return loadedMsg{model: base, name: "pending"} + })() + if result := a.shutdown(); !result.OK { + t.Fatalf("shutdown pending model: %s", result.Error()) + } + if base.closes.Load() != 1 { + t.Fatalf("pending model close count = %d", base.closes.Load()) + } + model, _ := a.Update(message) + a = model.(app) + if base.closes.Load() != 1 { + t.Fatalf("pending model closed twice: %d", base.closes.Load()) + } + }) +} + +func TestAppSessionGeneration_Good(t *testing.T) { + resources := openAppTestWorkspace(t) + a := newApp("", 0, 64) + if result := a.connectWorkspace(resources); !result.OK { + t.Fatalf("connect workspace: %v", result.Value) + } + base := newFakeTextModel(map[string][]string{"alpha": {"answer-a"}, "beta": {"answer-b"}}) + alphaGate := base.block("alpha") + m, _ := a.Update(loadedMsg{model: base, name: "fixture"}) + a = m.(app) + sessionA := a.sessions.Active().Record.ID + a.input.SetValue("alpha") + m, commandA := a.Update(tea.KeyMsg{Type: tea.KeyEnter}) + a = m.(app) + if commandA == nil || a.sessionJobs[sessionA] == nil { + t.Fatal("session A did not enqueue generation") + } + if started := waitFakeStarted(t, base.started); started != "alpha" { + t.Fatalf("first prompt = %q", started) + } + + m, _ = a.Update(tea.KeyMsg{Type: tea.KeyCtrlN}) + a = m.(app) + sessionB := a.sessions.Active().Record.ID + if sessionB == sessionA { + t.Fatal("Ctrl+N did not create session B") + } + a.input.SetValue("beta") + m, commandB := a.Update(tea.KeyMsg{Type: tea.KeyEnter}) + a = m.(app) + if commandB == nil || a.sessionJobs[sessionB] == nil || a.sessionJobs[sessionA] == nil { + t.Fatalf("independent jobs = A:%v B:%v", a.sessionJobs[sessionA] != nil, a.sessionJobs[sessionB] != nil) + } + assertNoFakeStarted(t, base.started) + close(alphaGate) + driveAppGeneration(t, &a, sessionA, commandA) + if started := waitFakeStarted(t, base.started); started != "beta" { + t.Fatalf("second prompt = %q", started) + } + if !a.sessions.sessions[sessionA].Attention || a.sessions.Active().Record.ID != sessionB { + t.Fatalf("hidden completion = active %q attention %v", a.sessions.Active().Record.ID, a.sessions.sessions[sessionA].Attention) + } + driveAppGeneration(t, &a, sessionB, commandB) + + for sessionID, want := range map[string]string{sessionA: "answer-a", sessionB: "answer-b"} { + turnResult := resources.Repository.Turns(sessionID) + if !turnResult.OK { + t.Fatalf("turns %s: %v", sessionID, turnResult.Value) + } + turns := turnResult.Value.([]turnRecord) + if len(turns) != 2 || turns[0].Role != "user" || turns[1].Role != "assistant" || turns[1].Visible != want { + t.Fatalf("session %s turns = %#v", sessionID, turns) + } + jobs := resources.Repository.Jobs(sessionID).Value.([]generationJobRecord) + if len(jobs) != 1 || jobs[0].Status != "completed" { + t.Fatalf("session %s jobs = %#v", sessionID, jobs) + } + } + if base.maxActive.Load() != 1 { + t.Fatalf("shared lane max concurrency = %d", base.maxActive.Load()) + } + _ = a.shutdown() +} + +func TestAppSessionPersistenceFailure_Bad(t *testing.T) { + tests := []struct { + name string + failTurn bool + failJobCall int + }{ + {name: "final turn", failTurn: true}, + {name: "final job", failJobCall: 3}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + resources := openAppTestWorkspace(t) + faults := &failingWorkspaceRepository{workspaceRepository: resources.Repository, failJobCall: test.failJobCall} + resources.Repository = faults + a := newApp("", 0, 64) + if result := a.connectWorkspace(resources); !result.OK { + t.Fatalf("connect workspace: %v", result.Value) + } + base := newFakeTextModel(map[string][]string{"persist": {"answer"}}) + model, _ := a.Update(loadedMsg{model: base, name: "fixture"}) + a = model.(app) + sessionID := a.sessions.Active().Record.ID + a.input.SetValue("persist") + model, command := a.Update(tea.KeyMsg{Type: tea.KeyEnter}) + a = model.(app) + faults.failTurn = test.failTurn + driveAppGeneration(t, &a, sessionID, command) + + if status := a.sessions.sessions[sessionID].Record.Status; status != "failed" { + t.Fatalf("session status = %q, want failed", status) + } + if !strings.Contains(a.errText, "injected persistence failure") { + t.Fatalf("error text = %q", a.errText) + } + if events := faults.workspaceRepository.Events(sessionID); !events.OK || len(events.Value.([]eventRecord)) != 1 || events.Value.([]eventRecord)[0].Kind != "generation.failed" { + t.Fatalf("failure events = %#v (%s)", events.Value, events.Error()) + } + _ = a.shutdown() + }) + } +} + +func TestAppManagedStreamBatchesPersistence_Good(t *testing.T) { + resources := openAppTestWorkspace(t) + tracked := &failingWorkspaceRepository{workspaceRepository: resources.Repository} + resources.Repository = tracked + a := newApp("", 0, 256) + if result := a.connectWorkspace(resources); !result.OK { + t.Fatalf("connect workspace: %v", result.Value) + } + tokens := make([]string, 128) + for index := range tokens { + tokens[index] = "x" + } + base := newFakeTextModel(map[string][]string{"burst": tokens}) + model, _ := a.Update(loadedMsg{model: base, name: "fixture"}) + a = model.(app) + sessionID := a.sessions.Active().Record.ID + a.input.SetValue("burst") + model, command := a.Update(tea.KeyMsg{Type: tea.KeyEnter}) + a = model.(app) + driveAppGeneration(t, &a, sessionID, command) + + if tracked.turnCalls > 8 { + t.Fatalf("128-token stream wrote turns %d times, want bounded batches", tracked.turnCalls) + } + turnResult := tracked.workspaceRepository.Turns(sessionID) + turns := turnResult.Value.([]turnRecord) + if !turnResult.OK || len(turns) != 2 || len(turns[1].Visible) != 128 { + t.Fatalf("batched transcript = %#v (%s)", turnResult.Value, turnResult.Error()) + } + _ = a.shutdown() +} + +func TestAppSessionToolLoop_Good(t *testing.T) { + resources := openAppTestWorkspace(t) + a := newApp("", 0, 64) + if result := a.connectWorkspace(resources); !result.OK { + t.Fatalf("connect workspace: %v", result.Value) + } + a.tools.setEnabled(true) + toolCall := parser.RenderGemmaToolCall("word_count", `{"text":"one two"}`) + toolResponse := parser.RenderGemmaToolResponse("2 words") + base := newFakeTextModel(map[string][]string{ + "please count": {toolCall}, + toolResponse: {"The count is two."}, + }) + m, _ := a.Update(loadedMsg{model: base, name: "tool-fixture"}) + a = m.(app) + sessionID := a.sessions.Active().Record.ID + a.input.SetValue("please count") + m, command := a.Update(tea.KeyMsg{Type: tea.KeyEnter}) + a = m.(app) + driveAppGeneration(t, &a, sessionID, command) + + turnResult := resources.Repository.Turns(sessionID) + if !turnResult.OK { + t.Fatalf("load tool turns: %v", turnResult.Value) + } + turns := turnResult.Value.([]turnRecord) + if len(turns) != 4 { + t.Fatalf("tool loop turns = %#v", turns) + } + if turns[0].Role != "user" || turns[1].Role != "assistant" || turns[2].Role != "tool" || turns[3].Role != "assistant" { + t.Fatalf("tool loop roles = %#v", turns) + } + if turns[2].ToolName != "word_count" || turns[3].Visible != "The count is two." { + t.Fatalf("tool loop result = %#v", turns) + } + events := resources.Repository.Events(sessionID).Value.([]eventRecord) + if len(events) != 1 || events[0].Kind != "tool.call" || events[0].Status != "completed" { + t.Fatalf("tool events = %#v", events) + } + jobs := resources.Repository.Jobs(sessionID).Value.([]generationJobRecord) + if len(jobs) != 2 || jobs[0].Status != "completed" || jobs[1].Status != "completed" { + t.Fatalf("tool jobs = %#v", jobs) + } + transcript := ansi.Strip(a.renderTranscript()) + for _, want := range []string{"word_count", "2 words", "The count is two."} { + if !strings.Contains(transcript, want) { + t.Fatalf("durable tool transcript missing %q:\n%s", want, transcript) + } + } + _ = a.shutdown() +} + +func TestAppSharedServiceLane_Good(t *testing.T) { + resources := openAppTestWorkspace(t) + a := newApp("", 0, 64) + if result := a.connectWorkspace(resources); !result.OK { + t.Fatalf("connect workspace: %v", result.Value) + } + base := newFakeTextModel(map[string][]string{ + "chat": {"chat-answer"}, "api": {"api-answer"}, "later": {"later-answer"}, + }) + chatGate := base.block("chat") + m, _ := a.Update(loadedMsg{model: base, name: "shared"}) + a = m.(app) + a.svc.custom = "127.0.0.1:0" + if command := a.svc.start(a.model); command == nil || !a.svc.running { + t.Fatal("service did not start on the shared lane") + } + a.input.SetValue("chat") + m, chatCommand := a.Update(tea.KeyMsg{Type: tea.KeyEnter}) + a = m.(app) + if started := waitFakeStarted(t, base.started); started != "chat" { + t.Fatalf("first shared request = %q", started) + } + apiDone := make(chan string, 1) + go consumeFakeChat(a.model, "api", apiDone) + assertNoFakeStarted(t, base.started) + close(chatGate) + driveAppGeneration(t, &a, a.sessionID, chatCommand) + if started := waitFakeStarted(t, base.started); started != "api" { + t.Fatalf("queued API request = %q", started) + } + if answer := waitFakeDone(t, apiDone); answer != "api-answer" { + t.Fatalf("API answer = %q", answer) + } + a.svc.teardown("test stop") + select { + case event := <-a.svc.events: + a.svc.finish(event.err) + case <-time.After(2 * time.Second): + t.Fatal("service listener did not stop") + } + if base.closes.Load() != 0 { + t.Fatalf("service stop closed base model %d times", base.closes.Load()) + } + a.input.SetValue("later") + m, laterCommand := a.Update(tea.KeyMsg{Type: tea.KeyEnter}) + a = m.(app) + if laterCommand == nil { + t.Fatal("later chat did not start after service stop") + } + if started := waitFakeStarted(t, base.started); started != "later" { + t.Fatalf("later request = %q", started) + } + driveAppGeneration(t, &a, a.sessionID, laterCommand) + if base.maxActive.Load() != 1 { + t.Fatalf("shared service lane concurrency = %d", base.maxActive.Load()) + } + _ = a.shutdown() +} + +func TestAppModelSwap_Bad(t *testing.T) { + resources := openAppTestWorkspace(t) + a := newApp("", 0, 64) + if result := a.connectWorkspace(resources); !result.OK { + t.Fatalf("connect workspace: %v", result.Value) + } + base := newFakeTextModel(map[string][]string{"busy": {"done"}}) + gate := base.block("busy") + m, _ := a.Update(loadedMsg{model: base, name: "old"}) + a = m.(app) + a.input.SetValue("busy") + m, generationCommand := a.Update(tea.KeyMsg{Type: tea.KeyEnter}) + a = m.(app) + waitFakeStarted(t, base.started) + loads := 0 + a.modelLoader = func(string, int) tea.Cmd { + loads++ + return func() tea.Msg { return loadErrMsg{err: errFor("must not load")} } + } + a.activePanel = panelModels + a.picker.SetItems([]list.Item{modelItem{path: "/models/new", name: "new", modelType: "fake"}}) + m, command := a.Update(tea.KeyMsg{Type: tea.KeyEnter}) + a = m.(app) + if command != nil || loads != 0 || base.closes.Load() != 0 || !strings.Contains(a.errText, "jobs are active") { + t.Fatalf("blocked swap = command=%v loads=%d closes=%d err=%q", command != nil, loads, base.closes.Load(), a.errText) + } + close(gate) + driveAppGeneration(t, &a, a.sessionID, generationCommand) + _ = a.shutdown() +} + +func TestAppModelSwap_Good(t *testing.T) { + oldBase := newFakeTextModel(nil) + newBase := newFakeTextModel(nil) + a := newApp("", 0, 64) + m, _ := a.Update(loadedMsg{model: oldBase, name: "old"}) + a = m.(app) + a.svc.custom = "127.0.0.1:0" + if command := a.svc.start(a.model); command == nil { + t.Fatal("service did not start before swap") + } + loads := 0 + a.modelLoader = func(path string, _ int) tea.Cmd { + loads++ + return func() tea.Msg { + if strings.Contains(path, "broken") { + return loadErrMsg{err: errFor("broken checkpoint")} + } + return loadedMsg{model: newBase, name: "new"} + } + } + a.activePanel = panelModels + a.picker.SetItems([]list.Item{modelItem{path: "/models/new", name: "new", modelType: "fake"}}) + m, command := a.Update(tea.KeyMsg{Type: tea.KeyEnter}) + a = m.(app) + if command != nil || !a.svc.stopping || a.pendingModel != "/models/new" || oldBase.closes.Load() != 0 { + t.Fatalf("draining swap = command=%v stopping=%v pending=%q closes=%d", command != nil, a.svc.stopping, a.pendingModel, oldBase.closes.Load()) + } + var serviceEvent serviceEvent + select { + case serviceEvent = <-a.svc.events: + case <-time.After(2 * time.Second): + t.Fatal("service did not drain for model swap") + } + m, command = a.Update(serviceMsg{ev: serviceEvent}) + a = m.(app) + if command == nil || oldBase.closes.Load() != 1 || a.model != nil { + t.Fatalf("post-drain unload = command=%v closes=%d model=%v", command != nil, oldBase.closes.Load(), a.model != nil) + } + m, _ = a.Update(command()) + a = m.(app) + if a.model == nil || a.modelName != "new" || loads != 1 || newBase.closes.Load() != 0 { + t.Fatalf("new model = name=%q loads=%d closes=%d", a.modelName, loads, newBase.closes.Load()) + } + + a.activePanel = panelModels + a.picker.SetItems([]list.Item{modelItem{path: "/models/broken", name: "broken", modelType: "fake"}}) + m, command = a.Update(tea.KeyMsg{Type: tea.KeyEnter}) + a = m.(app) + if command == nil || newBase.closes.Load() != 1 { + t.Fatalf("failed-swap unload = command=%v closes=%d", command != nil, newBase.closes.Load()) + } + m, _ = a.Update(command()) + a = m.(app) + if a.model != nil || a.lane != nil || a.loading != "" || !strings.Contains(a.errText, "broken checkpoint") { + t.Fatalf("failed load state = model=%v lane=%v loading=%q err=%q", a.model != nil, a.lane != nil, a.loading, a.errText) + } + _ = a.shutdown() +} + +func TestAppQuit_Ugly(t *testing.T) { + order := make([]string, 0, 4) + files := testWorkspaceFiles(t) + opened := openWorkspaceWith(files, workspaceOpeners{ + Repository: func(path string) core.Result { + result := openDuckRepository(path) + if !result.OK { + return result + } + return core.Ok(workspaceRepository(&trackingWorkspaceRepository{ + workspaceRepository: result.Value.(workspaceRepository), closeOrder: &order, + })) + }, + State: func(paths appPaths) core.Result { + result := openReactiveState(paths) + if !result.OK { + return result + } + return core.Ok(reactiveState(&trackingReactiveState{ + reactiveState: result.Value.(reactiveState), closeOrder: &order, + })) + }, + }) + if !opened.OK { + t.Fatalf("open tracked workspace: %v", opened.Value) + } + resources := opened.Value.(*workspaceResources) + a := newApp("", 0, 64) + resources.Agent = &orderedAgentProvider{order: &order} + if result := a.connectWorkspace(resources); !result.OK { + t.Fatalf("connect workspace: %v", result.Value) + } + base := &orderedFakeTextModel{ + fakeTextModel: newFakeTextModel(map[string][]string{"running": {"one"}, "queued": {"two"}}), + order: &order, + } + base.block("running") + m, _ := a.Update(loadedMsg{model: base, name: "ordered"}) + a = m.(app) + a.svc.custom = "127.0.0.1:0" + if command := a.svc.start(a.model); command == nil { + t.Fatal("service did not start") + } + a.input.SetValue("running") + m, _ = a.Update(tea.KeyMsg{Type: tea.KeyEnter}) + a = m.(app) + waitFakeStarted(t, base.started) + m, _ = a.Update(tea.KeyMsg{Type: tea.KeyCtrlN}) + a = m.(app) + a.input.SetValue("queued") + m, _ = a.Update(tea.KeyMsg{Type: tea.KeyEnter}) + a = m.(app) + if a.jobs.ActiveCount() != 2 { + t.Fatalf("queued job count = %d", a.jobs.ActiveCount()) + } + + type quitResult struct { + app app + command tea.Cmd + } + finished := make(chan quitResult, 1) + go func() { + model, command := a.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) + finished <- quitResult{app: model.(app), command: command} + }() + select { + case result := <-finished: + a = result.app + if result.command == nil { + t.Fatal("quit returned no Bubble Tea command") + } + case <-time.After(2 * time.Second): + t.Fatal("quit blocked with queued jobs") + } + if got := strings.Join(order, ","); got != "agent,model,state,repository" { + t.Fatalf("shutdown close order = %q", got) + } + if base.closes.Load() != 1 || a.svc.running || a.jobs.ActiveCount() != 0 { + t.Fatalf("shutdown state = closes=%d service=%v jobs=%d", base.closes.Load(), a.svc.running, a.jobs.ActiveCount()) + } + select { + case <-a.svc.events: + case <-time.After(2 * time.Second): + t.Fatal("service goroutine did not finish after quit") + } +} + +func TestAppShutdownPreservesAllCloseErrors_Ugly(t *testing.T) { + order := make([]string, 0, 4) + files := testWorkspaceFiles(t) + opened := openWorkspaceWith(files, workspaceOpeners{ + Repository: func(path string) core.Result { + result := openDuckRepository(path) + if !result.OK { + return result + } + return core.Ok(workspaceRepository(&trackingWorkspaceRepository{ + workspaceRepository: result.Value.(workspaceRepository), closeOrder: &order, + closeFailure: "repository close failed", + })) + }, + State: func(paths appPaths) core.Result { + result := openReactiveState(paths) + if !result.OK { + return result + } + return core.Ok(reactiveState(&trackingReactiveState{ + reactiveState: result.Value.(reactiveState), closeOrder: &order, + closeFailure: "state close failed", + })) + }, + }) + if !opened.OK { + t.Fatalf("open tracked workspace: %s", opened.Error()) + } + resources := opened.Value.(*workspaceResources) + resources.Agent = &orderedAgentProvider{order: &order, closeFailure: "agent close failed"} + a := newApp("", 0, 64) + if result := a.connectWorkspace(resources); !result.OK { + t.Fatalf("connect workspace: %s", result.Error()) + } + model := &orderedFakeTextModel{ + fakeTextModel: newFakeTextModel(map[string][]string{}), order: &order, + closeFailure: "model close failed", + } + updated, _ := a.Update(loadedMsg{model: model, name: "ordered-failure"}) + a = updated.(app) + + result := a.shutdown() + if result.OK { + t.Fatal("shutdown with retained agent ownership succeeded") + } + want := core.E( + "tui.app.shutdown", + "test.agent.Close: agent close failed; test.model.Close: model close failed", + nil, + ).Error() + if result.Error() != want { + t.Fatalf("shutdown error = %q, want %q", result.Error(), want) + } + if got := strings.Join(order, ","); got != "agent,model" { + t.Fatalf("shutdown close order = %q", got) + } + if resources.State == nil || resources.Repository == nil { + t.Fatal("shutdown destroyed retained-agent retry dependencies") + } + if model.closes.Load() != 1 { + t.Fatalf("underlying model Close calls = %d, want 1", model.closes.Load()) + } +} + +func TestAppShutdownRetriesAgentOwnershipBeforeClosingDependencies(t *testing.T) { + repository := openTestDuckRepository(t) + state := &retryCloseReactiveState{} + agent := &retryCloseAgent{repository: repository} + resources := &workspaceResources{Agent: agent, State: state, Repository: repository} + a := newApp("", 0, 64) + a.resources = resources + a.agent = agent + + first := a.shutdown() + core.AssertFalse(t, first.OK) + core.AssertEqual(t, 1, agent.calls) + core.AssertFalse(t, state.closed) + core.AssertTrue(t, resources.State != nil) + core.AssertTrue(t, resources.Repository != nil) + core.AssertTrue(t, repository.ListSessions(false).OK) + + second := a.shutdown() + core.AssertTrue(t, second.OK, second.Error()) + core.AssertEqual(t, 2, agent.calls) + core.AssertTrue(t, state.closed) + core.AssertTrue(t, resources.State == nil) + core.AssertTrue(t, resources.Repository == nil) +} + +func TestAppQuitPersistsPartialGeneration_Ugly(t *testing.T) { + files := testWorkspaceFiles(t) + opened := openWorkspaceWith(files, workspaceOpeners{}) + if !opened.OK { + t.Fatalf("open workspace: %v", opened.Value) + } + a := newApp("", 0, 64) + if result := a.connectWorkspace(opened.Value.(*workspaceResources)); !result.OK { + t.Fatalf("connect workspace: %v", result.Value) + } + base := newFakeTextModel(map[string][]string{"quit during reply": {"durable partial", "discarded"}}) + base.blockAfterFirst("quit during reply") + model, _ := a.Update(loadedMsg{model: base, name: "fixture"}) + a = model.(app) + sessionID := a.sessions.Active().Record.ID + a.input.SetValue("quit during reply") + model, _ = a.Update(tea.KeyMsg{Type: tea.KeyEnter}) + a = model.(app) + select { + case <-base.firstYielded: + case <-time.After(2 * time.Second): + t.Fatal("model did not yield partial output") + } + + model, command := a.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) + a = model.(app) + if command == nil { + t.Fatal("quit returned no Bubble Tea command") + } + + reopened := openWorkspaceWith(files, workspaceOpeners{}) + if !reopened.OK { + t.Fatalf("reopen workspace: %v", reopened.Value) + } + defer reopened.Value.(*workspaceResources).Close() + repository := reopened.Value.(*workspaceResources).Repository + turnResult := repository.Turns(sessionID) + turns, ok := turnResult.Value.([]turnRecord) + if !turnResult.OK || !ok || len(turns) != 2 || turns[1].Visible != "durable partial" { + t.Fatalf("reopened turns = %#v (%s)", turnResult.Value, turnResult.Error()) + } + jobResult := repository.Jobs(sessionID) + jobs, ok := jobResult.Value.([]generationJobRecord) + if !jobResult.OK || !ok || len(jobs) != 1 || jobs[0].Status != "cancelled" { + t.Fatalf("reopened jobs = %#v (%s)", jobResult.Value, jobResult.Error()) + } + sessionResult := repository.Session(sessionID) + if !sessionResult.OK || sessionResult.Value.(sessionRecord).Status != "cancelled" { + t.Fatalf("reopened session = %#v (%s)", sessionResult.Value, sessionResult.Error()) + } + eventResult := repository.Events(sessionID) + events, ok := eventResult.Value.([]eventRecord) + if !eventResult.OK || !ok || len(events) != 1 || events[0].Kind != "generation.cancelled" { + t.Fatalf("reopened events = %#v (%s)", eventResult.Value, eventResult.Error()) + } +} + +func TestAppInterruptedSessionIsVisible_Ugly(t *testing.T) { + files := testWorkspaceFiles(t) + opened := openWorkspaceWith(files, workspaceOpeners{}) + if !opened.OK { + t.Fatalf("open workspace: %v", opened.Value) + } + resources := opened.Value.(*workspaceResources) + now := time.Date(2026, time.July, 17, 20, 0, 0, 0, time.UTC) + session := testSessionRecord("session-interrupted", "Interrupted durable work", now) + session.Status = "generating" + if result := resources.Repository.SaveSession(session); !result.OK { + t.Fatalf("save session: %s", result.Error()) + } + answer := testTurnRecord("answer-interrupted", session.ID, 1, "assistant", "partial answer survives", now) + if result := resources.Repository.SaveTurn(answer); !result.OK { + t.Fatalf("save answer: %s", result.Error()) + } + job := testJobRecord("job-interrupted", session.ID, "generating", now, unsetRecordTime()) + job.AnswerTurnID = answer.ID + if result := resources.Repository.SaveJob(job); !result.OK { + t.Fatalf("save job: %s", result.Error()) + } + if result := resources.Close(); !result.OK { + t.Fatalf("close crashed workspace: %s", result.Error()) + } + + reopened := openWorkspaceWith(files, workspaceOpeners{Now: func() time.Time { return now.Add(time.Minute) }}) + if !reopened.OK { + t.Fatalf("reopen workspace: %v", reopened.Value) + } + a := newApp("", 0, 64) + if result := a.connectWorkspace(reopened.Value.(*workspaceResources)); !result.OK { + t.Fatalf("connect recovered workspace: %s", result.Error()) + } + transcript := ansi.Strip(a.renderTranscript()) + if !strings.Contains(transcript, "Generation interrupted") || !strings.Contains(transcript, "partial answer survives") { + t.Fatalf("recovered transcript:\n%s", transcript) + } + if strip := a.sessionStrip(); !strings.Contains(strip, "! Interrupted durable work") { + t.Fatalf("recovered strip = %q", strip) + } + _ = a.shutdown() +} + +type orderedAgentProvider struct { + order *[]string + closeFailure string +} + +type failingWorkspaceRepository struct { + workspaceRepository + failTurn bool + failJobCall int + jobCalls int + turnCalls int +} + +func (repository *failingWorkspaceRepository) SaveTurn(record turnRecord) core.Result { + repository.turnCalls++ + if repository.failTurn { + return core.Fail(core.E("test.repository.SaveTurn", "injected persistence failure", nil)) + } + return repository.workspaceRepository.SaveTurn(record) +} + +func (repository *failingWorkspaceRepository) SaveJob(record generationJobRecord) core.Result { + repository.jobCalls++ + if repository.failJobCall > 0 && repository.jobCalls >= repository.failJobCall { + return core.Fail(core.E("test.repository.SaveJob", "injected persistence failure", nil)) + } + return repository.workspaceRepository.SaveJob(record) +} + +func (*orderedAgentProvider) Capabilities() []agentCapability { + return agentFeatureCatalog(defaultAgentUnavailableReason) +} + +func (*orderedAgentProvider) Snapshot(context.Context) core.Result { + return core.Ok(agentSnapshot{Work: []agentWorkSnapshot{}, Events: []agentEventSnapshot{}}) +} + +func (*orderedAgentProvider) Review(context.Context, agentReviewRequest) core.Result { + return core.Ok(agentReview{}) +} + +func (*orderedAgentProvider) Run(context.Context, agentRequest) core.Result { + return core.Ok(nil) +} + +func (provider *orderedAgentProvider) Close() core.Result { + *provider.order = append(*provider.order, "agent") + if provider.closeFailure != "" { + return core.Fail(core.E("test.agent.Close", provider.closeFailure, nil)) + } + return core.Ok(nil) +} + +type orderedFakeTextModel struct { + *fakeTextModel + order *[]string + closeFailure string +} + +func (model *orderedFakeTextModel) Close() core.Result { + *model.order = append(*model.order, "model") + result := model.fakeTextModel.Close() + if !result.OK { + return result + } + if model.closeFailure != "" { + return core.Fail(core.E("test.model.Close", model.closeFailure, nil)) + } + return core.Ok(nil) +} + // TestAppUpdateTransitions drives the pure state machine without a terminal: // sizing readies the viewport, discovery fills the picker, enter on a picked // item moves to loading, a loadErr falls back to the picker, and ctrl+t @@ -24,22 +871,22 @@ func TestAppUpdateTransitions(t *testing.T) { if !a.ready { t.Fatal("window size did not ready the app") } - if a.activeTab != tabModels { - t.Fatalf("activeTab = %d, want Models on a pickerless start", a.activeTab) + if a.activePanel != panelChat { + t.Fatalf("activePanel = %d, want Chat on a pickerless start", a.activePanel) } // a load failure lands back on Models with the error surfaced m, _ = a.Update(loadErrMsg{err: errFor("no backends")}) a = m.(app) - if a.activeTab != tabModels || a.errText == "" { - t.Fatalf("loadErr: tab=%d err=%q, want Models + message", a.activeTab, a.errText) + if a.activePanel != panelModels || a.errText == "" { + t.Fatalf("loadErr: panel=%d err=%q, want Models + message", a.activePanel, a.errText) } // tab cycles through every pane and wraps - for i := 0; i < int(tabCount); i++ { + for i := 0; i < int(panelCount); i++ { m, _ = a.Update(tea.KeyMsg{Type: tea.KeyTab}) a = m.(app) } - if a.activeTab != tabModels { - t.Fatalf("tab cycle did not wrap: %d", a.activeTab) + if a.activePanel != panelModels { + t.Fatalf("panel cycle did not wrap: %d", a.activePanel) } // ctrl+t flips the thinking override to an explicit state m, _ = a.Update(tea.KeyMsg{Type: tea.KeyCtrlT}) @@ -50,32 +897,22 @@ func TestAppUpdateTransitions(t *testing.T) { if v := a.View(); !strings.Contains(v, "thinking") { t.Fatalf("status line missing thinking state: %q", v) } - // settings adjust: move to max tokens and bump it - a.activeTab = tabSettings - m, _ = a.Update(tea.KeyMsg{Type: tea.KeyDown}) - a = m.(app) - before := a.cfg.maxTokens() - m, _ = a.Update(tea.KeyMsg{Type: tea.KeyRight}) - a = m.(app) - if a.cfg.maxTokens() == before { - t.Fatal("settings right-adjust did not change max tokens") - } - // tools toggle arms declarations - a.activeTab = tabTools - m, _ = a.Update(tea.KeyMsg{Type: tea.KeyEnter}) + // inspector is a global surface, independent of the active primary panel. + m, _ = a.Update(tea.KeyMsg{Type: tea.KeyCtrlO}) a = m.(app) - if !a.tools.enabled || a.tools.declarations() == "" { - t.Fatal("tools enter did not arm declarations") + if !a.inspectorOpen || !strings.Contains(a.View(), "INSPECTOR") { + t.Fatal("ctrl+o did not open the inspector") } // service: enter with no model declines with a note, never starts - a.activeTab = tabService + a.inspectorOpen = false + a.activePanel = panelService m, _ = a.Update(tea.KeyMsg{Type: tea.KeyEnter}) a = m.(app) if a.svc.running || a.svc.note == "" { t.Fatalf("service start without a model: running=%v note=%q", a.svc.running, a.svc.note) } // address presets cycle while stopped and render in the tab - before = a.svc.addrIdx + before := a.svc.addrIdx m, _ = a.Update(tea.KeyMsg{Type: tea.KeyRight}) a = m.(app) if a.svc.addrIdx == before { @@ -86,6 +923,72 @@ func TestAppUpdateTransitions(t *testing.T) { } } +func TestAppComposerNewline_Good(t *testing.T) { + a := newApp("", 0, 64) + a.input.SetValue("first line") + + model, _ := a.Update(tea.KeyMsg{Type: tea.KeyEnter, Alt: true}) + a = model.(app) + + if a.input.Value() != "first line\n" { + t.Fatalf("Alt+Enter composer value = %q", a.input.Value()) + } +} + +func TestAppSessionStrip_Good(t *testing.T) { + manager := openTestSessionManager(t, sequenceIDs("session-one", "session-two", "session-three")) + first := manager.Create().Value.(*chatSession) + second := manager.Create().Value.(*chatSession) + third := manager.Create().Value.(*chatSession) + if result := manager.Rename(first.Record.ID, "Renamed durable session"); !result.OK { + t.Fatalf("rename first: %s", result.Error()) + } + if result := manager.Rename(second.Record.ID, "Background answer"); !result.OK { + t.Fatalf("rename second: %s", result.Error()) + } + if result := manager.Rename(third.Record.ID, "Interrupted work"); !result.OK { + t.Fatalf("rename third: %s", result.Error()) + } + if result := manager.Switch(first.Record.ID); !result.OK { + t.Fatalf("switch first: %s", result.Error()) + } + second.Record.Status = "generating" + second.ActiveJobID = "job-two" + second.Attention = true + third.Record.Status = "interrupted" + third.Attention = true + manager.order = []string{first.Record.ID, second.Record.ID, third.Record.ID} + + a := newApp("", 0, 64) + a.sessions = manager + a.sessionID = first.Record.ID + a.width = 120 + a.recentSessionLimit = 3 + strip := a.sessionStrip() + for _, want := range []string{"● Renamed durable", "◉ Background answer", "◆ Interrupted work"} { + if !strings.Contains(strip, want) { + t.Fatalf("session strip missing %q: %s", want, strip) + } + } + + a.recentSessionLimit = 2 + strip = a.sessionStrip() + if !strings.Contains(strip, "+1") || strings.Contains(strip, "Interrupted work") { + t.Fatalf("limited session strip = %q", strip) + } +} + +func TestTranscriptPreservesTurnModel_Good(t *testing.T) { + a := newApp("", 0, 64) + a.modelName = "current-model" + a.turns = []turn{{id: "answer-old", role: "assistant", model: "original-model", text: "historical answer"}} + a.view.Width = 72 + transcript := ansi.Strip(a.renderTranscript()) + if !strings.Contains(transcript, "original-model") || strings.Contains(transcript, "current-model") { + t.Fatalf("historical model label:\n%s", transcript) + } +} + // TestAppLiveChatDrive (LTHN_PROBE_MODEL-gated) is the headless end-to-end // receipt: load a real checkpoint, send a prompt through the real Update loop, // consume the stream to done, and assert an answer landed with metrics — the @@ -96,6 +999,10 @@ func TestAppLiveChatDrive(t *testing.T) { t.Skip("needs LTHN_PROBE_MODEL + MLX_METALLIB_PATH") } a := newApp(dir, 0, 128) + if result := a.connectWorkspace(openAppTestWorkspace(t)); !result.OK { + t.Fatalf("connect live workspace: %v", result.Value) + } + defer func() { _ = a.shutdown() }() m, _ := a.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) a = m.(app) @@ -106,11 +1013,9 @@ func TestAppLiveChatDrive(t *testing.T) { } m, _ = a.Update(loaded) a = m.(app) - if a.activeTab != tabChat || a.model == nil { - t.Fatalf("tab = %d model=%v, want chat + loaded", a.activeTab, a.model != nil) + if a.activePanel != panelChat || a.model == nil { + t.Fatalf("panel = %d model=%v, want chat + loaded", a.activePanel, a.model != nil) } - defer a.model.Close() - a.cfg.thinkIdx = 2 // thinking off — deterministic short answers for the drive a.input.SetValue("Say hello in exactly two words.") m, cmd := a.Update(tea.KeyMsg{Type: tea.KeyEnter}) @@ -148,6 +1053,10 @@ func TestAppLiveServiceAPI(t *testing.T) { t.Skip("needs LTHN_PROBE_MODEL + MLX_METALLIB_PATH") } a := newApp(dir, 0, 128) + if result := a.connectWorkspace(openAppTestWorkspace(t)); !result.OK { + t.Fatalf("connect live workspace: %v", result.Value) + } + defer func() { _ = a.shutdown() }() m, _ := a.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) a = m.(app) @@ -158,11 +1067,9 @@ func TestAppLiveServiceAPI(t *testing.T) { } m, _ = a.Update(loaded) a = m.(app) - defer a.model.Close() - const addr = "127.0.0.1:36917" a.svc.custom = addr - a.activeTab = tabService + a.activePanel = panelService m, cmd := a.Update(tea.KeyMsg{Type: tea.KeyEnter}) a = m.(app) if !a.svc.running || cmd == nil { @@ -209,14 +1116,1784 @@ func TestAppLiveServiceAPI(t *testing.T) { } m, _ = a.Update(waitService(a.svc.events)()) a = m.(app) - if a.svc.running || a.svc.sched != nil { + if a.svc.running { t.Fatal("service did not finish cleanly") } } -// errFor builds a plain error for transition tests. -func errFor(text string) error { return &driveErr{text} } +func TestAppServiceUsesLoadedLaneWithoutOwningIt(t *testing.T) { + base := newFakeTextModel(map[string][]string{"hello": {"world"}}) + a := newApp("", 0, 32) + m, _ := a.Update(loadedMsg{model: base, name: "fake"}) + a = m.(app) + if a.lane == nil || a.model != a.lane.Model() { + t.Fatal("loaded model was not wrapped in the application lane") + } -type driveErr struct{ s string } + a.svc.custom = "127.0.0.1:0" + cmd := a.svc.start(a.model) + if cmd == nil || !a.svc.running { + t.Fatalf("service did not start: running=%v note=%q", a.svc.running, a.svc.note) + } + a.svc.teardown("test stop") + select { + case event := <-a.svc.events: + a.svc.finish(event.err) + case <-time.After(2 * time.Second): + t.Fatal("service listener did not stop") + } + if got := base.closes.Load(); got != 0 { + t.Fatalf("service stop closed the loaded model %d times", got) + } + if r := a.lane.Close(); !r.OK { + t.Fatalf("lane Close error = %s", r.Error()) + } + if got := base.closes.Load(); got != 1 { + t.Fatalf("application lane closed base %d times, want 1", got) + } +} -func (e *driveErr) Error() string { return e.s } +func TestTranscriptFollow_Good(t *testing.T) { + a := transcriptTestApp(t) + a.follow = true + a.refreshTranscriptOutput() + if !a.view.AtBottom() || !a.follow || a.newOutput { + t.Fatalf("follow refresh: bottom=%v follow=%v marker=%v", a.view.AtBottom(), a.follow, a.newOutput) + } + a.turns[len(a.turns)-1].text += "\nnew streamed line" + a.refreshTranscriptOutput() + if !a.view.AtBottom() || a.newOutput { + t.Fatalf("streamed follow: bottom=%v marker=%v", a.view.AtBottom(), a.newOutput) + } +} + +func TestTranscriptFollow_Bad(t *testing.T) { + a := transcriptTestApp(t) + a.generating = true // keep the last assistant in streaming/plain render mode + a.follow = true + a.refreshTranscriptOutput() + if !a.view.AtBottom() || a.view.YOffset == 0 { + t.Fatalf("fixture did not overflow viewport: bottom=%v offset=%d", a.view.AtBottom(), a.view.YOffset) + } + + m, _ := a.Update(tea.KeyMsg{Type: tea.KeyUp}) + a = m.(app) + if a.follow { + t.Fatal("scrolling upward left transcript follow enabled") + } + offset := a.view.YOffset + a.turns[len(a.turns)-1].text += "\noutput while reading older text" + a.refreshTranscriptOutput() + if a.view.YOffset != offset || !a.newOutput { + t.Fatalf("scrolled refresh: offset=%d want=%d marker=%v", a.view.YOffset, offset, a.newOutput) + } + if !strings.Contains(a.View(), "new output") { + t.Fatal("scrolled transcript did not render its new-output marker") + } + + m, _ = a.Update(tea.KeyMsg{Type: tea.KeyEnd}) + a = m.(app) + if !a.follow || !a.view.AtBottom() || a.newOutput { + t.Fatalf("End: follow=%v bottom=%v marker=%v", a.follow, a.view.AtBottom(), a.newOutput) + } +} + +func TestTranscriptFollow_Ugly(t *testing.T) { + manager := openTestSessionManager(t, sequenceIDs("session-active", "session-hidden")) + if result := manager.Create(); !result.OK { + t.Fatalf("create active session: %v", result.Value) + } + if result := manager.Create(); !result.OK { + t.Fatalf("create hidden session: %v", result.Value) + } + if result := manager.Switch("session-active"); !result.OK { + t.Fatalf("switch active session: %v", result.Value) + } + if result := manager.SetViewport("session-active", 13, false); !result.OK { + t.Fatalf("set active viewport: %v", result.Value) + } + if result := manager.Complete("session-hidden"); !result.OK { + t.Fatalf("complete hidden session: %v", result.Value) + } + active := manager.Active() + if active.Record.ID != "session-active" || active.ViewportOffset != 13 || active.Follow { + t.Fatalf("hidden completion moved active viewport: %#v", active) + } + if !manager.sessions["session-hidden"].Attention { + t.Fatal("hidden completion did not mark the session for attention") + } +} + +func TestApp_AgentReviewLaunchesOnlyAfterBothConfirmations(t *testing.T) { + repository := openTestDuckRepository(t) + defer closeTestDuckRepository(t, repository) + provider := &launchReviewProvider{ + caps: []agentCapability{{Feature: agentFeatureDispatch, Available: true}}, + reviews: []agentReview{{Feature: agentFeatureDispatch, Title: "Review project registration", Body: "Source: /tmp/repo", ConfirmRequired: true}}, + } + a := newApp("", 0, 64) + if result := a.attachWork(repository, provider); !result.OK { + t.Fatalf("attachWork: %v", result.Value) + } + if result := a.work.CreateWork("Launch", "Run the approved task", "/tmp/repo"); !result.OK { + t.Fatalf("CreateWork: %v", result.Value) + } + if result := a.palette.Invoke(agentCommandID(agentFeatureDispatch), &a); !result.OK { + t.Fatalf("dispatch palette invoke: %v", result.Value) + } + startAgentProjectReview(t, &a, "codex", "gpt-5") + if a.activeOverlay != overlayProjectReview || len(provider.runs) != 0 { + t.Fatalf("project review state = overlay %d runs %d", a.activeOverlay, len(provider.runs)) + } + model, command := a.Update(tea.KeyMsg{Type: tea.KeyEnter}) + a = model.(app) + if len(provider.runs) != 0 || command == nil { + t.Fatalf("registration confirmation = runs=%d command=%v", len(provider.runs), command != nil) + } + model, _ = a.Update(command()) + a = model.(app) + if a.activeOverlay != overlayLaunchReview || len(provider.runs) != 1 { + t.Fatalf("launch review state = overlay %d runs %d", a.activeOverlay, len(provider.runs)) + } + model, command = a.Update(tea.KeyMsg{Type: tea.KeyEnter}) + a = model.(app) + if command == nil || len(provider.runs) != 1 { + t.Fatalf("launch confirmation = runs=%d command=%v", len(provider.runs), command != nil) + } + model, _ = a.Update(command()) + a = model.(app) + selected, ok := a.work.Selected() + if len(provider.runs) != 2 || a.activeOverlay != overlayNone || !ok || selected.Status != "queued" || provider.runs[0].Provider != "codex" || provider.runs[0].Model != "gpt-5" || provider.runs[1].Provider != "codex" || provider.runs[1].Model != "gpt-5" { + t.Fatalf("final dispatch = runs=%#v overlay=%d", provider.runs, a.activeOverlay) + } + a.refreshAgentPalette() + if command := a.palette.byID[agentCommandID(agentFeatureDispatch)]; command.Available { + t.Fatalf("dispatch remained available after queued receipt: %#v", command) + } +} + +func TestApp_AgentReviewRejectsOverlappingAndStaleOperations(t *testing.T) { + repository := openTestDuckRepository(t) + defer closeTestDuckRepository(t, repository) + provider := &launchReviewProvider{caps: []agentCapability{{Feature: agentFeatureDispatch, Available: true}}, reviews: []agentReview{{Feature: agentFeatureDispatch, Title: "Project", ConfirmRequired: true}}} + a := newApp("", 0, 64) + if result := a.attachWork(repository, provider); !result.OK { + t.Fatalf("attachWork: %v", result.Value) + } + if result := a.work.CreateWork("Launch", "Run the task", "/tmp/repo"); !result.OK { + t.Fatalf("CreateWork: %v", result.Value) + } + if result := a.queueAgentAction(agentFeatureDispatch); !result.OK { + t.Fatalf("first action: %v", result.Value) + } + a.launchReview.providerInput.SetValue("codex") + a.launchReview.modelInput.SetValue("gpt-5") + if result := a.confirmAgentSelection(); !result.OK { + t.Fatalf("confirm selection: %v", result.Value) + } + operationID := a.agentOperationID + if result := a.queueAgentAction(agentFeatureDispatch); result.OK { + t.Fatal("overlapping agent action was accepted") + } + beforeRequest := a.agentRequest + beforeOverlay := a.activeOverlay + model, _ := a.Update(agentActionMsg{operationID: operationID + 1, feature: agentFeatureDispatch, stage: agentReviewLaunch, request: agentRequest{Provider: "stale", Model: "stale"}, result: core.Ok(agentReview{Title: "stale"})}) + a = model.(app) + if a.agentRequest != beforeRequest || a.agentOperationID != operationID || a.activeOverlay != beforeOverlay || len(provider.runs) != 0 { + t.Fatalf("stale result mutated app: request=%#v operation=%d overlay=%d runs=%d", a.agentRequest, a.agentOperationID, a.activeOverlay, len(provider.runs)) + } +} + +func TestApp_AgentReviewReceiptUsesRequestedLocalWorkIDAndResets(t *testing.T) { + repository := openTestDuckRepository(t) + defer closeTestDuckRepository(t, repository) + provider := &launchReviewProvider{caps: []agentCapability{{Feature: agentFeatureDispatch, Available: true}, {Feature: agentFeatureQueueStart, Available: true}}} + a := newApp("", 0, 64) + if result := a.attachWork(repository, provider); !result.OK { + t.Fatalf("attachWork: %v", result.Value) + } + a.work.ids = sequenceIDs("requested", "other") + first := a.work.CreateWork("Requested", "Task one", "/tmp/one") + second := a.work.CreateWork("Other", "Task two", "/tmp/two") + if !first.OK || !second.OK { + t.Fatalf("CreateWork = %#v / %#v", first, second) + } + requested := first.Value.(workItemRecord) + other := second.Value.(workItemRecord) + a.agentOperationID, a.agentOperationNext, a.agentInFlight = 4, 5, true + a.agentRequest = agentRequest{Feature: agentFeatureDispatch, WorkID: requested.ID, Review: agentReview{Payload: agentProjectRegistration{Provider: "codex"}}} + a.agentReview = a.agentRequest.Review + a.agentStage = agentReviewLaunch + model, _ := a.Update(agentActionMsg{operationID: 4, feature: agentFeatureDispatch, stage: agentReviewLaunch, request: a.agentRequest, result: core.Ok(agentActionReceipt{Feature: agentFeatureDispatch, WorkID: other.ID, Status: "queued"})}) + a = model.(app) + items := a.work.Items() + statuses := map[string]string{} + for _, item := range items { + statuses[item.ID] = item.Status + } + if statuses[requested.ID] != "queued" || statuses[other.ID] != workStatusActive || !strings.Contains(a.errText, "receipt WorkID") { + t.Fatalf("receipt reconciliation = statuses=%#v error=%q", statuses, a.errText) + } + if a.agentOperationID != 0 || a.agentRequest.Feature != "" || a.agentReview.Payload != nil || a.agentStage != agentReviewNone || a.agentCommand != nil || a.agentInFlight { + t.Fatalf("terminal success retained transaction: id=%d request=%#v review=%#v stage=%d command=%v inFlight=%v", a.agentOperationID, a.agentRequest, a.agentReview, a.agentStage, a.agentCommand != nil, a.agentInFlight) + } + a.work.selectWork(requested.ID) + a.refreshAgentPalette() + if command := a.palette.byID[agentCommandID(agentFeatureDispatch)]; command.Available { + t.Fatalf("dispatch remained available for queued requested Work: %#v", command) + } + if result := a.queueAgentAction(agentFeatureQueueStart); !result.OK || a.agentOperationID != 6 { + t.Fatalf("fresh operation = result=%#v id=%d", result, a.agentOperationID) + } + + a.agentInFlight = true + a.agentReview = agentReview{Payload: agentProjectRegistration{Provider: "stale"}} + model, _ = a.Update(agentActionMsg{operationID: a.agentOperationID, feature: agentFeatureQueueStart, request: a.agentRequest, result: core.Fail(core.E("test.agent", "provider failed", nil))}) + a = model.(app) + if !strings.Contains(a.errText, "provider failed") || a.agentOperationID != 0 || a.agentRequest.Feature != "" || a.agentReview.Payload != nil || a.agentStage != agentReviewNone || a.agentCommand != nil || a.agentInFlight { + t.Fatalf("terminal error retained transaction: err=%q id=%d request=%#v review=%#v stage=%d command=%v inFlight=%v", a.errText, a.agentOperationID, a.agentRequest, a.agentReview, a.agentStage, a.agentCommand != nil, a.agentInFlight) + } +} + +func TestApp_AgentActionTargetsNativeRunAndRequiresReviewReceipt(t *testing.T) { + repository := openTestDuckRepository(t) + defer closeTestDuckRepository(t, repository) + provider := &launchReviewProvider{caps: []agentCapability{{Feature: agentFeatureCancel, Available: true}, {Feature: agentFeatureAnswer, Available: true}, {Feature: agentFeatureChangesReview, Available: true}, {Feature: agentFeatureAccept, Available: true}, {Feature: agentFeatureReject, Available: true}}, reviews: []agentReview{{Feature: agentFeatureChangesReview, Title: "Review", ConfirmRequired: true, Payload: "opaque-review"}}} + a := newApp("", 0, 64) + if result := a.attachWork(repository, provider); !result.OK { + t.Fatalf("attachWork: %v", result.Value) + } + a.work.ids = sequenceIDs("local") + if result := a.work.CreateWork("Local", "task", "/tmp/repo"); !result.OK { + t.Fatalf("CreateWork: %v", result.Value) + } + if result := a.work.updateWork("snapshot", "local", func(record *workItemRecord) { record.Status = "running" }); !result.OK { + t.Fatalf("update: %v", result.Value) + } + a.work.agentWork["local"] = agentWorkSnapshot{NativeRunID: "run-immutable"} + if result := a.queueAgentAction(agentFeatureCancel); !result.OK { + t.Fatalf("cancel: %v", result.Value) + } + if a.agentRequest.WorkID != "local" || a.agentRequest.RunID != "run-immutable" { + t.Fatalf("cancel request = %#v", a.agentRequest) + } + if result := a.queueAgentAction(agentFeatureAccept); result.OK { + t.Fatal("accepted without exact change review receipt") + } +} + +func TestApp_AgentRefreshRunsWhileAnotherPanelIsActive(t *testing.T) { + repository := openTestDuckRepository(t) + defer closeTestDuckRepository(t, repository) + a := newApp("", 0, 64) + if result := a.attachWork(repository, newUnavailableAgentProvider(defaultAgentUnavailableReason)); !result.OK { + t.Fatalf("attachWork: %v", result.Value) + } + a.work.ids = sequenceIDs("local") + if result := a.work.CreateWork("Local", "task", "/tmp/repo"); !result.OK { + t.Fatalf("CreateWork: %v", result.Value) + } + if result := a.work.updateWork("running", "local", func(record *workItemRecord) { record.Status = "running" }); !result.OK { + t.Fatalf("update: %v", result.Value) + } + a.work.agentWork["local"] = agentWorkSnapshot{NativeRunID: "run-1", Status: "running"} + a.activePanel = panelChat + if command := a.armAgentRefresh(); command == nil || !a.agentRefreshArmed { + t.Fatalf("refresh = %v armed=%v", command != nil, a.agentRefreshArmed) + } +} + +func TestApp_AgentAnswerStoresExactResumeIdentity(t *testing.T) { + repository := openTestDuckRepository(t) + defer closeTestDuckRepository(t, repository) + provider := &launchReviewProvider{caps: []agentCapability{{Feature: agentFeatureAnswer, Available: true}, {Feature: agentFeatureResume, Available: true}}} + a := newApp("", 0, 64) + if result := a.attachWork(repository, provider); !result.OK { + t.Fatalf("attachWork: %v", result.Value) + } + a.work.ids = sequenceIDs("local") + if result := a.work.CreateWork("Local", "task", "/tmp/repo"); !result.OK { + t.Fatalf("CreateWork: %v", result.Value) + } + if result := a.work.updateWork("waiting", "local", func(record *workItemRecord) { record.Status = "waiting"; record.Question = "Which target?" }); !result.OK { + t.Fatalf("update: %v", result.Value) + } + a.work.agentWork["local"] = agentWorkSnapshot{NativeRunID: "run-parent", QuestionID: "question-1"} + if result := a.queueAgentAction(agentFeatureAnswer); !result.OK { + t.Fatalf("answer: %v", result.Value) + } + a.agentInFlight = true + model, _ := a.Update(agentActionMsg{operationID: a.agentOperationID, feature: agentFeatureAnswer, request: a.agentRequest, result: core.Ok(agentActionReceipt{Feature: agentFeatureAnswer, WorkID: "local", RunID: "run-reserved", Detail: "answer-1", Status: "answered"})}) + a = model.(app) + if result := a.work.ApplyAgentSnapshot(agentSnapshot{Work: []agentWorkSnapshot{{ExternalID: "local", NativeRunID: "run-parent", Status: "waiting"}}}); !result.OK { + t.Fatalf("snapshot: %v", result.Value) + } + state := a.work.AgentState(a.work.Items()[0]) + if state.NativeRunID != "run-parent" || state.AnswerID != "answer-1" || state.ResumeRunID != "run-reserved" { + t.Fatalf("answer state = %#v", state) + } + if result := a.queueAgentAction(agentFeatureResume); !result.OK || a.agentRequest.RunID != "run-parent" || a.agentRequest.Input != "answer-1" { + t.Fatalf("resume request = %#v / %#v", result, a.agentRequest) + } +} + +func TestApp_WorkspaceReadyCorrelatesSnapshotAndArmsOneRefresh(t *testing.T) { + resources := openAppTestWorkspace(t) + at := time.Date(2026, time.July, 18, 13, 0, 0, 0, time.UTC) + if result := resources.Repository.SaveWorkItem(testWorkRecord("work-1", "Existing work", workStatusActive, at)); !result.OK { + t.Fatalf("SaveWorkItem: %v", result.Value) + } + provider := &correctiveAgentProvider{ + snapshot: func(context.Context, int) core.Result { + return core.Ok(agentSnapshot{Work: []agentWorkSnapshot{{ + ExternalID: "work-1", NativeRunID: "run-existing", Status: "running", + }}}) + }, + } + resources.Agent = provider + a := newWorkspaceApp("", 0, 64, nil) + a.runtimeDetector, a.knowledgeScan = nil, nil + a.activePanel = panelChat + + model, command := a.Update(workspaceReadyMsg{resources: resources}) + a = model.(app) + if !a.agentSnapshotInFlight || a.agentSnapshotCurrent != 1 || provider.SnapshotCalls() != 0 { + t.Fatalf("workspace-ready snapshot correlation = inFlight %v current %d calls %d", a.agentSnapshotInFlight, a.agentSnapshotCurrent, provider.SnapshotCalls()) + } + message := command() + batch, ok := message.(tea.BatchMsg) + if !ok { + t.Fatalf("workspace-ready command = %T, want tea.BatchMsg", message) + } + var refresh tea.Cmd + for _, child := range batch { + if child == nil { + continue + } + model, next := a.Update(child()) + a = model.(app) + if next != nil { + refresh = next + } + } + items := a.work.Items() + if provider.SnapshotCalls() != 1 || len(items) != 1 || items[0].Status != "running" || a.work.AgentState(items[0]).NativeRunID != "run-existing" { + t.Fatalf("startup snapshot = calls %d items %#v state %#v", provider.SnapshotCalls(), items, a.work.AgentState(items[0])) + } + if a.activePanel != panelChat || refresh == nil || !a.agentRefreshArmed || a.armAgentRefresh() != nil { + t.Fatalf("startup refresh = panel %d command %v armed %v", a.activePanel, refresh != nil, a.agentRefreshArmed) + } + if result := a.shutdown(); !result.OK { + t.Fatalf("shutdown: %s", result.Error()) + } +} + +func TestApp_ManualAgentRefreshCoalescesAndRejectsStaleSnapshots(t *testing.T) { + repository := openTestDuckRepository(t) + defer closeTestDuckRepository(t, repository) + started := make(chan struct{}, 1) + release := make(chan struct{}) + provider := &correctiveAgentProvider{snapshot: func(ctx context.Context, call int) core.Result { + if call == 1 { + started <- struct{}{} + select { + case <-release: + case <-ctx.Done(): + return core.Fail(core.E("test.snapshot", "cancelled", ctx.Err())) + } + return core.Ok(agentSnapshot{Work: []agentWorkSnapshot{{ExternalID: "local", NativeRunID: "run-old", Status: "running"}}}) + } + return core.Ok(agentSnapshot{Work: []agentWorkSnapshot{{ExternalID: "local", NativeRunID: "run-new", Status: "completed"}}}) + }} + a := newApp("", 0, 64) + if result := a.attachWork(repository, provider); !result.OK { + t.Fatalf("attachWork: %v", result.Value) + } + a.work.ids = sequenceIDs("local") + if result := a.work.CreateWork("Local", "refresh it", "/src/local"); !result.OK { + t.Fatalf("CreateWork: %v", result.Value) + } + if result := a.palette.Invoke(commandRefreshWork, &a); !result.OK { + t.Fatalf("first Refresh Work: %v", result.Value) + } + first := a.takeAgentCommand() + if first == nil || provider.SnapshotCalls() != 0 { + t.Fatalf("manual refresh called Snapshot synchronously: command %v calls %d", first != nil, provider.SnapshotCalls()) + } + message := make(chan tea.Msg, 1) + go func() { message <- first() }() + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("first snapshot did not start") + } + if result := a.palette.Invoke(commandRefreshWork, &a); !result.OK { + t.Fatalf("coalesced Refresh Work: %v", result.Value) + } + if !a.agentSnapshotPending || a.takeAgentCommand() != nil || provider.SnapshotCalls() != 1 { + t.Fatalf("coalesced refresh = pending %v calls %d", a.agentSnapshotPending, provider.SnapshotCalls()) + } + close(release) + model, second := a.Update(<-message) + a = model.(app) + if second == nil || a.agentSnapshotCurrent != 2 || a.work.Items()[0].Status != "running" { + t.Fatalf("first completion = second %v current %d items %#v", second != nil, a.agentSnapshotCurrent, a.work.Items()) + } + model, _ = a.Update(agentSnapshotMsg{requestID: 1, result: core.Ok(agentSnapshot{Work: []agentWorkSnapshot{{ExternalID: "local", NativeRunID: "run-stale", Status: "failed"}}})}) + a = model.(app) + if state := a.work.AgentState(a.work.Items()[0]); state.NativeRunID != "run-old" || a.work.Items()[0].Status != "running" { + t.Fatalf("stale snapshot replaced state: %#v / %#v", state, a.work.Items()) + } + model, _ = a.Update(second()) + a = model.(app) + if state := a.work.AgentState(a.work.Items()[0]); provider.SnapshotCalls() != 2 || state.NativeRunID != "run-new" || a.work.Items()[0].Status != "completed" { + t.Fatalf("newest snapshot = calls %d state %#v items %#v", provider.SnapshotCalls(), state, a.work.Items()) + } + model, _ = a.Update(agentSnapshotMsg{requestID: 1, result: core.Ok(agentSnapshot{Work: []agentWorkSnapshot{{ExternalID: "local", NativeRunID: "run-stale-after-new", Status: "failed"}}})}) + a = model.(app) + if state := a.work.AgentState(a.work.Items()[0]); state.NativeRunID != "run-new" || a.work.Items()[0].Status != "completed" { + t.Fatalf("stale snapshot replaced newest applied state: %#v / %#v", state, a.work.Items()) + } +} + +func TestApp_AgentSnapshotFailurePreservesViewAndRefreshesLiveWork(t *testing.T) { + repository := openTestDuckRepository(t) + defer closeTestDuckRepository(t, repository) + provider := &correctiveAgentProvider{} + provider.snapshot = func(_ context.Context, call int) core.Result { + if call == 1 { + return core.Fail(core.E("test.snapshot", "provider temporarily unavailable", nil)) + } + return core.Ok(agentSnapshot{Work: []agentWorkSnapshot{{ExternalID: "local", NativeRunID: "run-1", Status: "completed"}}}) + } + a := newApp("", 0, 64) + if result := a.attachWork(repository, provider); !result.OK { + t.Fatalf("attachWork: %v", result.Value) + } + a.work.ids = sequenceIDs("local") + if result := a.work.CreateWork("Local", "keep last good", "/src/local"); !result.OK { + t.Fatalf("CreateWork: %v", result.Value) + } + if result := a.work.ApplyAgentSnapshot(agentSnapshot{Work: []agentWorkSnapshot{{ExternalID: "local", NativeRunID: "run-1", Status: "running"}}}); !result.OK { + t.Fatalf("seed snapshot: %v", result.Value) + } + command := a.requestAgentSnapshot() + model, refresh := a.Update(command()) + a = model.(app) + if refresh == nil || !a.agentRefreshArmed || a.work.Items()[0].Status != "running" || !strings.Contains(a.errText, "temporarily unavailable") { + t.Fatalf("failed snapshot = refresh %v armed %v items %#v error %q", refresh != nil, a.agentRefreshArmed, a.work.Items(), a.errText) + } + model, command = a.Update(agentRefreshMsg{}) + a = model.(app) + if command == nil { + t.Fatal("armed refresh did not issue a new snapshot request") + } + model, _ = a.Update(command()) + a = model.(app) + if provider.SnapshotCalls() != 2 || a.work.Items()[0].Status != "completed" { + t.Fatalf("refreshed snapshot = calls %d items %#v", provider.SnapshotCalls(), a.work.Items()) + } +} + +func TestApp_AgentRefreshTimerStopsWithLifecycle(t *testing.T) { + repository := openTestDuckRepository(t) + defer closeTestDuckRepository(t, repository) + provider := &correctiveAgentProvider{} + a := newApp("", 0, 64) + if result := a.attachWork(repository, provider); !result.OK { + t.Fatalf("attachWork: %v", result.Value) + } + a.work.ids = sequenceIDs("local") + if result := a.work.CreateWork("Local", "stop refresh", "/src/local"); !result.OK { + t.Fatalf("CreateWork: %v", result.Value) + } + if result := a.work.ApplyAgentSnapshot(agentSnapshot{Work: []agentWorkSnapshot{{ExternalID: "local", NativeRunID: "run-1", Status: "running"}}}); !result.OK { + t.Fatalf("seed snapshot: %v", result.Value) + } + command := a.armAgentRefresh() + if command == nil { + t.Fatal("live work did not arm a stoppable refresh timer") + } + message := make(chan tea.Msg, 1) + go func() { message <- command() }() + time.Sleep(10 * time.Millisecond) + started := time.Now() + a.lifecycle.stop() + select { + case got := <-message: + if _, ok := got.(lifecycleStoppedMsg); !ok { + t.Fatalf("cancelled timer message = %T, want lifecycleStoppedMsg", got) + } + case <-time.After(250 * time.Millisecond): + t.Fatal("lifecycle cancellation did not stop the refresh timer promptly") + } + a.lifecycle.wait() + if elapsed := time.Since(started); elapsed >= 250*time.Millisecond || provider.SnapshotCalls() != 0 { + t.Fatalf("stopped lifecycle = elapsed %s snapshots %d", elapsed, provider.SnapshotCalls()) + } +} + +func TestApp_AgentResumeUsesNativeResumeAndInterruptedRetry(t *testing.T) { + t.Run("waiting", func(t *testing.T) { + engine := &fixtureNativeAgentEngine{capabilities: nativeFixtureCapabilities()} + a := testNativeAgentApp(t, requireAgentAdapter(t, engine)) + if result := a.work.updateWork("waiting", "work-1", func(record *workItemRecord) { record.Status = "waiting" }); !result.OK { + t.Fatalf("set waiting: %v", result.Value) + } + a.work.agentWork["work-1"] = agentWorkSnapshot{NativeRunID: "run-parent", AnswerID: "answer-exact", Agent: "codex", Runtime: "gpt-5"} + if result := a.queueAgentAction(agentFeatureResume); !result.OK { + t.Fatalf("Resume: %v", result.Value) + } + driveCorrectiveCommand(t, &a, a.takeAgentCommand()) + if a.activeOverlay != overlayLaunchReview || countAgentCall(engine.calls, "resume") != 0 { + t.Fatalf("resume review = overlay %d calls %#v", a.activeOverlay, engine.calls) + } + model, command := a.Update(tea.KeyMsg{Type: tea.KeyEnter}) + a = model.(app) + driveCorrectiveCommand(t, &a, command) + want := work.ResumeRequest{ + Work: nativeWorkItem(agentWorkRequest{ID: "work-1", ExternalID: "local:work-1", Title: "Ship", Task: "Implement the slice", Repository: "/src/project"}, "work-1", ""), + ParentRunID: "run-parent", AnswerID: "answer-exact", Provider: "codex", Model: "gpt-5", + } + if core.JSONMarshalString(engine.resumeRequest) != core.JSONMarshalString(want) || engine.retryParent != "" { + t.Fatalf("native waiting Resume = %#v retry parent %q, want %#v", engine.resumeRequest, engine.retryParent, want) + } + }) + + t.Run("interrupted", func(t *testing.T) { + engine := &fixtureNativeAgentEngine{capabilities: nativeFixtureCapabilities()} + a := testNativeAgentApp(t, requireAgentAdapter(t, engine)) + if result := a.work.updateWork("interrupted", "work-1", func(record *workItemRecord) { record.Status = "interrupted" }); !result.OK { + t.Fatalf("set interrupted: %v", result.Value) + } + a.work.agentWork["work-1"] = agentWorkSnapshot{NativeRunID: "run-interrupted"} + if result := a.queueAgentAction(agentFeatureResume); !result.OK { + t.Fatalf("Resume interrupted: %v", result.Value) + } + driveCorrectiveCommand(t, &a, a.takeAgentCommand()) + if a.activeOverlay != overlayLaunchReview || countAgentCall(engine.calls, "retry") != 0 { + t.Fatalf("interrupted retry review = overlay %d calls %#v", a.activeOverlay, engine.calls) + } + model, command := a.Update(tea.KeyMsg{Type: tea.KeyEnter}) + a = model.(app) + driveCorrectiveCommand(t, &a, command) + if engine.retryParent != "run-interrupted" || engine.resumeRequest.ParentRunID != "" || engine.retryItem.ID != "work-1" { + t.Fatalf("interrupted Resume = retry item %#v parent %q resume %#v", engine.retryItem, engine.retryParent, engine.resumeRequest) + } + }) +} + +func TestApp_AgentRunReplacementClearsRunScopedContinuation(t *testing.T) { + t.Run("answered parent replaced by interrupted child", func(t *testing.T) { + engine := &fixtureNativeAgentEngine{capabilities: nativeFixtureCapabilities()} + a := testNativeAgentApp(t, requireAgentAdapter(t, engine)) + if result := a.work.updateWork("waiting", "work-1", func(record *workItemRecord) { record.Status = "waiting" }); !result.OK { + t.Fatalf("set waiting: %v", result.Value) + } + a.work.agentWork["work-1"] = agentWorkSnapshot{NativeRunID: "run-parent", QuestionID: "question-parent", Question: "Which target?", Status: "waiting"} + a.work.syncList() + engine.snapshot = work.Snapshot{ + Runs: []work.Run{ + {ID: "run-parent", WorkID: "work-1", Status: work.RunWaiting}, + {ID: "run-child", WorkID: "work-1", Status: work.RunInterrupted}, + }, + Questions: []work.Question{{ID: "question-parent", RunID: "run-parent", Text: "Which target?", CreatedAt: time.Now().UTC()}}, + } + if result := a.queueAgentAction(agentFeatureAnswer); !result.OK { + t.Fatalf("Answer: %v", result.Value) + } + a.answerOverlay.input.SetValue("Use child target") + model, answerCommand := a.Update(tea.KeyMsg{Type: tea.KeyEnter}) + a = model.(app) + model, snapshotCommand := a.Update(answerCommand()) + a = model.(app) + if snapshotCommand == nil { + t.Fatal("answered parent did not schedule a snapshot") + } + if state := a.work.AgentState(a.work.Items()[0]); state.AnswerID != "answer-1" || state.ResumeRunID != "run-2" { + t.Fatalf("parent answer identity = %#v", state) + } + model, _ = a.Update(snapshotCommand()) + a = model.(app) + state := a.work.AgentState(a.work.Items()[0]) + if state.NativeRunID != "run-child" || state.AnswerID != "" || state.ResumeRunID != "" || state.QuestionID != "" || state.Question != "" || a.work.Items()[0].Status != "interrupted" { + t.Fatalf("child replacement retained parent state: %#v item %#v", state, a.work.Items()[0]) + } + if result := a.queueAgentAction(agentFeatureResume); !result.OK { + t.Fatalf("Resume interrupted child: %v", result.Value) + } + driveCorrectiveCommand(t, &a, a.takeAgentCommand()) + if engine.retryParent != "run-child" || engine.resumeRequest.ParentRunID != "" { + t.Fatalf("interrupted child Resume = retry %q resume %#v", engine.retryParent, engine.resumeRequest) + } + }) + + t.Run("late parent answer cannot attach to selected child", func(t *testing.T) { + engine := &fixtureNativeAgentEngine{capabilities: nativeFixtureCapabilities(), snapshot: work.Snapshot{ + Runs: []work.Run{ + {ID: "run-parent", WorkID: "work-1", Status: work.RunWaiting}, + {ID: "run-child", WorkID: "work-1", Status: work.RunInterrupted}, + }, + }} + a := testNativeAgentApp(t, requireAgentAdapter(t, engine)) + if result := a.work.updateWork("waiting", "work-1", func(record *workItemRecord) { record.Status = "waiting" }); !result.OK { + t.Fatalf("set waiting: %v", result.Value) + } + a.work.agentWork["work-1"] = agentWorkSnapshot{NativeRunID: "run-parent", QuestionID: "question-parent", Question: "Which target?", Status: "waiting"} + a.work.syncList() + if result := a.queueAgentAction(agentFeatureAnswer); !result.OK { + t.Fatalf("Answer: %v", result.Value) + } + a.answerOverlay.input.SetValue("Use child target") + model, answerCommand := a.Update(tea.KeyMsg{Type: tea.KeyEnter}) + a = model.(app) + lateAnswer := answerCommand() + snapshotCommand := a.requestAgentSnapshot() + model, _ = a.Update(snapshotCommand()) + a = model.(app) + model, _ = a.Update(lateAnswer) + a = model.(app) + state := a.work.AgentState(a.work.Items()[0]) + if state.NativeRunID != "run-child" || state.AnswerID != "" || state.ResumeRunID != "" { + t.Fatalf("late parent answer attached to child: %#v", state) + } + }) +} + +func TestApp_AgentRunReplacementDropsOldPreparedReview(t *testing.T) { + review := correctiveChangeReview(true) + review.RunID = "run-reviewed-old" + engine := &fixtureNativeAgentEngine{capabilities: nativeFixtureCapabilities(), snapshot: work.Snapshot{ + Runs: []work.Run{ + {ID: review.RunID, WorkID: review.WorkID, Status: work.RunCompleted}, + {ID: "run-new-attempt", WorkID: review.WorkID, Status: work.RunRunning}, + }, + Acceptances: []work.Acceptance{{ID: "review-old", WorkID: review.WorkID, RunID: review.RunID, Status: "prepared", ValidationJSON: core.JSONMarshalString(review)}}, + }} + a := testNativeAgentApp(t, requireAgentAdapter(t, engine)) + driveCorrectiveCommand(t, &a, a.requestAgentSnapshot()) + state := a.work.AgentState(a.work.Items()[0]) + if state.NativeRunID != "run-new-attempt" || state.ReviewID != "" || state.ReviewStatus != "" || state.Review.Payload != nil { + t.Fatalf("new attempt retained old review: %#v", state) + } + a.refreshAgentPalette() + if command := a.palette.byID[agentCommandID(agentFeatureAccept)]; command.Available { + t.Fatalf("Accept available for old prepared review: %#v", command) + } +} + +func TestAppRecoveryAbandonUsesReviewConfirmationAndRefreshesLedger(t *testing.T) { + at := time.Date(2026, time.July, 19, 14, 0, 0, 0, time.UTC) + recovery := agentPendingRecovery{EventID: "recovery-event-11", Receipt: agentRecoveryReceipt{ + Kind: "run", ProjectID: "project-1", WorkID: "work-1", RunID: "attempt-11", + RunNumber: 11, WorkspaceRunID: "lineage-root", Branch: "lem/work/work-1/run-11", + Worktree: "/private/workspaces/project-1/runs/lineage-root/worktree", + }} + engine := &fixtureNativeAgentEngine{ + capabilities: append(nativeFixtureCapabilities(), work.Capability{Name: "recovery.abandon", Available: true}), + snapshot: work.Snapshot{ + Runs: []work.Run{{ID: recovery.Receipt.RunID, WorkID: recovery.Receipt.WorkID, ProjectID: recovery.Receipt.ProjectID, Number: recovery.Receipt.RunNumber, Status: work.RunFailed}}, + Events: []work.Event{{ + ID: recovery.EventID, RunID: recovery.Receipt.RunID, WorkID: recovery.Receipt.WorkID, + Kind: "workspace_cleanup_retained", Detail: recovery.Receipt.Worktree, + DetailJSON: core.JSONMarshalString(recovery.Receipt), CreatedAt: at, + }}, + }, + } + a := testNativeAgentApp(t, requireAgentAdapter(t, engine)) + driveCorrectiveCommand(t, &a, a.requestAgentSnapshot()) + if result := a.queueAgentAction(agentFeatureRecoveryAbandon); !result.OK { + t.Fatalf("queue recovery abandon: %s", result.Error()) + } + driveCorrectiveCommand(t, &a, a.takeAgentCommand()) + if a.activeOverlay != overlayLaunchReview || engine.abandonRecoveryCalls != 0 { + t.Fatalf("recovery review = overlay %d calls %d err %q", a.activeOverlay, engine.abandonRecoveryCalls, a.errText) + } + + model, confirmation := a.Update(tea.KeyMsg{Type: tea.KeyEnter}) + a = model.(app) + next := driveCorrectiveCommand(t, &a, confirmation) + if engine.abandonRecoveryCalls != 1 || engine.abandonRecoveryRunID != recovery.Receipt.RunID || engine.abandonRecoveryEventID != recovery.EventID { + t.Fatalf("recovery action = calls %d run %q event %q", engine.abandonRecoveryCalls, engine.abandonRecoveryRunID, engine.abandonRecoveryEventID) + } + engine.snapshot.Events = append(engine.snapshot.Events, work.Event{ + ID: "recovery-success-11", RunID: recovery.Receipt.RunID, WorkID: recovery.Receipt.WorkID, + Kind: "cleanup_recovery_succeeded", Detail: recovery.Receipt.Worktree, + DetailJSON: core.JSONMarshalString(agentRecoveryOutcome{RecoveryEventID: recovery.EventID, Receipt: recovery.Receipt}), CreatedAt: at.Add(time.Second), + }) + driveCorrectiveCommand(t, &a, next) + state := a.work.AgentState(a.work.Items()[0]) + if state.RecoveryCount != 0 || state.Recovery.EventID != "" { + t.Fatalf("recovery remained after success refresh: %#v", state) + } + a.refreshAgentPalette() + if command := a.palette.byID[agentCommandID(agentFeatureRecoveryAbandon)]; command.Available { + t.Fatalf("recovery action remained available after success: %#v", command) + } +} + +func TestApp_AgentReviewPreparedSnapshotAndRejectPreserveLocalWork(t *testing.T) { + review := correctiveChangeReview(true) + engine := &fixtureNativeAgentEngine{ + capabilities: nativeFixtureCapabilities(), changeReview: review, + snapshot: work.Snapshot{ + Runs: []work.Run{{ID: review.RunID, WorkID: review.WorkID, Status: work.RunCompleted}}, + Acceptances: []work.Acceptance{{ID: "review-prepared", WorkID: review.WorkID, RunID: review.RunID, Status: "prepared", ValidationJSON: core.JSONMarshalString(review)}}, + }, + } + a := testNativeAgentApp(t, requireAgentAdapter(t, engine)) + if result := a.work.updateWork("completed", review.WorkID, func(record *workItemRecord) { record.Status = "completed" }); !result.OK { + t.Fatalf("set completed: %v", result.Value) + } + a.work.agentWork[review.WorkID] = agentWorkSnapshot{NativeRunID: review.RunID, Status: "completed"} + localEvent := eventRecord{ID: "local-review-note", SessionID: workEventSessionID(a.work.Items()[0]), WorkItemID: review.WorkID, Kind: "local.note", Status: "recorded", Title: "keep me", PayloadJSON: "{}", CreatedAt: time.Now().UTC()} + if result := a.work.repository.SaveEvent(localEvent); !result.OK { + t.Fatalf("SaveEvent: %v", result.Value) + } + if result := a.work.RefreshLocal(); !result.OK { + t.Fatalf("RefreshLocal: %v", result.Value) + } + if result := a.queueAgentAction(agentFeatureChangesReview); !result.OK { + t.Fatalf("Review Changes: %v", result.Value) + } + model, snapshotCommand := a.Update(a.takeAgentCommand()()) + a = model.(app) + if snapshotCommand == nil || a.activeOverlay != overlayChangeReview || engine.reviewChangesRunID != review.RunID || core.JSONMarshalString(a.agentReview.Payload) != core.JSONMarshalString(review) { + t.Fatalf("fresh review = snapshot %v overlay %d run %q payload %#v", snapshotCommand != nil, a.activeOverlay, engine.reviewChangesRunID, a.agentReview.Payload) + } + model, _ = a.Update(snapshotCommand()) + a = model.(app) + state := a.work.AgentState(a.work.Items()[0]) + if state.ReviewID != "review-prepared" || state.ReviewStatus != "prepared" || core.JSONMarshalString(state.Review.Payload) != core.JSONMarshalString(review) { + t.Fatalf("durable prepared review state = %#v", state) + } + model, _ = a.Update(tea.KeyMsg{Type: tea.KeyEsc}) + a = model.(app) + if command := a.palette.byID[agentCommandID(agentFeatureReject)]; !command.Available { + t.Fatalf("Reject unavailable after review escape: %#v", command) + } + if result := a.palette.Invoke(agentCommandID(agentFeatureReject), &a); !result.OK { + t.Fatalf("Reject invoke: %v", result.Value) + } + driveCorrectiveCommand(t, &a, a.takeAgentCommand()) + if engine.rejectRunID != review.RunID { + t.Fatalf("Reject native run = %q, want %q", engine.rejectRunID, review.RunID) + } + storedItems := a.work.repository.ListWorkItems(false).Value.([]workItemRecord) + storedEvents := a.work.repository.Events(workEventSessionID(storedItems[0])).Value.([]eventRecord) + if len(storedItems) != 1 || storedItems[0].ID != review.WorkID || storedItems[0].Title != "Ship" || len(storedEvents) != 1 || storedEvents[0].ID != localEvent.ID { + t.Fatalf("Reject mutated local state: items %#v events %#v", storedItems, storedEvents) + } +} + +func TestApp_AgentPreparedAcceptRequiresReviewAndFinalConfirmation(t *testing.T) { + for _, test := range []struct { + name string + validated bool + acknowledgement bool + }{ + {name: "validated", validated: true}, + {name: "no validation", acknowledgement: true}, + } { + t.Run(test.name, func(t *testing.T) { + review := correctiveChangeReview(test.validated) + engine := &fixtureNativeAgentEngine{capabilities: nativeFixtureCapabilities()} + a := testNativeAgentApp(t, requireAgentAdapter(t, engine)) + a.work.agentWork[review.WorkID] = agentWorkSnapshot{ + NativeRunID: review.RunID, ReviewID: "review-prepared", ReviewStatus: "prepared", + Review: mapChangeReview(review, true), + } + a.refreshAgentPalette() + if result := a.palette.Invoke(agentCommandID(agentFeatureAccept), &a); !result.OK { + t.Fatalf("Accept invoke: %v", result.Value) + } + if a.activeOverlay != overlayChangeReview || engine.acceptRequest.Confirmed { + t.Fatalf("prepared Accept = overlay %d request %#v", a.activeOverlay, engine.acceptRequest) + } + if test.acknowledgement { + model, command := a.Update(tea.KeyMsg{Type: tea.KeyEnter}) + a = model.(app) + if command != nil || a.changeOverlay.final || engine.acceptRequest.Confirmed { + t.Fatalf("unacknowledged enter = command %v final %v accept %#v", command != nil, a.changeOverlay.final, engine.acceptRequest) + } + model, _ = a.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}) + a = model.(app) + if !a.changeOverlay.acknowledged { + t.Fatal("a did not acknowledge missing validation") + } + } + model, command := a.Update(tea.KeyMsg{Type: tea.KeyEnter}) + a = model.(app) + if command != nil || !a.changeOverlay.final || engine.acceptRequest.Confirmed { + t.Fatalf("review confirmation = command %v final %v accept %#v", command != nil, a.changeOverlay.final, engine.acceptRequest) + } + model, command = a.Update(tea.KeyMsg{Type: tea.KeyEnter}) + a = model.(app) + if command == nil || engine.acceptRequest.Confirmed { + t.Fatalf("final confirmation scheduling = command %v accept %#v", command != nil, engine.acceptRequest) + } + driveCorrectiveCommand(t, &a, command) + want := workspace.AcceptRequest{Review: review, Confirmed: true} + if core.JSONMarshalString(engine.acceptRequest) != core.JSONMarshalString(want) { + t.Fatalf("Accept request = %#v, want %#v", engine.acceptRequest, want) + } + }) + } +} + +func TestApp_AgentBlockedReviewStaysScrollableAndRejectable(t *testing.T) { + for _, test := range []struct { + name string + review workspace.ChangeReview + }{ + {name: "conflict", review: func() workspace.ChangeReview { + review := correctiveChangeReview(true) + review.Conflicts = []string{"a.go content conflict"} + return review + }()}, + {name: "failed validation", review: func() workspace.ChangeReview { + review := correctiveChangeReview(true) + review.Validation[0].Passed = false + review.Validation[0].ExitCode = 1 + review.Validation[0].Output = "FAIL package" + return review + }()}, + } { + t.Run(test.name, func(t *testing.T) { + lines := make([]string, 80) + for index := range lines { + lines[index] = core.Sprintf("diff line %03d", index) + } + test.review.Diff = core.Join("\n", lines...) + engine := &fixtureNativeAgentEngine{ + capabilities: nativeFixtureCapabilities(), changeReview: test.review, + snapshot: work.Snapshot{ + Runs: []work.Run{{ID: test.review.RunID, WorkID: test.review.WorkID, Status: work.RunCompleted}}, + Acceptances: []work.Acceptance{{ID: "review-blocked", WorkID: test.review.WorkID, RunID: test.review.RunID, Status: "prepared", ValidationJSON: core.JSONMarshalString(test.review)}}, + }, + } + a := testNativeAgentApp(t, requireAgentAdapter(t, engine)) + if result := a.work.updateWork("completed", test.review.WorkID, func(record *workItemRecord) { record.Status = "completed" }); !result.OK { + t.Fatalf("set completed: %v", result.Value) + } + a.work.agentWork[test.review.WorkID] = agentWorkSnapshot{NativeRunID: test.review.RunID, Status: "completed"} + if result := a.queueAgentAction(agentFeatureChangesReview); !result.OK { + t.Fatalf("Review Changes: %v", result.Value) + } + model, snapshotCommand := a.Update(a.takeAgentCommand()()) + a = model.(app) + model, _ = a.Update(snapshotCommand()) + a = model.(app) + a.changeOverlay.View(48, 12, a.styles) + model, _ = a.Update(tea.KeyMsg{Type: tea.KeyPgDown}) + a = model.(app) + if a.changeOverlay.viewport.YOffset == 0 || a.changeOverlay.viewport.TotalLineCount() < len(lines) { + t.Fatalf("blocked review viewport = offset %d lines %d", a.changeOverlay.viewport.YOffset, a.changeOverlay.viewport.TotalLineCount()) + } + model, command := a.Update(tea.KeyMsg{Type: tea.KeyEnter}) + a = model.(app) + if command != nil || a.activeOverlay != overlayChangeReview || strings.Contains(core.JSONMarshalString(engine.calls), "accept") || !strings.Contains(a.errText, "prevent acceptance") { + t.Fatalf("blocked Accept = command %v overlay %d calls %#v error %q", command != nil, a.activeOverlay, engine.calls, a.errText) + } + model, _ = a.Update(tea.KeyMsg{Type: tea.KeyEsc}) + a = model.(app) + if result := a.palette.Invoke(agentCommandID(agentFeatureReject), &a); !result.OK { + t.Fatalf("Reject blocked review: %v", result.Value) + } + driveCorrectiveCommand(t, &a, a.takeAgentCommand()) + if engine.rejectRunID != test.review.RunID || strings.Contains(core.JSONMarshalString(engine.calls), "accept") { + t.Fatalf("blocked review mutation = reject %q calls %#v", engine.rejectRunID, engine.calls) + } + }) + } +} + +func TestApp_AgentQueueStateGatesAndExecutesNativeControls(t *testing.T) { + engine := &fixtureNativeAgentEngine{capabilities: nativeFixtureCapabilities()} + a := testNativeAgentApp(t, requireAgentAdapter(t, engine)) + a.work.queueStatus = "frozen" + a.refreshAgentPalette() + if result := a.palette.Invoke(agentCommandID(agentFeatureQueueStart), &a); !result.OK { + t.Fatalf("Start frozen queue: %v", result.Value) + } + driveCorrectiveCommand(t, &a, a.takeAgentCommand()) + if countAgentCall(engine.calls, "queue.start") != 1 { + t.Fatalf("queue start calls = %#v", engine.calls) + } + a.work.queueStatus = "accepting" + a.refreshAgentPalette() + if result := a.palette.Invoke(agentCommandID(agentFeatureQueueStop), &a); !result.OK { + t.Fatalf("Stop accepting queue: %v", result.Value) + } + driveCorrectiveCommand(t, &a, a.takeAgentCommand()) + if countAgentCall(engine.calls, "queue.stop") != 1 { + t.Fatalf("queue stop calls = %#v", engine.calls) + } + a.work.queueStatus = "draining" + a.refreshAgentPalette() + for _, feature := range []agentFeature{agentFeatureQueueStart, agentFeatureQueueStop} { + before := len(engine.calls) + if result := a.palette.Invoke(agentCommandID(feature), &a); result.OK { + t.Fatalf("%s admitted while draining", feature) + } + if len(engine.calls) != before { + t.Fatalf("%s mutated provider while draining: %#v", feature, engine.calls) + } + } +} + +func TestApp_AgentReviewEscapeAbortsTransaction(t *testing.T) { + for _, overlay := range []overlayKind{overlayAgentSelection, overlayProjectReview, overlayGitEnableReview, overlayLaunchReview} { + t.Run(core.Sprintf("overlay-%d", overlay), func(t *testing.T) { + a := newApp("", 0, 64) + a.agentOperationID, a.agentOperationNext = 7, 7 + a.agentStage = agentReviewProject + a.agentRequest = agentRequest{Feature: agentFeatureDispatch, Provider: "codex"} + a.agentReview = agentReview{Title: "review"} + a.activeOverlay = overlay + a.launchReview = newAgentSelectionOverlay("codex", "gpt-5") + model, _ := a.Update(tea.KeyMsg{Type: tea.KeyEsc}) + a = model.(app) + if a.activeOverlay != overlayNone || a.agentStage != agentReviewNone || a.agentOperationID != 0 || a.agentRequest.Feature != "" || a.agentReview.Title != "" { + t.Fatalf("escape state = overlay=%d stage=%d id=%d request=%#v review=%#v", a.activeOverlay, a.agentStage, a.agentOperationID, a.agentRequest, a.agentReview) + } + }) + } +} + +func TestApp_AgentReviewNativeAdapterTransactions(t *testing.T) { + project := testNativeProjectReview("work-1", false) + dispatch := testNativeDispatchReview(project) + engine := &fixtureNativeAgentEngine{capabilities: nativeFixtureCapabilities(), projectReview: project, registeredProject: dispatch.Project, dispatchReview: dispatch} + adapter := requireAgentAdapter(t, engine) + a := testNativeAgentApp(t, adapter) + + if result := a.queueAgentAction(agentFeatureDispatch); !result.OK { + t.Fatalf("dispatch: %v", result.Value) + } + startAgentProjectReview(t, &a, "codex", "gpt-5") + if !strings.Contains(a.agentReview.Body, "Included files: 2") { + t.Fatalf("ad-hoc included-file review:\n%s", a.agentReview.Body) + } + registration, ok := a.agentReview.Payload.(agentProjectRegistration) + if !ok || core.JSONMarshalString(registration.Review) != core.JSONMarshalString(project) || registration.Provider != "codex" || registration.Model != "gpt-5" { + t.Fatalf("project payload = %#v", a.agentReview.Payload) + } + model, command := a.Update(tea.KeyMsg{Type: tea.KeyEnter}) + a = model.(app) + if command == nil || engine.registerCalls != 0 || engine.dispatchCalls != 0 || a.agentRequest.EnableGit { + t.Fatalf("clean project confirmation = command=%v register=%d dispatch=%d enableGit=%v", command != nil, engine.registerCalls, engine.dispatchCalls, a.agentRequest.EnableGit) + } + model, _ = a.Update(command()) + a = model.(app) + launch, ok := a.agentReview.Payload.(orchestrator.DispatchReview) + if !ok || core.JSONMarshalString(launch) != core.JSONMarshalString(dispatch) || a.activeOverlay != overlayLaunchReview || engine.registerCalls != 1 || engine.dispatchCalls != 0 { + t.Fatalf("launch payload = %#v register=%d dispatch=%d", a.agentReview.Payload, engine.registerCalls, engine.dispatchCalls) + } + for _, want := range []string{"Provider: codex", "Model: gpt-5", "Command: codex exec --api-key [REDACTED] --model gpt-5", "Source: /src/project", "Branch: main", "Revision: abc123", "Private repository: work-1", "Worktree: /private/runs/pending-run/worktree", "Queue: ready for admission"} { + if !strings.Contains(a.agentReview.Body, want) { + t.Fatalf("launch body missing %q:\n%s", want, a.agentReview.Body) + } + } + for _, width := range []int{48, 120} { + a.width, a.height = width, 22 + view := a.View() + wants := []string{"Command:", "codex", "exec", "--api-key", "[REDACTED]", "--model", "gpt-5", "native host access"} + if width >= 100 { + wants = append(wants, "Command: codex exec --api-key [REDACTED] --model gpt-5") + } + for _, want := range wants { + if !strings.Contains(view, want) { + t.Fatalf("width %d launch view missing %q:\n%s", width, want, view) + } + } + for line, text := range strings.Split(view, "\n") { + if got := lipgloss.Width(text); got > width { + t.Fatalf("width %d line %d overflows at %d", width, line, got) + } + } + } + model, command = a.Update(tea.KeyMsg{Type: tea.KeyEnter}) + a = model.(app) + if command == nil || engine.dispatchCalls != 0 { + t.Fatalf("launch confirmation = command=%v dispatch=%d", command != nil, engine.dispatchCalls) + } + model, _ = a.Update(command()) + a = model.(app) + if engine.dispatchCalls != 1 || a.work.Items()[0].Status != "queued" { + t.Fatalf("final dispatch = calls=%d work=%#v", engine.dispatchCalls, a.work.Items()) + } +} + +func TestApp_AgentReviewNativeAdapterGitConfirmationAndFailure(t *testing.T) { + project := testNativeProjectReview("work-1", true) + dispatch := testNativeDispatchReview(project) + engine := &fixtureNativeAgentEngine{capabilities: nativeFixtureCapabilities(), projectReview: project, registeredProject: dispatch.Project, dispatchReview: dispatch} + adapter := requireAgentAdapter(t, engine) + a := testNativeAgentApp(t, adapter) + if result := a.queueAgentAction(agentFeatureDispatch); !result.OK { + t.Fatalf("dispatch: %v", result.Value) + } + startAgentProjectReview(t, &a, "codex", "gpt-5") + model, command := a.Update(tea.KeyMsg{Type: tea.KeyEnter}) + a = model.(app) + if command != nil || a.activeOverlay != overlayGitEnableReview || engine.registerCalls != 0 { + t.Fatalf("ad-hoc project enter = command=%v overlay=%d register=%d", command != nil, a.activeOverlay, engine.registerCalls) + } + model, _ = a.Update(tea.KeyMsg{Type: tea.KeyEsc}) + a = model.(app) + if engine.registerCalls != 0 || a.agentStage != agentReviewNone { + t.Fatalf("Git cancel = register=%d stage=%d", engine.registerCalls, a.agentStage) + } + + if result := a.queueAgentAction(agentFeatureDispatch); !result.OK { + t.Fatalf("fresh dispatch: %v", result.Value) + } + startAgentProjectReview(t, &a, "codex", "gpt-5") + model, _ = a.Update(tea.KeyMsg{Type: tea.KeyEnter}) + a = model.(app) + model, command = a.Update(tea.KeyMsg{Type: tea.KeyEnter}) + a = model.(app) + if command == nil || !a.agentRequest.EnableGit || engine.registerCalls != 0 { + t.Fatalf("Git confirmation = command=%v enable=%v register=%d", command != nil, a.agentRequest.EnableGit, engine.registerCalls) + } + model, _ = a.Update(command()) + a = model.(app) + if engine.registerCalls != 1 || a.activeOverlay != overlayLaunchReview { + t.Fatalf("Git registration = register=%d overlay=%d", engine.registerCalls, a.activeOverlay) + } + + failure := core.Fail(core.E("test.register", "included file hash changed", nil)) + failedEngine := &fixtureNativeAgentEngine{capabilities: nativeFixtureCapabilities(), projectReview: project, registeredProject: dispatch.Project, dispatchReview: dispatch, registerProject: func(context.Context, orchestrator.ProjectReview, bool) core.Result { return failure }} + failedApp := testNativeAgentApp(t, requireAgentAdapter(t, failedEngine)) + if result := failedApp.queueAgentAction(agentFeatureDispatch); !result.OK { + t.Fatalf("failed dispatch: %v", result.Value) + } + startAgentProjectReview(t, &failedApp, "codex", "gpt-5") + model, _ = failedApp.Update(tea.KeyMsg{Type: tea.KeyEnter}) + failedApp = model.(app) + model, command = failedApp.Update(tea.KeyMsg{Type: tea.KeyEnter}) + failedApp = model.(app) + model, _ = failedApp.Update(command()) + failedApp = model.(app) + if !strings.Contains(failedApp.errText, "included file hash changed") || failedEngine.reviewDispatchCalls != 0 || failedEngine.dispatchCalls != 0 { + t.Fatalf("changed hash failure = err=%q reviewDispatch=%d dispatch=%d", failedApp.errText, failedEngine.reviewDispatchCalls, failedEngine.dispatchCalls) + } +} + +func TestApp_AgentReviewNativeAdapterProjectFailuresDoNotRegister(t *testing.T) { + for _, reason := range []string{"source repository is dirty", "source repository is detached"} { + t.Run(reason, func(t *testing.T) { + engine := &failingProjectNativeEngine{fixtureNativeAgentEngine: &fixtureNativeAgentEngine{capabilities: nativeFixtureCapabilities()}, failure: core.Fail(core.E("test.project", reason, nil))} + a := testNativeAgentApp(t, requireAgentAdapter(t, engine)) + if result := a.queueAgentAction(agentFeatureDispatch); !result.OK { + t.Fatalf("dispatch: %v", result.Value) + } + startAgentProjectReview(t, &a, "codex", "gpt-5") + if !strings.Contains(a.errText, reason) || engine.registerCalls != 0 || engine.dispatchCalls != 0 { + t.Fatalf("project failure = err=%q register=%d dispatch=%d", a.errText, engine.registerCalls, engine.dispatchCalls) + } + }) + } +} + +func TestWorkEditor_CtrlSSavesAndEscapeCancels(t *testing.T) { + repository := openTestDuckRepository(t) + defer closeTestDuckRepository(t, repository) + provider := &launchReviewProvider{caps: []agentCapability{{Feature: agentFeatureDispatch, Available: true}}} + a := newApp("", 0, 64) + if result := a.attachWork(repository, provider); !result.OK { + t.Fatalf("attachWork: %v", result.Value) + } + if result := a.openWorkEditor(workItemRecord{}); !result.OK { + t.Fatalf("open editor: %v", result.Value) + } + a.workEditor.title.SetValue("Keyboard save") + a.workEditor.task.SetValue("Complete task") + a.workEditor.repository.SetValue("/tmp/repository") + model, _ := a.Update(tea.KeyMsg{Type: tea.KeyCtrlS}) + a = model.(app) + if a.activeOverlay != overlayNone || len(a.work.Items()) != 1 || len(provider.reviewRequests) != 0 || len(provider.runs) != 0 { + t.Fatalf("ctrl+s state = overlay=%d work=%#v", a.activeOverlay, a.work.Items()) + } + if result := a.openWorkEditor(workItemRecord{}); !result.OK { + t.Fatalf("open cancellation editor: %v", result.Value) + } + a.workEditor.title.SetValue("Cancelled") + model, _ = a.Update(tea.KeyMsg{Type: tea.KeyEsc}) + a = model.(app) + if a.activeOverlay != overlayNone || len(a.work.Items()) != 1 { + t.Fatalf("escape state = overlay=%d work=%#v", a.activeOverlay, a.work.Items()) + } +} + +// ---- Data panel wiring (Task 8) ---- + +func assertDataReviewPending(t *testing.T, store dataset.Store, itemID string) { + t.Helper() + review := core.MustCast[dataset.Review](store.ReviewLatest(itemID)) + if review.Status != dataset.StatusPending { + t.Fatalf("item %s status = %q, want still pending (no write yet)", itemID, review.Status) + } +} + +func TestApp_AttachDataWiresPanelAndPalette(t *testing.T) { + store := newTestDataPanelStore(t) + a := newApp("", 0, 64) + if result := a.attachData(store); !result.OK { + t.Fatalf("attachData: %v", result.Value) + } + if a.data == nil { + t.Fatal("attachData left a.data nil") + } + if _, exists := a.palette.byID[dataCommandID(dataActionApprove, false)]; !exists { + t.Fatal("attachData did not seed the palette's data.* commands") + } +} + +func TestApp_AttachDataNilStoreLeavesPanelHonestlyUnavailable(t *testing.T) { + a := newApp("", 0, 64) + if result := a.attachData(nil); !result.OK { + t.Fatalf("attachData(nil): %v", result.Value) + } + if a.data != nil { + t.Fatalf("attachData(nil) left a.data non-nil: %#v", a.data) + } + a.activePanel = panelData + view := a.panelView() + if !strings.Contains(view, "No dataset store connected") { + t.Fatalf("panelView with no data store:\n%s", view) + } +} + +func TestApp_RoutePanelDataDispatchesToDataPanel(t *testing.T) { + store := newTestDataPanelStore(t) + at := time.Now() + ds := seedDataDataset(t, store, "vents", at) + seedDataItem(t, store, ds.ID, "first", "first", at) + seedDataItem(t, store, ds.ID, "second", "second", at.Add(time.Second)) + + a := newApp("", 0, 64) + if result := a.attachData(store); !result.OK { + t.Fatalf("attachData: %v", result.Value) + } + a.activePanel = panelData + before, ok := a.data.Selected() + if !ok { + t.Fatal("no initial selection") + } + model, _ := a.Update(keyMsg("j")) + a = model.(app) + after, ok := a.data.Selected() + if !ok || after.Item.ID == before.Item.ID { + t.Fatalf("j navigation did not move the selection: before=%s after=%s ok=%v", before.Item.ID, after.Item.ID, ok) + } +} + +func TestApp_PanelViewDispatchesToDataPanel(t *testing.T) { + store := newTestDataPanelStore(t) + at := time.Now() + ds := seedDataDataset(t, store, "vents", at) + seedDataItem(t, store, ds.ID, "prompt-marker", "response-marker", at) + + a := newApp("", 0, 64) + if result := a.attachData(store); !result.OK { + t.Fatalf("attachData: %v", result.Value) + } + a.activePanel, a.width, a.height = panelData, 120, 40 + view := a.panelView() + if !strings.Contains(view, "DATA") { + t.Fatalf("panelView for panelData missing the panel's own header:\n%s", view) + } +} + +func TestApp_DataPanelHotkeys_ApproveReject(t *testing.T) { + store := newTestDataPanelStore(t) + at := time.Now() + ds := seedDataDataset(t, store, "vents", at) + item := seedDataItem(t, store, ds.ID, "p", "r", at) + + a := newApp("", 0, 64) + if result := a.attachData(store); !result.OK { + t.Fatalf("attachData: %v", result.Value) + } + a.activePanel = panelData + + model, _ := a.Update(keyMsg("a")) + a = model.(app) + if a.errText != "" { + t.Fatalf("approve hotkey errText = %q", a.errText) + } + if review := core.MustCast[dataset.Review](store.ReviewLatest(item.ID)); review.Status != dataset.StatusApproved { + t.Fatalf("status after 'a' = %q", review.Status) + } + + model, _ = a.Update(keyMsg("r")) + a = model.(app) + if review := core.MustCast[dataset.Review](store.ReviewLatest(item.ID)); review.Status != dataset.StatusRejected { + t.Fatalf("status after 'r' = %q", review.Status) + } +} + +func TestApp_DataPanelHotkeys_TagOverlayFlow(t *testing.T) { + store := newTestDataPanelStore(t) + at := time.Now() + ds := seedDataDataset(t, store, "vents", at) + item := seedDataItem(t, store, ds.ID, "p", "r", at) + + a := newApp("", 0, 64) + if result := a.attachData(store); !result.OK { + t.Fatalf("attachData: %v", result.Value) + } + a.activePanel = panelData + + model, _ := a.Update(keyMsg("t")) + a = model.(app) + if a.activeOverlay != overlayDataNote || a.dataNote == nil { + t.Fatalf("t did not open the note overlay: overlay=%d note=%v", a.activeOverlay, a.dataNote) + } + for _, r := range "hand-picked" { + model, _ = a.Update(keyMsg(string(r))) + a = model.(app) + } + model, _ = a.Update(keyMsg("enter")) + a = model.(app) + if a.activeOverlay != overlayNone || a.dataNote != nil { + t.Fatalf("enter did not close the note overlay: overlay=%d note=%v", a.activeOverlay, a.dataNote) + } + if review := core.MustCast[dataset.Review](store.ReviewLatest(item.ID)); review.Note != "tag: hand-picked" { + t.Fatalf("tag review = %#v", review) + } +} + +func TestApp_DataPanelHotkeys_QuarantineClearRequiresNote(t *testing.T) { + store := newTestDataPanelStore(t) + at := time.Now() + ds := seedDataDataset(t, store, "vents", at) + item := seedDataItem(t, store, ds.ID, "p", "r", at) + if result := store.ReviewAppend(dataset.Review{ItemID: item.ID, Status: dataset.StatusQuarantined, Reviewer: dataset.ReviewerAutoWelfare, CreatedAt: at}); !result.OK { + t.Fatalf("seed quarantine: %v", result.Value) + } + + a := newApp("", 0, 64) + if result := a.attachData(store); !result.OK { + t.Fatalf("attachData: %v", result.Value) + } + a.activePanel = panelData + + model, _ := a.Update(keyMsg("c")) + a = model.(app) + if a.activeOverlay != overlayDataNote { + t.Fatalf("c did not open the note overlay: overlay=%d", a.activeOverlay) + } + // An empty Enter must not submit — no write. + model, _ = a.Update(keyMsg("enter")) + a = model.(app) + if a.activeOverlay != overlayDataNote { + t.Fatalf("empty enter closed the overlay without a note: overlay=%d", a.activeOverlay) + } + if review := core.MustCast[dataset.Review](store.ReviewLatest(item.ID)); review.Status != dataset.StatusQuarantined { + t.Fatalf("status changed despite an empty note: %q", review.Status) + } + for _, r := range "false positive" { + model, _ = a.Update(keyMsg(string(r))) + a = model.(app) + } + model, _ = a.Update(keyMsg("enter")) + a = model.(app) + if a.activeOverlay != overlayNone { + t.Fatalf("non-empty enter did not close the overlay: overlay=%d", a.activeOverlay) + } + if review := core.MustCast[dataset.Review](store.ReviewLatest(item.ID)); review.Status != dataset.StatusApproved || review.Note != "false positive" { + t.Fatalf("cleared review = %#v", review) + } +} + +func TestApp_DataPanelHotkeys_EditAsDerivedFlow(t *testing.T) { + store := newTestDataPanelStore(t) + at := time.Now() + ds := seedDataDataset(t, store, "vents", at) + original := seedDataItem(t, store, ds.ID, "original", "original response", at) + + a := newApp("", 0, 64) + if result := a.attachData(store); !result.OK { + t.Fatalf("attachData: %v", result.Value) + } + a.activePanel = panelData + + model, _ := a.Update(keyMsg("e")) + a = model.(app) + if a.activeOverlay != overlayDataEditor || a.dataEditor == nil { + t.Fatalf("e did not open the editor: overlay=%d editor=%v", a.activeOverlay, a.dataEditor) + } + a.dataEditor.focus = 1 + a.dataEditor.applyFocus() + a.dataEditor.response.SetValue("edited response") + + model, _ = a.Update(tea.KeyMsg{Type: tea.KeyCtrlS}) + a = model.(app) + if a.activeOverlay != overlayNone || a.dataEditor != nil { + t.Fatalf("ctrl+s did not close the editor: overlay=%d editor=%v", a.activeOverlay, a.dataEditor) + } + if reread := core.MustCast[dataset.Item](store.Item(original.ID)); !reread.Archived { + t.Fatal("ctrl+s did not archive the original") + } +} + +// TestApp_DataPanelBulkConfirmGate is the explicit "no confirm, no writes" +// proof the task requires, driven end-to-end through real key messages: +// opening the bulk overlay writes nothing; Escape cancels and still +// writes nothing; a single Enter only arms (still nothing written); only +// the SECOND Enter applies the action. +func TestApp_DataPanelBulkConfirmGate(t *testing.T) { + store := newTestDataPanelStore(t) + at := time.Now() + ds := seedDataDataset(t, store, "vents", at) + itemA := seedDataItem(t, store, ds.ID, "a", "a", at) + itemB := seedDataItem(t, store, ds.ID, "b", "b", at.Add(time.Second)) + + a := newApp("", 0, 64) + if result := a.attachData(store); !result.OK { + t.Fatalf("attachData: %v", result.Value) + } + a.activePanel = panelData + + model, _ := a.Update(keyMsg("A")) + a = model.(app) + if a.activeOverlay != overlayDataBulk || a.dataBulk == nil { + t.Fatalf("A did not open the bulk overlay: overlay=%d bulk=%v", a.activeOverlay, a.dataBulk) + } + assertDataReviewPending(t, store, itemA.ID) + assertDataReviewPending(t, store, itemB.ID) + + model, _ = a.Update(keyMsg("esc")) + a = model.(app) + if a.activeOverlay != overlayNone || a.dataBulk != nil { + t.Fatalf("esc did not clear the bulk overlay: overlay=%d bulk=%v", a.activeOverlay, a.dataBulk) + } + assertDataReviewPending(t, store, itemA.ID) + assertDataReviewPending(t, store, itemB.ID) + + model, _ = a.Update(keyMsg("A")) + a = model.(app) + model, _ = a.Update(keyMsg("enter")) + a = model.(app) + if a.activeOverlay != overlayDataBulk || a.dataBulk == nil || !a.dataBulk.armed { + t.Fatalf("first enter did not arm: overlay=%d bulk=%#v", a.activeOverlay, a.dataBulk) + } + assertDataReviewPending(t, store, itemA.ID) + assertDataReviewPending(t, store, itemB.ID) + + model, _ = a.Update(keyMsg("enter")) + a = model.(app) + if a.activeOverlay != overlayNone || a.dataBulk != nil { + t.Fatalf("second enter did not close the overlay: overlay=%d bulk=%v", a.activeOverlay, a.dataBulk) + } + for _, id := range []string{itemA.ID, itemB.ID} { + if review := core.MustCast[dataset.Review](store.ReviewLatest(id)); review.Status != dataset.StatusApproved { + t.Fatalf("item %s status = %q after confirm, want approved", id, review.Status) + } + } +} + +func testNativeAgentApp(t *testing.T, provider agentProvider) app { + t.Helper() + repository := openTestDuckRepository(t) + t.Cleanup(func() { closeTestDuckRepository(t, repository) }) + a := newApp("", 0, 64) + if result := a.attachWork(repository, provider); !result.OK { + t.Fatalf("attachWork: %v", result.Value) + } + a.work.ids = sequenceIDs("work-1") + if result := a.work.CreateWork("Ship", "Implement the slice", "/src/project"); !result.OK { + t.Fatalf("CreateWork: %v", result.Value) + } + return a +} + +func testNativeProjectReview(workID string, enableGit bool) orchestrator.ProjectReview { + return orchestrator.ProjectReview{Work: work.Item{ID: workID, Title: "Ship", Task: "Implement the slice", Repository: "/src/project"}, Source: workspace.SourceReview{Path: "/src/project", Root: "/src/project", Branch: "main", Revision: "abc123", IncludedHash: "hash", Included: []string{"go.mod", "main.go"}}, RepositoryName: workID, RequiresGitEnable: enableGit} +} + +func testNativeDispatchReview(project orchestrator.ProjectReview) orchestrator.DispatchReview { + return orchestrator.DispatchReview{Request: work.DispatchRequest{Work: project.Work, Provider: "codex", Model: "gpt-5", ConfirmedSourceRevision: project.Source.Revision}, Project: work.Project{ID: project.Work.ID, SourcePath: project.Source.Path, SourceRevision: project.Source.Revision, RepositoryName: project.RepositoryName}, Source: project.Source, Command: provider.Command{Provider: "codex", Executable: "codex", Args: []string{"exec", "--api-key", "[REDACTED]", "--model", "gpt-5"}, Receipt: "codex exec --api-key [REDACTED] --model gpt-5"}, WorktreePath: "/private/runs/pending-run/worktree", Warning: "native host access"} +} + +type failingProjectNativeEngine struct { + *fixtureNativeAgentEngine + failure core.Result +} + +func (engine *failingProjectNativeEngine) ReviewProject(context.Context, work.Item) core.Result { + return engine.failure +} + +func TestApp_AgentReviewUsesStageInsteadOfReviewTitle(t *testing.T) { + repository := openTestDuckRepository(t) + defer closeTestDuckRepository(t, repository) + provider := &launchReviewProvider{caps: []agentCapability{{Feature: agentFeatureDispatch, Available: true}}, reviews: []agentReview{{Feature: agentFeatureDispatch, Title: "Localised provider wording", Body: "Source: /tmp/repo", ConfirmRequired: true}}} + a := newApp("", 0, 64) + if result := a.attachWork(repository, provider); !result.OK { + t.Fatalf("attachWork: %v", result.Value) + } + if result := a.work.CreateWork("Launch", "Run the task", "/tmp/repo"); !result.OK { + t.Fatalf("CreateWork: %v", result.Value) + } + if result := a.palette.Invoke(agentCommandID(agentFeatureDispatch), &a); !result.OK { + t.Fatalf("dispatch: %v", result.Value) + } + startAgentProjectReview(t, &a, "codex", "gpt-5") + if a.activeOverlay != overlayProjectReview { + t.Fatalf("project review overlay = %d, want %d", a.activeOverlay, overlayProjectReview) + } +} + +func TestApp_AgentReviewSelectsProviderAndModelBeforeProjectReview(t *testing.T) { + repository := openTestDuckRepository(t) + defer closeTestDuckRepository(t, repository) + provider := &launchReviewProvider{caps: []agentCapability{{Feature: agentFeatureDispatch, Available: true}}, reviews: []agentReview{{Feature: agentFeatureDispatch, Title: "Project", ConfirmRequired: true}}} + a := newApp("", 0, 64) + if result := a.attachWork(repository, provider); !result.OK { + t.Fatalf("attachWork: %v", result.Value) + } + if result := a.work.CreateWork("Launch", "Run the task", "/tmp/repo"); !result.OK { + t.Fatalf("CreateWork: %v", result.Value) + } + if result := a.palette.Invoke(agentCommandID(agentFeatureDispatch), &a); !result.OK { + t.Fatalf("dispatch: %v", result.Value) + } + if a.activeOverlay != overlayAgentSelection || a.agentCommand != nil { + t.Fatalf("selection state = overlay %d command=%v", a.activeOverlay, a.agentCommand != nil) + } + a.launchReview.providerInput.SetValue("codex") + a.launchReview.modelInput.SetValue("gpt-5") + model, command := a.Update(tea.KeyMsg{Type: tea.KeyEnter}) + a = model.(app) + if command == nil { + t.Fatal("provider selection did not schedule a project review") + } + model, _ = a.Update(command()) + a = model.(app) + if len(provider.reviewRequests) != 1 || provider.reviewRequests[0].Provider != "codex" || provider.reviewRequests[0].Model != "gpt-5" || a.activeOverlay != overlayProjectReview { + t.Fatalf("project review = requests=%#v overlay=%d", provider.reviewRequests, a.activeOverlay) + } +} + +func TestApp_AgentReviewFailureDoesNotRun(t *testing.T) { + for _, reason := range []string{"source repository is dirty", "source repository is detached", "included file hash changed"} { + t.Run(reason, func(t *testing.T) { + repository := openTestDuckRepository(t) + defer closeTestDuckRepository(t, repository) + provider := &failingLaunchReviewProvider{reason: reason} + a := newApp("", 0, 64) + if result := a.attachWork(repository, provider); !result.OK { + t.Fatalf("attachWork: %v", result.Value) + } + if result := a.work.CreateWork("Launch", "Run the task", "/tmp/repo"); !result.OK { + t.Fatalf("CreateWork: %v", result.Value) + } + if result := a.palette.Invoke(agentCommandID(agentFeatureDispatch), &a); !result.OK { + t.Fatalf("dispatch: %v", result.Value) + } + startAgentProjectReview(t, &a, "codex", "gpt-5") + if !strings.Contains(a.errText, reason) || provider.runs != 0 || a.activeOverlay != overlayNone { + t.Fatalf("failure=%q err=%q runs=%d overlay=%d", reason, a.errText, provider.runs, a.activeOverlay) + } + }) + } +} + +func startAgentProjectReview(t *testing.T, target *app, provider, model string) { + t.Helper() + if target.activeOverlay != overlayAgentSelection || target.launchReview == nil { + t.Fatalf("agent selection = overlay %d review=%v", target.activeOverlay, target.launchReview != nil) + } + target.launchReview.providerInput.SetValue(provider) + target.launchReview.modelInput.SetValue(model) + next, command := target.Update(tea.KeyMsg{Type: tea.KeyEnter}) + *target = next.(app) + if command == nil { + t.Fatal("agent selection did not schedule project review") + } + next, _ = target.Update(command()) + *target = next.(app) +} + +type failingLaunchReviewProvider struct { + reason string + runs int +} + +func (*failingLaunchReviewProvider) Capabilities() []agentCapability { + return []agentCapability{{Feature: agentFeatureDispatch, Available: true}} +} +func (*failingLaunchReviewProvider) Snapshot(context.Context) core.Result { + return core.Ok(agentSnapshot{}) +} +func (provider *failingLaunchReviewProvider) Review(context.Context, agentReviewRequest) core.Result { + return core.Fail(core.E("test.review", provider.reason, nil)) +} +func (provider *failingLaunchReviewProvider) Run(context.Context, agentRequest) core.Result { + provider.runs++ + return core.Ok(agentActionReceipt{}) +} +func (*failingLaunchReviewProvider) Close() core.Result { return core.Ok(nil) } + +func transcriptTestApp(t *testing.T) app { + t.Helper() + a := newApp("", 0, 64) + m, _ := a.Update(tea.WindowSizeMsg{Width: 72, Height: 18}) + a = m.(app) + a.activePanel = panelChat + a.turns = make([]turn, 0, 24) + for i := 0; i < 12; i++ { + a.turns = append(a.turns, + turn{id: benchmarkTurnID(i * 2), role: "user", text: "Question with enough words to occupy a transcript line"}, + turn{id: benchmarkTurnID(i*2 + 1), role: "assistant", text: "Answer with enough words to occupy another transcript line"}, + ) + } + return a +} + +// errFor builds a plain error for transition tests. +func errFor(text string) error { return &driveErr{text} } + +type driveErr struct{ s string } + +func (e *driveErr) Error() string { return e.s } + +func openAppTestWorkspace(t *testing.T) *workspaceResources { + t.Helper() + result := openWorkspaceWith(testWorkspaceFiles(t), workspaceOpeners{}) + if !result.OK { + t.Fatalf("open app test workspace: %v", result.Value) + } + return result.Value.(*workspaceResources) +} + +func driveAppGeneration(t *testing.T, target *app, sessionID string, command tea.Cmd) { + t.Helper() + for step := 0; step < 1024 && command != nil; step++ { + model, next := target.Update(command()) + *target = model.(app) + command = next + if target.sessionJobs[sessionID] == nil { + return + } + } + t.Fatalf("session %s generation did not complete", sessionID) +} + +type correctiveAgentProvider struct { + mu sync.Mutex + caps []agentCapability + snapshot func(context.Context, int) core.Result + review func(context.Context, agentReviewRequest) core.Result + run func(context.Context, agentRequest) core.Result + snapshots int + reviews int + runs int + closes int +} + +func (provider *correctiveAgentProvider) Capabilities() []agentCapability { + provider.mu.Lock() + defer provider.mu.Unlock() + if provider.caps != nil { + return append([]agentCapability(nil), provider.caps...) + } + capabilities := agentFeatureCatalog("") + for index := range capabilities { + capabilities[index].Available = true + } + return capabilities +} + +func (provider *correctiveAgentProvider) Snapshot(ctx context.Context) core.Result { + provider.mu.Lock() + provider.snapshots++ + call := provider.snapshots + snapshot := provider.snapshot + provider.mu.Unlock() + if snapshot == nil { + return core.Ok(agentSnapshot{}) + } + return snapshot(ctx, call) +} + +func (provider *correctiveAgentProvider) Review(ctx context.Context, request agentReviewRequest) core.Result { + provider.mu.Lock() + provider.reviews++ + review := provider.review + provider.mu.Unlock() + if review == nil { + return core.Ok(agentReview{}) + } + return review(ctx, request) +} + +func (provider *correctiveAgentProvider) Run(ctx context.Context, request agentRequest) core.Result { + provider.mu.Lock() + provider.runs++ + run := provider.run + provider.mu.Unlock() + if run == nil { + return core.Ok(agentActionReceipt{Feature: request.Feature, WorkID: request.WorkID, RunID: request.RunID}) + } + return run(ctx, request) +} + +func (provider *correctiveAgentProvider) Close() core.Result { + provider.mu.Lock() + provider.closes++ + provider.mu.Unlock() + return core.Ok(nil) +} + +func (provider *correctiveAgentProvider) SnapshotCalls() int { + provider.mu.Lock() + defer provider.mu.Unlock() + return provider.snapshots +} + +func (provider *correctiveAgentProvider) CallCounts() (int, int, int, int) { + provider.mu.Lock() + defer provider.mu.Unlock() + return provider.snapshots, provider.reviews, provider.runs, provider.closes +} + +func correctiveChangeReview(validated bool) workspace.ChangeReview { + review := workspace.ChangeReview{ + WorkID: "work-1", RunID: "run-reviewed", SourceBranch: "main", SourceRevision: "source-123", + AgentBase: "source-123", AgentTip: "agent-456", IntegrationBranch: "lem/integration/run-reviewed", + IntegrationPath: "/private/reviews/run-reviewed", ResultRevision: "result-789", + Diff: "diff --git a/main.go b/main.go\n+reviewed change", CommitLog: "agent-456 implement reviewed change", + } + if validated { + review.Validation = []workspace.ValidationResult{{ + Command: workspace.Command{Dir: "/src/project", Executable: "go", Args: []string{"test", "./..."}}, + Output: "ok all packages", Receipt: "validation-receipt", Passed: true, + }} + } + return review +} + +func driveCorrectiveCommand(t *testing.T, target *app, command tea.Cmd) tea.Cmd { + t.Helper() + if command == nil { + t.Fatal("expected lifecycle-owned command") + } + model, next := target.Update(command()) + *target = model.(app) + return next +} + +func countAgentCall(calls []string, want string) int { + count := 0 + for _, call := range calls { + if call == want { + count++ + } + } + return count +} + +func TestSelectPanel_Good(t *testing.T) { + a := newApp("", 0, 64) + if cmd := a.selectPanel(panelWork); cmd != nil { + t.Fatal("selecting a non-Models panel must not schedule a command") + } + if a.activePanel != panelWork { + t.Fatalf("activePanel = %v, want work", a.activePanel) + } +} + +func TestSelectPanel_Ugly(t *testing.T) { + a := newApp("", 0, 64) + cmd := a.selectPanel(panelModels) + if a.activePanel != panelModels { + t.Fatalf("activePanel = %v, want models", a.activePanel) + } + if cmd == nil { + t.Fatal("a first Models visit with an empty picker must schedule discovery") + } +} + +// tabClick builds the left-press message for the centre of one derived tab +// box, offset from strip-local to screen cells by the frame insets. +func tabClick(t *testing.T, a app, panel panelID) tea.MouseMsg { + t.Helper() + metrics := measureFrame(a.width, a.height, a.inspectorOpen) + _, boxes := renderPanelBarBoxes(a.activePanel, metrics.innerWidth, metrics.kind, a.styles) + box, ok := boxes[panelTabBlockID(panel)] + if !ok { + t.Fatalf("box map missing %s", panelTabBlockID(panel)) + } + return tea.MouseMsg{ + X: frameInsetCols + box.Col + box.Width/2, + Y: frameInsetRows + box.Row, + Action: tea.MouseActionPress, + Button: tea.MouseButtonLeft, + } +} + +func TestOnMouse_Good(t *testing.T) { + a := newApp("", 0, 64) + model, _ := a.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + a = model.(app) + model, cmd := a.Update(tabClick(t, a, panelWork)) + a = model.(app) + if a.activePanel != panelWork { + t.Fatalf("activePanel = %v, want work", a.activePanel) + } + if cmd != nil { + t.Fatal("switching to Work must not schedule a command") + } + model, cmd = a.Update(tabClick(t, a, panelModels)) + a = model.(app) + if a.activePanel != panelModels { + t.Fatalf("activePanel = %v, want models", a.activePanel) + } + if cmd == nil { + t.Fatal("a first Models visit through a click must schedule discovery") + } +} + +func TestOnMouse_Bad(t *testing.T) { + a := newApp("", 0, 64) + model, _ := a.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + a = model.(app) + press := func(x, y int, button tea.MouseButton) { + t.Helper() + model, _ = a.Update(tea.MouseMsg{X: x, Y: y, Action: tea.MouseActionPress, Button: button}) + a = model.(app) + if a.activePanel != panelChat { + t.Fatalf("press at (%d, %d) switched panel to %v", x, y, a.activePanel) + } + } + press(frameInsetCols+6, 0, tea.MouseButtonLeft) // the border row above the strip + press(frameInsetCols, frameInsetRows, tea.MouseButtonLeft) // the brand cell resolves to the strip, not a tab + press(frameInsetCols+8, frameInsetRows, tea.MouseButtonWheelUp) // wheel messages keep their transcript route +} + +func TestOnMouse_Ugly(t *testing.T) { + // Before any WindowSizeMsg the frame has no geometry: no panic, no switch. + a := newApp("", 0, 64) + model, _ := a.Update(tea.MouseMsg{X: 8, Y: 1, Action: tea.MouseActionPress, Button: tea.MouseButtonLeft}) + a = model.(app) + if a.activePanel != panelChat { + t.Fatalf("sizeless click switched panel to %v", a.activePanel) + } + + // Under an overlay the bar takes no clicks — the keyboard gate mirrored. + b := newApp("", 0, 64) + model, _ = b.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + b = model.(app) + click := tabClick(t, b, panelWork) + b.activeOverlay = overlayHelp + model, _ = b.Update(click) + b = model.(app) + if b.activePanel != panelChat { + t.Fatalf("overlay click switched panel to %v", b.activePanel) + } +} diff --git a/cli/tui/bootstrap.go b/cli/tui/bootstrap.go new file mode 100644 index 000000000..a22ca89de --- /dev/null +++ b/cli/tui/bootstrap.go @@ -0,0 +1,866 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package tui + +import ( + "context" + "reflect" + "sync" + "time" + + core "dappco.re/go" + "dappco.re/go/inference/agent/gitserver" + "dappco.re/go/inference/agent/orchestrator" + "dappco.re/go/inference/agent/provider" + "dappco.re/go/inference/agent/queue" + "dappco.re/go/inference/agent/work" + "dappco.re/go/inference/agent/workspace" + coreio "dappco.re/go/io" + coreprocess "dappco.re/go/process" +) + +const ( + agentHardenedRuntimeContract = "go-inference/native-runtime/v1;softserve-fail-closed;bounded-cleanup;attempt-timeout;reviewed-retry-resume" + defaultAgentCleanupTimeout = 5 * time.Second +) + +type workspaceResources struct { + Paths appPaths + Files coreio.Medium + Repository workspaceRepository + State reactiveState + Preferences preferenceStore + Agent agentProvider + // DatasetStore backs the Data panel — datasets.duckdb, a separate file + // from Database (lem.duckdb) per the dataset loop design's + // bulk/lifecycle/blast-radius decision (docs/superpowers/specs/ + // 2026-07-19-lem-dataset-loop-design.md, "Storage"). Opened + // best-effort in openWorkspaceWithContext: a damaged or missing + // dataset file degrades to a nil DatasetStore + a warning, exactly + // like State/Preferences already do, never a fatal workspace-open + // failure — "a bloated or damaged dataset file must never take the + // agent/TUI state down with it" is the design's own words. + DatasetStore DatasetStore + Warnings []string +} + +type workspaceOpeners struct { + Preflight func(context.Context) core.Result + Repository func(path string) core.Result + State func(paths appPaths) core.Result + Preferences func(files coreio.Medium, path string) core.Result + Agent func(context.Context, appFiles, workspaceRepository) core.Result + Now func() time.Time +} + +type agentBootstrapInput struct { + Files appFiles + Repository workspaceRepository +} + +type agentBootstrapResult struct { + Provider agentProvider + Warnings []string +} + +type nativeProviderDetection struct { + Available int + Warnings []string +} + +type agentAvailability struct { + mu sync.RWMutex + unavailable map[agentFeature]string +} + +type observedGitService struct { + gitserver.Service + availability *agentAvailability +} + +type agentBootstrapFactories struct { + RuntimeContract func(context.Context) core.Result + LoadPolicy func(coreio.Medium, string) core.Result + OpenWorkspaceMedium func(string) core.Result + NewStore func(workspaceRepository) core.Result + GitAvailable func() core.Result + GitOptions func(string) core.Result + NewGitServer func(gitserver.Options) core.Result + NewWorkspaces func(workspace.ManagerOptions) core.Result + NewProviders func(provider.Finder, map[string]provider.Config) core.Result + DetectProviders func(context.Context, *provider.Registry) core.Result + NewQueue func(queue.Policy, work.QueueState, []work.ProviderState) core.Result + NewLauncher func() core.Result + NewOrchestrator func(orchestrator.Options) core.Result + Now func() time.Time + IDs orchestrator.Identifier +} + +func openWorkspace(root string, openers workspaceOpeners) core.Result { + return openWorkspaceContext(context.Background(), root, openers) +} + +func openWorkspaceContext(ctx context.Context, root string, openers workspaceOpeners) core.Result { + if ctx == nil { + return core.Fail(core.E("tui.openWorkspace", "workspace context is required", nil)) + } + if openers.Preflight != nil { + if preflight := openers.Preflight(ctx); !preflight.OK { + return core.Fail(core.E("tui.openWorkspace", "native workspace preflight failed", preflight.Err())) + } + openers.Preflight = nil + } + opened := openAppFilesAt(root) + if !opened.OK { + return core.Fail(core.E("tui.openWorkspace", "open application files", resultError(opened))) + } + files, ok := opened.Value.(appFiles) + if !ok { + return core.Fail(core.E("tui.openWorkspace", "invalid application files result", nil)) + } + return openWorkspaceWithContext(ctx, files, openers) +} + +func openWorkspaceWith(files appFiles, openers workspaceOpeners) core.Result { + return openWorkspaceWithContext(context.Background(), files, openers) +} + +func openWorkspaceWithContext(ctx context.Context, files appFiles, openers workspaceOpeners) core.Result { + if ctx == nil { + return core.Fail(core.E("tui.openWorkspaceWith", "workspace context is required", nil)) + } + if files.Medium == nil { + return core.Fail(core.E("tui.openWorkspaceWith", "application file medium is required", nil)) + } + if openers.Preflight != nil { + if preflight := openers.Preflight(ctx); !preflight.OK { + return core.Fail(core.E("tui.openWorkspaceWith", "native workspace preflight failed", preflight.Err())) + } + openers.Preflight = nil + } + if result := ensureAppFiles(files.Medium, files.Paths); !result.OK { + return core.Fail(core.E("tui.openWorkspaceWith", "ensure application files", resultError(result))) + } + openers = openers.withDefaults() + warnings := make([]string, 0) + + repositoryResult := openers.Repository(files.Paths.Database) + if !repositoryResult.OK { + return core.Fail(core.E( + "tui.openWorkspaceWith", + core.Concat("open repository: ", files.Paths.Database), + workspaceOpenError(repositoryResult, "open repository"), + )) + } + repository, ok := repositoryResult.Value.(workspaceRepository) + if !ok { + return core.Fail(core.E( + "tui.openWorkspaceWith", + core.Concat("open repository: ", files.Paths.Database), + core.E("tui.workspace.repository", "invalid repository result", nil), + )) + } + if result := repository.InterruptActiveJobs(openers.Now()); !result.OK { + if closeResult := repository.Close(); !closeResult.OK { + core.Warn("tui.workspace.repository_close_after_recovery_failure", "error", closeResult.Value) + } + return core.Fail(core.E( + "tui.openWorkspaceWith", + core.Concat("recover active jobs: ", files.Paths.Database), + workspaceOpenError(result, "recover active jobs"), + )) + } + + stateResult := openers.State(files.Paths) + state, stateOK := stateResult.Value.(reactiveState) + if !stateResult.OK || !stateOK { + reason := workspaceOpenError(stateResult, "open reactive state") + if stateResult.OK && !stateOK { + reason = core.E("tui.workspace.state", "invalid reactive state result", nil) + } + warnings = append(warnings, core.Concat("reactive state: ", reason.Error())) + state = newDisabledReactiveState(reason) + } + + preferencesResult := openers.Preferences(files.Medium, files.Paths.Config) + preferences, preferencesOK := preferencesResult.Value.(preferenceStore) + if !preferencesResult.OK || !preferencesOK { + reason := workspaceOpenError(preferencesResult, "open preferences") + if preferencesResult.OK && !preferencesOK { + reason = core.E("tui.workspace.preferences", "invalid preference result", nil) + } + warnings = append(warnings, core.Concat("preferences: ", reason.Error())) + fallback := openDegradedPreferences(files.Medium, files.Paths.Config, reason) + if !fallback.OK { + _ = state.Close() + _ = repository.Close() + return core.Fail(core.E("tui.openWorkspaceWith", "create preference fallback", resultError(fallback))) + } + preferences = fallback.Value.(preferenceStore) + } else if warning := preferences.Warning(); warning != nil { + warnings = append(warnings, core.Concat("preferences: ", warning.Error())) + } + + agentResult := openers.Agent(ctx, files, repository) + agent := agentProvider(nil) + if !agentResult.OK { + reason := workspaceOpenError(agentResult, defaultAgentUnavailableReason) + warnings = append(warnings, core.Concat("agent: ", reason.Error())) + agent = newUnavailableAgentProvider(reason.Error()) + } else { + switch value := agentResult.Value.(type) { + case agentBootstrapResult: + agent = value.Provider + warnings = append(warnings, value.Warnings...) + case agentProvider: + agent = value + default: + reason := core.E("tui.workspace.agent", "invalid agent provider result", nil) + warnings = append(warnings, core.Concat("agent: ", reason.Error())) + agent = newUnavailableAgentProvider(reason.Error()) + } + } + if agent == nil { + agent = newUnavailableAgentProvider(defaultAgentUnavailableReason) + } + + datasetResult := newDuckDatasetStore(files.Paths.Datasets) + var datasets DatasetStore + if !datasetResult.OK { + reason := workspaceOpenError(datasetResult, "open dataset store") + warnings = append(warnings, core.Concat("dataset store: ", reason.Error())) + } else if store, ok := datasetResult.Value.(*duckDatasetStore); ok { + datasets = store + } else { + warnings = append(warnings, "dataset store: invalid dataset store result") + } + + return core.Ok(&workspaceResources{ + Paths: files.Paths, + Files: files.Medium, + Repository: repository, + State: state, + Preferences: preferences, + Agent: agent, + DatasetStore: datasets, + Warnings: warnings, + }) +} + +func (resources *workspaceResources) Close() core.Result { + if resources == nil { + return core.Ok(nil) + } + if closeResult := resources.closeAgent(); !closeResult.OK { + return core.Fail(core.E("tui.workspaceResources.Close", closeResult.Error(), closeResult.Err())) + } + failures := make([]string, 0, 3) + if resources.State != nil { + if closeResult := resources.State.Close(); !closeResult.OK { + failures = append(failures, closeResult.Error()) + } + resources.State = nil + } + if resources.Repository != nil { + if closeResult := resources.Repository.Close(); !closeResult.OK { + failures = append(failures, closeResult.Error()) + } + resources.Repository = nil + } + if resources.DatasetStore != nil { + if closeResult := resources.DatasetStore.Close(); !closeResult.OK { + failures = append(failures, closeResult.Error()) + } + resources.DatasetStore = nil + } + if len(failures) > 0 { + return core.Fail(core.E("tui.workspaceResources.Close", core.Join("; ", failures...), nil)) + } + return core.Ok(nil) +} + +func (resources *workspaceResources) closeAgent() core.Result { + if resources == nil || resources.Agent == nil { + return core.Ok(nil) + } + agent := resources.Agent + closed := agent.Close() + if closed.OK { + resources.Agent = nil + } + return closed +} + +func (openers workspaceOpeners) withDefaults() workspaceOpeners { + if openers.Repository == nil { + openers.Repository = openDuckRepository + } + if openers.State == nil { + openers.State = openReactiveState + } + if openers.Preferences == nil { + openers.Preferences = openPreferences + } + if openers.Agent == nil { + openers.Agent = openReadOnlyWorkspaceAgent + } + if openers.Now == nil { + openers.Now = time.Now + } + return openers +} + +func openReadOnlyWorkspaceAgent(context.Context, appFiles, workspaceRepository) core.Result { + return core.Ok(newUnavailableAgentProvider("native agent execution is disabled for this read-only workspace")) +} + +func openNativeWorkspaceAgent(ctx context.Context, files appFiles, repository workspaceRepository) core.Result { + return composeNativeAgent(ctx, agentBootstrapInput{Files: files, Repository: repository}, agentBootstrapFactories{}) +} + +func composeNativeAgent(ctx context.Context, input agentBootstrapInput, factories agentBootstrapFactories) core.Result { + if ctx == nil { + return core.Fail(core.E("tui.composeNativeAgent", "agent bootstrap context is required", nil)) + } + if err := ctx.Err(); err != nil { + return core.Fail(core.E("tui.composeNativeAgent", "agent bootstrap context is done", err)) + } + if input.Files.Medium == nil || input.Repository == nil { + return core.Fail(core.E("tui.composeNativeAgent", "agent bootstrap files and repository are required", nil)) + } + factories = factories.withDefaults() + contractResult := factories.RuntimeContract(ctx) + if !contractResult.OK { + if err := ctx.Err(); err != nil { + return core.Fail(core.E("tui.composeNativeAgent", "agent bootstrap context is done", err)) + } + return core.Fail(core.E("tui.composeNativeAgent", "native execution requires a hardened dappco.re/go/inference release/update before composition", contractResult.Err())) + } + + policyResult := factories.LoadPolicy(input.Files.Medium, input.Files.Paths.Agents) + if !policyResult.OK { + return core.Fail(core.E("tui.composeNativeAgent", core.Concat("agent policy is invalid; queue is frozen: ", policyResult.Error()), resultError(policyResult))) + } + policy, ok := policyResult.Value.(queue.Policy) + if !ok { + return core.Fail(core.E("tui.composeNativeAgent", "agent policy loader returned an invalid policy; queue is frozen", nil)) + } + maxAttemptMinutes := int64(time.Duration(1<<63-1) / time.Minute) + if int64(policy.Dispatch.TimeoutMinutes) > maxAttemptMinutes { + return core.Fail(core.E("tui.composeNativeAgent", "agent dispatch timeout exceeds the supported duration; queue is frozen", nil)) + } + attemptTimeout := time.Duration(policy.Dispatch.TimeoutMinutes) * time.Minute + engineOptions := orchestrator.Options{} + if !setAgentOrchestratorDurationOption(&engineOptions, "AttemptTimeout", attemptTimeout) || + !setAgentOrchestratorDurationOption(&engineOptions, "CleanupTimeout", defaultAgentCleanupTimeout) { + return core.Fail(core.E("tui.composeNativeAgent", "native execution requires a hardened dappco.re/go/inference release/update with attempt and cleanup timeout options", nil)) + } + managerOptions := workspace.ManagerOptions{} + if !setAgentWorkspaceDurationOption(&managerOptions, "CleanupTimeout", defaultAgentCleanupTimeout) { + return core.Fail(core.E("tui.composeNativeAgent", "native execution requires a hardened dappco.re/go/inference release/update with bounded workspace cleanup", nil)) + } + + workspaceMediumResult := factories.OpenWorkspaceMedium(input.Files.Paths.Workspaces) + if !workspaceMediumResult.OK { + return agentCompositionFailure("open workspace medium", workspaceMediumResult, nil, nil) + } + workspaceMedium, ok := workspaceMediumResult.Value.(coreio.Medium) + if !ok || workspaceMedium == nil { + return core.Fail(core.E("tui.composeNativeAgent", "workspace medium constructor returned an invalid medium", nil)) + } + + storeResult := factories.NewStore(input.Repository) + if !storeResult.OK { + return agentCompositionFailure("open durable agent store", storeResult, nil, nil) + } + store, ok := storeResult.Value.(orchestrator.Store) + if !ok || store == nil { + return core.Fail(core.E("tui.composeNativeAgent", "agent store constructor returned an invalid store", nil)) + } + snapshotResult := store.Snapshot("") + if !snapshotResult.OK { + return agentCompositionFailure("load durable queue state", snapshotResult, nil, nil) + } + durable, ok := snapshotResult.Value.(work.Snapshot) + if !ok { + return agentCompositionFailure( + "load durable queue state", + core.Fail(core.E("tui.composeNativeAgent", "invalid durable snapshot", nil)), + nil, + nil, + ) + } + + if gitResult := factories.GitAvailable(); !gitResult.OK { + return agentCompositionFailure("detect Git", gitResult, nil, nil) + } + optionsResult := factories.GitOptions(input.Files.Paths.SoftServe) + if !optionsResult.OK { + return agentCompositionFailure("configure private Git", optionsResult, nil, nil) + } + gitOptions, ok := optionsResult.Value.(gitserver.Options) + if !ok { + return core.Fail(core.E("tui.composeNativeAgent", "private Git options have an invalid type", nil)) + } + serverResult := factories.NewGitServer(gitOptions) + if !serverResult.OK { + return agentCompositionFailure("construct lazy private Git", serverResult, nil, nil) + } + server, ok := serverResult.Value.(gitserver.Service) + if !ok || server == nil { + return core.Fail(core.E("tui.composeNativeAgent", "private Git constructor returned an invalid service", nil)) + } + availability := &agentAvailability{unavailable: make(map[agentFeature]string)} + server = &observedGitService{Service: server, availability: availability} + + managerOptions.Root = input.Files.Paths.Workspaces + managerOptions.Files = workspaceMedium + managerOptions.Git = workspace.ProcessRunner{} + managerOptions.Server = server + managerOptions.IDs = factories.IDs.New + managerOptions.Now = factories.Now + workspacesResult := factories.NewWorkspaces(managerOptions) + if !workspacesResult.OK { + return agentCompositionFailure("construct workspace manager", workspacesResult, server, nil) + } + workspaces, ok := workspacesResult.Value.(*workspace.Manager) + if !ok || workspaces == nil { + return agentCompositionFailure("construct workspace manager", core.Fail(core.E("tui.composeNativeAgent", "workspace constructor returned an invalid manager", nil)), server, nil) + } + + providerConfigs := make(map[string]provider.Config, len(policy.Providers)) + for name, configured := range policy.Providers { + providerConfigs[name] = provider.Config{ + Executable: configured.Executable, DefaultModel: configured.DefaultModel, + CredentialEnv: append([]string(nil), configured.CredentialEnv...), + Flags: append([]string(nil), configured.Flags...), + } + } + providersResult := factories.NewProviders(nil, providerConfigs) + if !providersResult.OK { + return agentCompositionFailure("construct provider registry", providersResult, server, nil) + } + providers, ok := providersResult.Value.(*provider.Registry) + if !ok || providers == nil { + return agentCompositionFailure("construct provider registry", core.Fail(core.E("tui.composeNativeAgent", "provider constructor returned an invalid registry", nil)), server, nil) + } + detectionResult := factories.DetectProviders(ctx, providers) + if !detectionResult.OK { + return agentCompositionFailure("detect native providers", detectionResult, server, nil) + } + detection, ok := detectionResult.Value.(nativeProviderDetection) + if !ok { + return agentCompositionFailure("detect native providers", core.Fail(core.E("tui.composeNativeAgent", "provider detection returned an invalid result", nil)), server, nil) + } + + queueResult := factories.NewQueue(policy, durable.Queue, durable.Providers) + if !queueResult.OK { + return agentCompositionFailure("construct queue controller", queueResult, server, nil) + } + controller, ok := queueResult.Value.(*queue.Controller) + if !ok || controller == nil { + return agentCompositionFailure("construct queue controller", core.Fail(core.E("tui.composeNativeAgent", "queue constructor returned an invalid controller", nil)), server, nil) + } + + launcherResult := factories.NewLauncher() + if !launcherResult.OK { + return agentCompositionFailure("construct native launcher", launcherResult, server, nil) + } + launcher, ok := launcherResult.Value.(orchestrator.Launcher) + if !ok || launcher == nil { + return agentCompositionFailure("construct native launcher", core.Fail(core.E("tui.composeNativeAgent", "launcher constructor returned an invalid launcher", nil)), server, nil) + } + + engineOptions.Store = store + engineOptions.GitServer = server + engineOptions.Workspaces = workspaces + engineOptions.Providers = providers + engineOptions.Queue = controller + engineOptions.Launcher = launcher + engineOptions.Clock = agentClock{now: factories.Now} + engineOptions.IDs = factories.IDs + engineResult := factories.NewOrchestrator(engineOptions) + if !engineResult.OK { + return agentCompositionFailure("construct native orchestrator", engineResult, server, launcher) + } + engine, ok := engineResult.Value.(nativeAgentEngine) + if !ok || engine == nil { + return agentCompositionFailure("construct native orchestrator", core.Fail(core.E("tui.composeNativeAgent", "orchestrator constructor returned an invalid engine", nil)), server, launcher) + } + adapterResult := newAgentAdapterWithAvailability(engine, availability) + if !adapterResult.OK { + if closed := engine.Close(); !closed.OK { + return core.Fail(core.E("tui.composeNativeAgent", adapterResult.Error(), resultError(closed))) + } + return adapterResult + } + return core.Ok(agentBootstrapResult{Provider: adapterResult.Value.(agentProvider), Warnings: detection.Warnings}) +} + +func nativeWorkspacePreflight(ctx context.Context) core.Result { + if ctx == nil { + return core.Fail(core.NewError("native workspace preflight context is required")) + } + if err := ctx.Err(); err != nil { + return core.Fail(core.E("tui.nativeWorkspacePreflight", "preflight context is done", err)) + } + result := linkedAgentRuntimeContract(ctx) + if !result.OK { + if err := ctx.Err(); err != nil { + return core.Fail(core.E("tui.nativeWorkspacePreflight", "preflight context is done", err)) + } + return core.Fail(core.E("tui.nativeWorkspacePreflight", "native execution requires a hardened dappco.re/go/inference release/update", result.Err())) + } + return result +} + +var agentDurationType = reflect.TypeOf(time.Duration(0)) + +func setAgentDurationOption(options any, name string, duration time.Duration) bool { + container := reflect.ValueOf(options) + if !container.IsValid() || container.Kind() != reflect.Pointer || container.IsNil() { + return false + } + container = container.Elem() + if container.Kind() != reflect.Struct { + return false + } + value := container.FieldByName(name) + if !value.IsValid() || !value.CanSet() || value.Type() != agentDurationType { + return false + } + value.SetInt(int64(duration)) + return true +} + +func agentDurationOption(options any, name string) (time.Duration, bool) { + container := reflect.ValueOf(options) + if !container.IsValid() || container.Kind() != reflect.Struct { + return 0, false + } + value := container.FieldByName(name) + if !value.IsValid() || value.Type() != agentDurationType { + return 0, false + } + return time.Duration(value.Int()), true +} + +func setAgentOrchestratorDurationOption(options *orchestrator.Options, name string, duration time.Duration) bool { + return setAgentDurationOption(options, name, duration) +} + +func agentOrchestratorDurationOption(options orchestrator.Options, name string) (time.Duration, bool) { + return agentDurationOption(options, name) +} + +func setAgentWorkspaceDurationOption(options *workspace.ManagerOptions, name string, duration time.Duration) bool { + return setAgentDurationOption(options, name, duration) +} + +func agentWorkspaceDurationOption(options workspace.ManagerOptions, name string) (time.Duration, bool) { + return agentDurationOption(options, name) +} + +type agentHardenedRuntime interface { + HardenedRuntimeContract(context.Context) core.Result +} + +func linkedAgentRuntimeContract(ctx context.Context) core.Result { + options := orchestrator.Options{} + contract, ok := any(options).(agentHardenedRuntime) + if !ok { + return core.Fail(core.NewError("linked inference module does not expose the hardened native runtime contract")) + } + result := contract.HardenedRuntimeContract(ctx) + if !result.OK { + return result + } + receipt, ok := result.Value.(string) + if !ok || receipt != agentHardenedRuntimeContract { + return core.Fail(core.NewError("linked inference module returned an unsupported hardened native runtime contract")) + } + for _, name := range []string{"AttemptTimeout", "CleanupTimeout"} { + if _, available := agentOrchestratorDurationOption(options, name); !available { + return core.Fail(core.Errorf("linked inference module hardened runtime is missing %s", name)) + } + } + if _, available := agentWorkspaceDurationOption(workspace.ManagerOptions{}, "CleanupTimeout"); !available { + return core.Fail(core.NewError("linked inference module hardened runtime is missing workspace CleanupTimeout")) + } + return core.Ok(receipt) +} + +func (availability *agentAvailability) reason(feature agentFeature) string { + if availability == nil { + return "" + } + availability.mu.RLock() + defer availability.mu.RUnlock() + return availability.unavailable[feature] +} + +func (availability *agentAvailability) recordGit(result core.Result) { + if availability == nil { + return + } + reason := "" + if !result.OK { + reason = result.Error() + } + availability.mu.Lock() + defer availability.mu.Unlock() + for _, feature := range []agentFeature{ + agentFeatureDispatch, agentFeatureRetry, agentFeatureResume, + agentFeatureChangesReview, agentFeatureAccept, agentFeatureReject, + } { + if reason == "" { + delete(availability.unavailable, feature) + continue + } + availability.unavailable[feature] = reason + } +} + +func (service *observedGitService) Start(ctx context.Context) core.Result { + result := service.Service.Start(ctx) + service.availability.recordGit(result) + return result +} + +func (service *observedGitService) EnsureRepository(ctx context.Context, name string) core.Result { + result := service.Service.EnsureRepository(ctx, name) + service.availability.recordGit(result) + return result +} + +func (factories agentBootstrapFactories) withDefaults() agentBootstrapFactories { + if factories.RuntimeContract == nil { + factories.RuntimeContract = linkedAgentRuntimeContract + } + if factories.LoadPolicy == nil { + factories.LoadPolicy = queue.LoadPolicy + } + if factories.OpenWorkspaceMedium == nil { + factories.OpenWorkspaceMedium = func(root string) core.Result { + medium, err := coreio.NewSandboxed(root) + if err != nil { + return core.Fail(core.E("tui.agentWorkspaceMedium", core.Concat("open ", root), err)) + } + return core.Ok(coreio.Medium(medium)) + } + } + if factories.NewStore == nil { + factories.NewStore = newDuckAgentStore + } + if factories.GitAvailable == nil { + factories.GitAvailable = func() core.Result { + program := &coreprocess.Program{Name: "git"} + if found := program.Find(); !found.OK { + return core.Fail(core.E("tui.agentGit", "Git executable is unavailable", resultError(found))) + } + return core.Ok(program.Path) + } + } + if factories.GitOptions == nil { + factories.GitOptions = gitserver.DefaultOptions + } + if factories.NewGitServer == nil { + factories.NewGitServer = func(options gitserver.Options) core.Result { return gitserver.NewSoftServe(options) } + } + if factories.NewWorkspaces == nil { + factories.NewWorkspaces = workspace.NewManager + } + if factories.NewProviders == nil { + factories.NewProviders = provider.DefaultRegistry + } + if factories.DetectProviders == nil { + factories.DetectProviders = detectNativeProviders + } + if factories.NewQueue == nil { + factories.NewQueue = queue.NewController + } + if factories.NewLauncher == nil { + factories.NewLauncher = newOwnedNativeLauncher + } + if factories.NewOrchestrator == nil { + factories.NewOrchestrator = func(options orchestrator.Options) core.Result { return orchestrator.New(options) } + } + if factories.Now == nil { + factories.Now = time.Now + } + if factories.IDs == nil { + factories.IDs = &agentIdentifiers{} + } + return factories +} + +func detectNativeProviders(ctx context.Context, registry *provider.Registry) core.Result { + if registry == nil { + return core.Fail(core.E("tui.detectNativeProviders", "provider registry is required", nil)) + } + detection := nativeProviderDetection{Warnings: make([]string, 0)} + for _, name := range registry.Names() { + adapterResult := registry.Adapter(name) + if !adapterResult.OK { + detection.Warnings = append(detection.Warnings, core.Concat("agent provider ", name, ": ", adapterResult.Error())) + continue + } + adapter, ok := adapterResult.Value.(provider.Adapter) + if !ok { + detection.Warnings = append(detection.Warnings, core.Concat("agent provider ", name, ": invalid adapter")) + continue + } + result := adapter.Detect(ctx) + if !result.OK { + detection.Warnings = append(detection.Warnings, core.Concat("agent provider ", name, ": ", result.Error())) + continue + } + value, ok := result.Value.(provider.Detection) + if !ok { + detection.Warnings = append(detection.Warnings, core.Concat("agent provider ", name, ": invalid detection result")) + continue + } + if value.Available { + detection.Available++ + continue + } + reason := core.Trim(value.Reason) + if reason == "" { + reason = core.Concat(name, " executable is unavailable") + } + detection.Warnings = append(detection.Warnings, core.Concat("agent provider ", name, ": ", reason)) + } + return core.Ok(detection) +} + +func agentCompositionFailure(stage string, failure core.Result, server gitserver.Service, launcher orchestrator.Launcher) core.Result { + failures := []string{core.Concat(stage, ": ", failure.Error())} + if launcher != nil { + if result := launcher.Close(); !result.OK { + failures = append(failures, core.Concat("launcher cleanup: ", result.Error())) + } + } + if server != nil { + if result := server.Close(); !result.OK { + failures = append(failures, core.Concat("private Git cleanup: ", result.Error())) + } + } + return core.Fail(core.E("tui.composeNativeAgent", core.Join("; ", failures...), resultError(failure))) +} + +type agentClock struct{ now func() time.Time } + +func (clock agentClock) Now() time.Time { + if clock.now == nil { + return time.Time{} + } + return clock.now() +} + +type agentIdentifiers struct{} + +func (*agentIdentifiers) New() string { return newRecordID() } + +type ownedNativeLauncher struct { + launcher orchestrator.Launcher + service *coreprocess.Service + closeMu sync.Mutex + closeComplete bool + result core.Result +} + +func newOwnedNativeLauncher() core.Result { + app := core.New() + serviceResult := coreprocess.NewService(coreprocess.Options{})(app) + if !serviceResult.OK { + return core.Fail(core.E("tui.newOwnedNativeLauncher", "construct process service", resultError(serviceResult))) + } + service, ok := serviceResult.Value.(*coreprocess.Service) + if !ok || service == nil { + return core.Fail(core.E("tui.newOwnedNativeLauncher", "process service constructor returned an invalid service", nil)) + } + if started := service.OnStartup(context.Background()); !started.OK { + return core.Fail(core.E("tui.newOwnedNativeLauncher", "start process service", resultError(started))) + } + launcherResult := orchestrator.NewNativeLauncher(service, nativeAgentEssentialEnvironment()) + if !launcherResult.OK { + shutdown := service.OnShutdown(context.Background()) + if !shutdown.OK { + return core.Fail(core.E("tui.newOwnedNativeLauncher", launcherResult.Error(), resultError(shutdown))) + } + return launcherResult + } + launcher, ok := launcherResult.Value.(orchestrator.Launcher) + if !ok { + if shutdown := service.OnShutdown(context.Background()); !shutdown.OK { + return core.Fail(core.E("tui.newOwnedNativeLauncher", "native launcher constructor returned an invalid launcher", resultError(shutdown))) + } + return core.Fail(core.E("tui.newOwnedNativeLauncher", "native launcher constructor returned an invalid launcher", nil)) + } + return core.Ok(orchestrator.Launcher(&ownedNativeLauncher{launcher: launcher, service: service, result: core.Ok(nil)})) +} + +func nativeAgentEssentialEnvironment() []string { + return []string{"PATH", "HOME", "USER", "TMPDIR", "TMP", "TEMP", "LANG", "LC_ALL", "SHELL"} +} + +func (launcher *ownedNativeLauncher) DetectEnvironment(keys []string) core.Result { + return launcher.launcher.DetectEnvironment(keys) +} + +func (launcher *ownedNativeLauncher) Start(ctx context.Context, command provider.Command, output func(string, string)) core.Result { + return launcher.launcher.Start(ctx, command, output) +} + +func (launcher *ownedNativeLauncher) Close() core.Result { + if launcher == nil { + return core.Ok(nil) + } + launcher.closeMu.Lock() + defer launcher.closeMu.Unlock() + if launcher.closeComplete { + return launcher.result + } + if launcher.launcher != nil { + if result := launcher.launcher.Close(); !result.OK { + launcher.result = core.Fail(core.E("tui.ownedNativeLauncher.Close", result.Error(), result.Err())) + return launcher.result + } + launcher.launcher = nil + } + if launcher.service != nil { + if result := launcher.service.OnShutdown(context.Background()); !result.OK { + launcher.result = core.Fail(core.E("tui.ownedNativeLauncher.Close", result.Error(), result.Err())) + return launcher.result + } + launcher.service = nil + } + launcher.result = core.Ok(nil) + launcher.closeComplete = true + return launcher.result +} + +func openDegradedPreferences(medium coreio.Medium, path string, warning error) core.Result { + fallback := openPreferences(coreio.NewMemoryMedium(), path) + if !fallback.OK { + return fallback + } + preferences, ok := fallback.Value.(*configPreferenceStore) + if !ok { + return core.Fail(core.E("tui.openDegradedPreferences", "invalid preference fallback", nil)) + } + preferences.mu.Lock() + preferences.medium = medium + preferences.path = path + preferences.warning = warning + preferences.commitDisabled = true + preferences.mu.Unlock() + return core.Ok(preferenceStore(preferences)) +} + +func workspaceOpenError(result core.Result, fallback string) error { + if err := resultError(result); err != nil { + return err + } + return core.E("tui.workspace", fallback, nil) +} diff --git a/cli/tui/bootstrap_test.go b/cli/tui/bootstrap_test.go new file mode 100644 index 000000000..d51920b48 --- /dev/null +++ b/cli/tui/bootstrap_test.go @@ -0,0 +1,961 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package tui + +import ( + "context" + "os" + "strings" + "testing" + "time" + + core "dappco.re/go" + "dappco.re/go/inference/agent/gitserver" + "dappco.re/go/inference/agent/orchestrator" + "dappco.re/go/inference/agent/provider" + "dappco.re/go/inference/agent/queue" + "dappco.re/go/inference/agent/work" + "dappco.re/go/inference/agent/workspace" + "dappco.re/go/inference/dataset" + coreio "dappco.re/go/io" +) + +func TestOpenWorkspace_Good(t *testing.T) { + files := testWorkspaceFiles(t) + closeOrder := make([]string, 0, 2) + openers := workspaceOpeners{ + Repository: func(path string) core.Result { + result := openDuckRepository(path) + if !result.OK { + return result + } + repository, ok := result.Value.(workspaceRepository) + if !ok { + return core.Fail(core.E("test.repository", "unexpected repository", nil)) + } + return core.Ok(&trackingWorkspaceRepository{workspaceRepository: repository, closeOrder: &closeOrder}) + }, + State: func(paths appPaths) core.Result { + result := openReactiveState(paths) + if !result.OK { + return result + } + state, ok := result.Value.(reactiveState) + if !ok { + return core.Fail(core.E("test.state", "unexpected state", nil)) + } + return core.Ok(&trackingReactiveState{reactiveState: state, closeOrder: &closeOrder}) + }, + Now: func() time.Time { + return time.Date(2026, time.July, 17, 14, 0, 0, 0, time.UTC) + }, + } + + opened := openWorkspaceWith(files, openers) + if !opened.OK { + t.Fatalf("openWorkspaceWith failed: %v", opened.Value) + } + resources, ok := opened.Value.(*workspaceResources) + if !ok { + t.Fatalf("openWorkspaceWith value = %T, want *workspaceResources", opened.Value) + } + for _, directory := range []string{appWorkspacesPath, files.Paths.Packs, files.Paths.Exports} { + if !files.Medium.IsDir(directory) { + t.Errorf("workspace medium directory %q was not created", directory) + } + } + if _, err := os.Stat(files.Paths.Database); err != nil { + t.Errorf("migrated DuckDB %q: %v", files.Paths.Database, err) + } + if len(resources.Warnings) != 0 { + t.Errorf("healthy workspace warnings = %#v, want none", resources.Warnings) + } + + createdAt := time.Date(2026, time.July, 17, 14, 1, 0, 0, time.UTC) + session := testSessionRecord("bootstrap-session", "Bootstrapped", createdAt) + if result := resources.Repository.SaveSession(session); !result.OK { + t.Fatalf("save through bootstrapped repository: %v", result.Value) + } + if result := resources.Repository.Session(session.ID); !result.OK { + t.Fatalf("read through bootstrapped repository: %v", result.Value) + } + if result := resources.State.Set(reactiveGroupWorkspace, "active_panel", "chat"); !result.OK { + t.Fatalf("write through bootstrapped state: %v", result.Value) + } + if value, result := resources.State.Get(reactiveGroupWorkspace, "active_panel"); !result.OK || value != "chat" { + t.Fatalf("read through bootstrapped state = %q, %#v", value, result.Value) + } + if values := resources.Preferences.Values(); values.Theme != "midnight" || values.MaxTokens != 4096 { + t.Fatalf("bootstrapped preference defaults = %#v", values) + } + + if result := resources.Close(); !result.OK { + t.Fatalf("close workspace resources: %v", result.Value) + } + if len(closeOrder) != 2 || closeOrder[0] != "state" || closeOrder[1] != "repository" { + t.Fatalf("close order = %#v, want [state repository]", closeOrder) + } +} + +// TestOpenWorkspace_DatasetStoreOpensAndCloses proves the Data panel's +// store attaches through openWorkspaceWithContext exactly like Repository/ +// State already do (Task 8) — its own file, datasets.duckdb, separate +// from lem.duckdb per the design's bulk/lifecycle/blast-radius decision. +func TestOpenWorkspace_DatasetStoreOpensAndCloses(t *testing.T) { + files := testWorkspaceFiles(t) + opened := openWorkspaceWith(files, workspaceOpeners{}) + if !opened.OK { + t.Fatalf("openWorkspaceWith: %v", opened.Value) + } + resources := opened.Value.(*workspaceResources) + if resources.DatasetStore == nil { + t.Fatal("openWorkspaceWith did not attach a DatasetStore") + } + if _, err := os.Stat(files.Paths.Datasets); err != nil { + t.Errorf("migrated datasets.duckdb %q: %v", files.Paths.Datasets, err) + } + if len(resources.Warnings) != 0 { + t.Errorf("healthy dataset store warnings = %#v, want none", resources.Warnings) + } + if files.Paths.Datasets == files.Paths.Database { + t.Fatal("datasets.duckdb and lem.duckdb resolved to the same path") + } + + ds := dataset.Dataset{ID: dataset.NewID(), Slug: "bootstrap-vents", Title: "bootstrap", CreatedAt: time.Now()} + if result := resources.DatasetStore.DatasetCreate(ds); !result.OK { + t.Fatalf("write through the bootstrapped dataset store: %v", result.Value) + } + + if result := resources.Close(); !result.OK { + t.Fatalf("close workspace resources: %v", result.Value) + } + if resources.DatasetStore != nil { + t.Fatal("Close did not clear resources.DatasetStore") + } +} + +// TestOpenWorkspace_DatasetStoreFailureDegradesWithWarningNotFailure proves +// the design's own rationale for a separate file: "a bloated or damaged +// dataset file must never take the agent/TUI state down with it" — a +// dataset store that cannot open degrades to nil + a warning, exactly +// like State/Preferences already do, never a hard openWorkspaceWith +// failure the way a broken Repository is. +func TestOpenWorkspace_DatasetStoreFailureDegradesWithWarningNotFailure(t *testing.T) { + files := testWorkspaceFiles(t) + if err := os.MkdirAll(files.Paths.Datasets, 0o755); err != nil { + t.Fatalf("seed a directory at the dataset path: %v", err) + } + opened := openWorkspaceWith(files, workspaceOpeners{}) + if !opened.OK { + t.Fatalf("openWorkspaceWith failed instead of degrading: %v", opened.Value) + } + resources := opened.Value.(*workspaceResources) + if resources.DatasetStore != nil { + t.Fatal("resources.DatasetStore is non-nil despite the open failure") + } + assertWorkspaceWarning(t, resources.Warnings, "dataset store") + if result := resources.Close(); !result.OK { + t.Fatalf("close workspace resources: %v", result.Value) + } +} + +func TestOpenWorkspaceNativePreflightRunsBeforeStateMutation(t *testing.T) { + files := testWorkspaceFiles(t) + calls := 0 + result := openWorkspaceWith(files, workspaceOpeners{ + Preflight: func(context.Context) core.Result { + return core.Fail(core.NewError("linked native runtime requires release/update")) + }, + Repository: func(string) core.Result { + calls++ + return core.Fail(core.NewError("repository must not open")) + }, + State: func(appPaths) core.Result { + calls++ + return core.Fail(core.NewError("state must not open")) + }, + Agent: func(context.Context, appFiles, workspaceRepository) core.Result { + calls++ + return core.Fail(core.NewError("agent must not open")) + }, + }) + core.AssertFalse(t, result.OK) + core.AssertContains(t, result.Error(), "release/update") + core.AssertEqual(t, 0, calls) + core.AssertFalse(t, files.Medium.IsDir(appWorkspacesPath)) +} + +func TestAgentBootstrap_ValidPolicy_Good(t *testing.T) { + files := testWorkspaceFiles(t) + if err := files.Medium.Write(files.Paths.Agents, "version: 1\ndispatch:\n default_agent: codex\n global_concurrency: 2\nproviders:\n codex:\n executable: custom-codex\n default_model: gpt-5\n credential_env: [OPENAI_API_KEY]\n flags: [--search]\n"); err != nil { + t.Fatalf("write policy: %v", err) + } + repository := openTestDuckRepository(t) + defer closeTestDuckRepository(t, repository) + engine := &fixtureNativeAgentEngine{capabilities: []work.Capability{{Name: "dispatch", Available: true}}} + server := &fixtureGitServer{} + var configured map[string]provider.Config + factories := fixtureAgentBootstrapFactories(t, repository, engine, server) + factories.NewProviders = func(_ provider.Finder, configurations map[string]provider.Config) core.Result { + configured = configurations + return provider.NewRegistry(&fixtureNativeProvider{name: "codex", available: true}) + } + + result := composeNativeAgent(context.Background(), agentBootstrapInput{Files: files, Repository: repository}, factories) + if !result.OK { + t.Fatalf("composeNativeAgent: %s", result.Error()) + } + opened := result.Value.(agentBootstrapResult) + if configured["codex"].Executable != "custom-codex" || configured["codex"].DefaultModel != "gpt-5" || len(configured["codex"].CredentialEnv) != 1 || len(configured["codex"].Flags) != 1 { + t.Fatalf("mapped provider config = %#v", configured) + } + if server.startCalls != 0 { + t.Fatalf("composition started lazy Git service %d times", server.startCalls) + } + if snapshot := opened.Provider.Snapshot(context.Background()); !snapshot.OK { + t.Fatalf("adapter Snapshot: %s", snapshot.Error()) + } + if server.startCalls != 0 { + t.Fatalf("snapshot started lazy Git service %d times", server.startCalls) + } + if closed := opened.Provider.Close(); !closed.OK { + t.Fatalf("provider Close: %s", closed.Error()) + } +} + +func TestAgentBootstrap_LauncherEnvironmentAllowlist_Good(t *testing.T) { + want := []string{"PATH", "HOME", "USER", "TMPDIR", "TMP", "TEMP", "LANG", "LC_ALL", "SHELL"} + got := nativeAgentEssentialEnvironment() + if core.JSONMarshalString(got) != core.JSONMarshalString(want) { + t.Fatalf("launcher environment allowlist = %#v, want %#v", got, want) + } + for _, key := range got { + if key == "TERM" { + t.Fatal("launcher environment allowlist includes TERM") + } + } +} + +func TestAgentBootstrap_DurableQueueState_Good(t *testing.T) { + files := testWorkspaceFiles(t) + repository := openTestDuckRepository(t) + defer closeTestDuckRepository(t, repository) + at := time.Date(2026, time.July, 18, 10, 30, 0, 0, time.UTC) + durable := work.Snapshot{ + Queue: work.QueueState{ID: "default", Status: work.QueueDraining, Reason: "recovering active work", UpdatedAt: at}, + Providers: []work.ProviderState{{ + Provider: "codex", BackoffReason: "quota", LastRunID: "run-9", + BackoffUntil: at.Add(45 * time.Minute), LastStartedAt: at.Add(-time.Minute), + WindowStartedAt: at.Add(-3 * time.Hour), WindowAdmissions: 17, UpdatedAt: at, + }}, + } + engine := &fixtureNativeAgentEngine{} + server := &fixtureGitServer{} + factories := fixtureAgentBootstrapFactories(t, repository, engine, server) + baseStore := newDuckAgentStore(repository).Value.(orchestrator.Store) + factories.NewStore = func(workspaceRepository) core.Result { + return core.Ok(orchestrator.Store(&fixtureSnapshotStore{Store: baseStore, result: core.Ok(durable)})) + } + var gotQueue work.QueueState + var gotProviders []work.ProviderState + factories.NewQueue = func(_ queue.Policy, initial work.QueueState, providers []work.ProviderState) core.Result { + gotQueue = initial + gotProviders = append([]work.ProviderState(nil), providers...) + return core.Ok(&queue.Controller{}) + } + + result := composeNativeAgent(context.Background(), agentBootstrapInput{Files: files, Repository: repository}, factories) + if !result.OK { + t.Fatalf("composeNativeAgent: %s", result.Error()) + } + defer result.Value.(agentBootstrapResult).Provider.Close() + if core.JSONMarshalString(gotQueue) != core.JSONMarshalString(durable.Queue) || core.JSONMarshalString(gotProviders) != core.JSONMarshalString(durable.Providers) { + t.Fatalf("NewQueue state = queue %#v providers %#v, want queue %#v providers %#v", gotQueue, gotProviders, durable.Queue, durable.Providers) + } +} + +func TestAgentBootstrapPropagatesDispatchAttemptTimeout(t *testing.T) { + files := testWorkspaceFiles(t) + repository := openTestDuckRepository(t) + defer closeTestDuckRepository(t, repository) + engine := &fixtureNativeAgentEngine{} + factories := fixtureAgentBootstrapFactories(t, repository, engine, &fixtureGitServer{}) + policy := queue.Policy{ + Version: 1, + Dispatch: queue.DispatchConfig{DefaultAgent: "codex", GlobalConcurrency: 1, TimeoutMinutes: 7}, + Concurrency: map[string]queue.ConcurrencyLimit{"codex": {Total: 1}}, + Rates: map[string]queue.RateConfig{}, Providers: map[string]queue.NativeConfig{}, + } + factories.LoadPolicy = func(coreio.Medium, string) core.Result { return core.Ok(policy) } + var captured orchestrator.Options + var capturedWorkspace workspace.ManagerOptions + factories.NewWorkspaces = func(options workspace.ManagerOptions) core.Result { + capturedWorkspace = options + return core.Ok(&workspace.Manager{}) + } + factories.NewOrchestrator = func(options orchestrator.Options) core.Result { + captured = options + return core.Ok(nativeAgentEngine(engine)) + } + result := composeNativeAgent(context.Background(), agentBootstrapInput{Files: files, Repository: repository}, factories) + core.AssertTrue(t, result.OK, result.Error()) + defer result.Value.(agentBootstrapResult).Provider.Close() + duration, available := agentOrchestratorDurationOption(captured, "AttemptTimeout") + core.AssertTrue(t, available) + core.AssertEqual(t, 7*time.Minute, duration) + cleanup, cleanupAvailable := agentOrchestratorDurationOption(captured, "CleanupTimeout") + core.AssertTrue(t, cleanupAvailable) + core.AssertEqual(t, defaultAgentCleanupTimeout, cleanup) + managerCleanup, managerCleanupAvailable := agentWorkspaceDurationOption(capturedWorkspace, "CleanupTimeout") + core.AssertTrue(t, managerCleanupAvailable) + core.AssertEqual(t, defaultAgentCleanupTimeout, managerCleanup) +} + +func TestAgentBootstrapRejectsUnhardenedRuntimeBeforeCompositionMutation(t *testing.T) { + files := testWorkspaceFiles(t) + repository := openTestDuckRepository(t) + defer closeTestDuckRepository(t, repository) + factories := agentBootstrapFactories{} + mutations := 0 + factories.RuntimeContract = func(context.Context) core.Result { + return core.Fail(core.NewError("linked inference runtime has no hardened contract")) + } + factories.LoadPolicy = func(coreio.Medium, string) core.Result { + mutations++ + return core.Fail(core.NewError("policy loader must not run")) + } + + result := composeNativeAgent(context.Background(), agentBootstrapInput{Files: files, Repository: repository}, factories) + core.AssertFalse(t, result.OK) + core.AssertContains(t, result.Error(), "release/update") + core.AssertEqual(t, 0, mutations) +} + +func TestAgentBootstrapContractCancellationDoesNotMasqueradeAsReleaseMismatch(t *testing.T) { + files := testWorkspaceFiles(t) + repository := openTestDuckRepository(t) + defer closeTestDuckRepository(t, repository) + ctx, cancel := context.WithCancel(context.Background()) + mutations := 0 + factories := agentBootstrapFactories{ + RuntimeContract: func(context.Context) core.Result { + cancel() + return core.Fail(core.NewError("contract interrupted")) + }, + LoadPolicy: func(coreio.Medium, string) core.Result { + mutations++ + return core.Fail(core.NewError("policy loader must not run")) + }, + } + + result := composeNativeAgent(ctx, agentBootstrapInput{Files: files, Repository: repository}, factories) + core.AssertFalse(t, result.OK) + core.AssertContains(t, result.Error(), "context is done") + core.AssertFalse(t, strings.Contains(result.Error(), "release/update")) + core.AssertEqual(t, 0, mutations) +} + +func TestAgentBootstrapLinkedRuntimeContractBoundary(t *testing.T) { + result := linkedAgentRuntimeContract(context.Background()) + _, hardened := any(orchestrator.Options{}).(agentHardenedRuntime) + if hardened { + core.AssertTrue(t, result.OK, result.Error()) + core.AssertEqual(t, agentHardenedRuntimeContract, result.Value.(string)) + return + } + core.AssertFalse(t, result.OK) + core.AssertContains(t, result.Error(), "does not expose the hardened native runtime contract") +} + +func TestAgentBootstrap_DurableSnapshotFailure_Bad(t *testing.T) { + files := testWorkspaceFiles(t) + repository := openTestDuckRepository(t) + defer closeTestDuckRepository(t, repository) + for _, test := range []struct { + name string + result core.Result + want string + }{ + {name: "read failure", result: core.Fail(core.E("test.snapshot", "durable queue offline", nil)), want: "durable queue offline"}, + {name: "type failure", result: core.Ok("not a snapshot"), want: "invalid durable snapshot"}, + } { + t.Run(test.name, func(t *testing.T) { + server := &fixtureGitServer{} + launcher := &fixtureAgentLauncher{} + factories := fixtureAgentBootstrapFactories(t, repository, &fixtureNativeAgentEngine{}, server) + baseStore := newDuckAgentStore(repository).Value.(orchestrator.Store) + factories.NewStore = func(workspaceRepository) core.Result { + return core.Ok(orchestrator.Store(&fixtureSnapshotStore{Store: baseStore, result: test.result})) + } + factories.NewLauncher = func() core.Result { return core.Ok(orchestrator.Launcher(launcher)) } + result := composeNativeAgent(context.Background(), agentBootstrapInput{Files: files, Repository: repository}, factories) + if result.OK || !strings.Contains(result.Error(), "load durable queue state") || !strings.Contains(result.Error(), test.want) { + t.Fatalf("snapshot failure = %#v, want %q", result, test.want) + } + if server.startCalls != 0 || server.closeCalls != 0 || launcher.closeCalls != 0 { + t.Fatalf("snapshot failure constructed later resources: server start=%d close=%d launcher close=%d", server.startCalls, server.closeCalls, launcher.closeCalls) + } + }) + } +} + +func TestAgentBootstrap_MalformedPolicy_Bad(t *testing.T) { + files := testWorkspaceFiles(t) + if err := files.Medium.Write(files.Paths.Agents, "version: 99\ndispatch:\n global_concurrency: 1\n"); err != nil { + t.Fatalf("write malformed policy: %v", err) + } + repository := openTestDuckRepository(t) + defer closeTestDuckRepository(t, repository) + factories := fixtureAgentBootstrapFactories(t, repository, &fixtureNativeAgentEngine{}, &fixtureGitServer{}) + + result := composeNativeAgent(context.Background(), agentBootstrapInput{Files: files, Repository: repository}, factories) + if result.OK || !strings.Contains(result.Error(), "queue is frozen") || !strings.Contains(result.Error(), "version must be 1") { + t.Fatalf("malformed policy result = %#v", result) + } +} + +func TestAgentBootstrap_OwnerContention_Bad(t *testing.T) { + files := testWorkspaceFiles(t) + repository := openTestDuckRepository(t) + defer closeTestDuckRepository(t, repository) + startFailure := core.Fail(core.E("test.gitserver", "private Git owner lock is held by live PID 4242", nil)) + exact := startFailure.Error() + server := &fixtureGitServer{startResult: startFailure} + projectReview := orchestrator.ProjectReview{ + Work: work.Item{ID: "work-owner", Title: "Owner contention", Task: "review lazily", Repository: "/src/owner"}, + Source: workspace.SourceReview{Path: "/src/owner", Root: "/src/owner", Branch: "main", Revision: "abc", IncludedHash: "hash"}, + RepositoryName: "work-owner", + } + engine := &fixtureNativeAgentEngine{capabilities: nativeFixtureCapabilities(), projectReview: projectReview} + factories := fixtureAgentBootstrapFactories(t, repository, engine, server) + factories.NewOrchestrator = func(options orchestrator.Options) core.Result { + engine.registerProject = func(ctx context.Context, _ orchestrator.ProjectReview, _ bool) core.Result { + registered := options.GitServer.EnsureRepository(ctx, projectReview.RepositoryName) + if !registered.OK { + return registered + } + return core.Ok(engine.registeredProject) + } + return core.Ok(nativeAgentEngine(engine)) + } + openers := workspaceOpeners{Agent: func(ctx context.Context, files appFiles, repository workspaceRepository) core.Result { + return composeNativeAgent(ctx, agentBootstrapInput{Files: files, Repository: repository}, factories) + }} + + result := openWorkspaceWith(files, openers) + if !result.OK { + t.Fatalf("owner contention blocked the rest of workspace: %s", result.Error()) + } + resources := result.Value.(*workspaceResources) + defer resources.Close() + if server.startCalls != 0 { + t.Fatalf("composition started lazy Git service %d times", server.startCalls) + } + if snapshot := resources.Agent.Snapshot(context.Background()); !snapshot.OK { + t.Fatalf("Snapshot: %s", snapshot.Error()) + } + if server.startCalls != 0 { + t.Fatalf("snapshot started lazy Git service %d times", server.startCalls) + } + reviewResult := resources.Agent.Review(context.Background(), agentReviewRequest{ + Feature: agentFeatureDispatch, WorkID: projectReview.Work.ID, + Work: agentWorkRequest{ID: projectReview.Work.ID, Title: projectReview.Work.Title, Task: projectReview.Work.Task, Repository: projectReview.Work.Repository}, + }) + if !reviewResult.OK || server.startCalls != 0 { + t.Fatalf("project Review = %#v, start calls=%d", reviewResult, server.startCalls) + } + action := resources.Agent.Run(context.Background(), agentRequest{Feature: agentFeatureDispatch, Review: reviewResult.Value.(agentReview), Confirmed: true}) + if action.OK || action.Error() != exact || server.startCalls != 1 { + t.Fatalf("owner action = %#v, start calls=%d, want exact %q", action, server.startCalls, exact) + } + available := make(map[agentFeature]agentCapability) + for _, capability := range resources.Agent.Capabilities() { + available[capability.Feature] = capability + } + for _, feature := range []agentFeature{agentFeatureDispatch, agentFeatureRetry, agentFeatureResume, agentFeatureChangesReview, agentFeatureAccept, agentFeatureReject} { + if capability := available[feature]; capability.Available || capability.Reason != exact { + t.Fatalf("Git-dependent capability %q = %#v", feature, capability) + } + } + for _, feature := range []agentFeature{agentFeatureCancel, agentFeatureAnswer, agentFeatureQueueStart, agentFeatureQueueStop} { + if capability := available[feature]; !capability.Available { + t.Fatalf("unrelated capability %q degraded: %#v", feature, capability) + } + } +} + +func TestAgentBootstrap_MissingGitAndProviders_Ugly(t *testing.T) { + files := testWorkspaceFiles(t) + repository := openTestDuckRepository(t) + defer closeTestDuckRepository(t, repository) + t.Run("Git reason is exact", func(t *testing.T) { + factories := fixtureAgentBootstrapFactories(t, repository, &fixtureNativeAgentEngine{}, &fixtureGitServer{}) + factories.GitAvailable = func() core.Result { return core.Fail(core.E("test.git", "git executable missing from PATH", nil)) } + result := composeNativeAgent(context.Background(), agentBootstrapInput{Files: files, Repository: repository}, factories) + if result.OK || !strings.Contains(result.Error(), "git executable missing from PATH") { + t.Fatalf("missing Git result = %#v", result) + } + }) + t.Run("provider reasons are preserved", func(t *testing.T) { + server := &fixtureGitServer{} + factories := fixtureAgentBootstrapFactories(t, repository, &fixtureNativeAgentEngine{}, server) + factories.NewProviders = func(provider.Finder, map[string]provider.Config) core.Result { + return provider.NewRegistry( + &fixtureNativeProvider{name: "codex", reason: "codex executable not found"}, + &fixtureNativeProvider{name: "claude", reason: "claude executable not found"}, + ) + } + result := composeNativeAgent(context.Background(), agentBootstrapInput{Files: files, Repository: repository}, factories) + if !result.OK { + t.Fatalf("missing providers should leave durable controls usable: %s", result.Error()) + } + warnings := result.Value.(agentBootstrapResult).Warnings + assertWorkspaceWarning(t, warnings, "codex executable not found") + assertWorkspaceWarning(t, warnings, "claude executable not found") + _ = result.Value.(agentBootstrapResult).Provider.Close() + }) +} + +func TestAgentBootstrap_PartialFailureCleanup_Ugly(t *testing.T) { + files := testWorkspaceFiles(t) + repository := openTestDuckRepository(t) + defer closeTestDuckRepository(t, repository) + stages := []string{"workspace-medium", "store", "git", "git-options", "git-server", "workspaces", "providers", "provider-detection", "queue", "launcher", "orchestrator"} + for _, stage := range stages { + t.Run(stage, func(t *testing.T) { + server := &fixtureGitServer{} + launcher := &fixtureAgentLauncher{} + factories := fixtureAgentBootstrapFactories(t, repository, &fixtureNativeAgentEngine{}, server) + factories.NewLauncher = func() core.Result { return core.Ok(orchestrator.Launcher(launcher)) } + fail := func() core.Result { return core.Fail(core.E("test.bootstrap", "fail at "+stage, nil)) } + switch stage { + case "workspace-medium": + factories.OpenWorkspaceMedium = func(string) core.Result { return fail() } + case "store": + factories.NewStore = func(workspaceRepository) core.Result { return fail() } + case "git": + factories.GitAvailable = fail + case "git-options": + factories.GitOptions = func(string) core.Result { return fail() } + case "git-server": + factories.NewGitServer = func(gitserver.Options) core.Result { return fail() } + case "workspaces": + factories.NewWorkspaces = func(workspace.ManagerOptions) core.Result { return fail() } + case "providers": + factories.NewProviders = func(provider.Finder, map[string]provider.Config) core.Result { return fail() } + case "provider-detection": + factories.DetectProviders = func(context.Context, *provider.Registry) core.Result { return fail() } + case "queue": + factories.NewQueue = func(queue.Policy, work.QueueState, []work.ProviderState) core.Result { return fail() } + case "launcher": + factories.NewLauncher = fail + case "orchestrator": + factories.NewOrchestrator = func(orchestrator.Options) core.Result { return fail() } + } + result := composeNativeAgent(context.Background(), agentBootstrapInput{Files: files, Repository: repository}, factories) + if result.OK || !strings.Contains(result.Error(), "fail at "+stage) { + t.Fatalf("failure result = %#v", result) + } + serverConstructed := stage == "workspaces" || stage == "providers" || stage == "provider-detection" || stage == "queue" || stage == "launcher" || stage == "orchestrator" + if serverConstructed != (server.closeCalls == 1) { + t.Fatalf("server Close calls = %d, constructed=%v", server.closeCalls, serverConstructed) + } + launcherConstructed := stage == "orchestrator" + if launcherConstructed != (launcher.closeCalls == 1) { + t.Fatalf("launcher Close calls = %d, constructed=%v", launcher.closeCalls, launcherConstructed) + } + }) + } +} + +func TestAgentBootstrap_ResourceClosePreservesErrors_Ugly(t *testing.T) { + order := make([]string, 0, 3) + resources := &workspaceResources{ + Agent: &failingCloseAgent{order: &order}, + State: &failingCloseReactiveState{order: &order}, + Repository: &failingCloseWorkspaceRepository{order: &order}, + } + result := resources.Close() + if result.OK || strings.Join(order, ",") != "agent" { + t.Fatalf("Close = %#v, order=%#v", result, order) + } + if !strings.Contains(result.Error(), "agent close failed") { + t.Fatalf("Close error %q does not preserve agent failure", result.Error()) + } + if resources.State == nil || resources.Repository == nil { + t.Fatalf("Close destroyed retry dependencies after agent failure: %#v", resources) + } +} + +func TestAgentBootstrapResourceCloseRetriesAgentOwnershipCleanup(t *testing.T) { + repository := openTestDuckRepository(t) + state := &retryCloseReactiveState{} + agent := &retryCloseAgent{repository: repository} + resources := &workspaceResources{Agent: agent, State: state, Repository: repository} + first := resources.Close() + core.AssertFalse(t, first.OK) + core.AssertTrue(t, resources.Agent != nil) + core.AssertTrue(t, resources.State != nil) + core.AssertTrue(t, resources.Repository != nil) + core.AssertFalse(t, state.closed) + core.AssertTrue(t, repository.ListSessions(false).OK) + second := resources.Close() + core.AssertTrue(t, second.OK, second.Error()) + core.AssertEqual(t, 2, agent.calls) + core.AssertTrue(t, resources.Agent == nil) + core.AssertTrue(t, resources.State == nil) + core.AssertTrue(t, resources.Repository == nil) + core.AssertTrue(t, state.closed) +} + +func TestAgentBootstrapOwnedNativeLauncherCloseRetriesFailure(t *testing.T) { + inner := &fixtureAgentLauncher{closeFailures: 1} + launcher := &ownedNativeLauncher{launcher: inner, result: core.Ok(nil)} + first := launcher.Close() + core.AssertFalse(t, first.OK) + core.AssertEqual(t, 1, inner.closeCalls) + second := launcher.Close() + core.AssertTrue(t, second.OK, second.Error()) + core.AssertEqual(t, 2, inner.closeCalls) +} + +func TestOpenWorkspace_Bad(t *testing.T) { + files := testWorkspaceFiles(t) + openers := workspaceOpeners{ + Repository: func(string) core.Result { + return core.Fail(core.E("test.repository", "database unavailable", nil)) + }, + } + + result := openWorkspaceWith(files, openers) + if result.OK { + t.Fatalf("openWorkspaceWith repository failure = %#v, want failure", result.Value) + } + err, ok := result.Value.(error) + if !ok || !strings.Contains(err.Error(), files.Paths.Database) { + t.Fatalf("repository failure = %v, want exact DuckDB path %q", result.Value, files.Paths.Database) + } +} + +func TestOpenWorkspace_Context_Good(t *testing.T) { + type contextKey string + const key contextKey = "agent-bootstrap" + ctx := context.WithValue(context.Background(), key, "caller-context") + files := testWorkspaceFiles(t) + seen := "" + result := openWorkspaceWithContext(ctx, files, workspaceOpeners{ + Agent: func(agentContext context.Context, workspaceFiles appFiles, repository workspaceRepository) core.Result { + factories := fixtureAgentBootstrapFactories(t, repository, &fixtureNativeAgentEngine{}, &fixtureGitServer{}) + factories.DetectProviders = func(providerContext context.Context, registry *provider.Registry) core.Result { + seen, _ = providerContext.Value(key).(string) + return detectNativeProviders(providerContext, registry) + } + return composeNativeAgent(agentContext, agentBootstrapInput{Files: workspaceFiles, Repository: repository}, factories) + }, + }) + if !result.OK { + t.Fatalf("openWorkspaceWithContext: %s", result.Error()) + } + defer result.Value.(*workspaceResources).Close() + if seen != "caller-context" { + t.Fatalf("provider detection context value = %q, want caller-context", seen) + } +} + +func TestOpenWorkspace_Ugly(t *testing.T) { + t.Run("state degrades", func(t *testing.T) { + files := testWorkspaceFiles(t) + result := openWorkspaceWith(files, workspaceOpeners{ + State: func(appPaths) core.Result { + return core.Fail(core.E("test.state", "state offline", nil)) + }, + }) + if !result.OK { + t.Fatalf("state failure blocked startup: %v", result.Value) + } + resources := result.Value.(*workspaceResources) + defer func() { _ = resources.Close() }() + if _, read := resources.State.Get(reactiveGroupDrafts, "session-a"); read.OK { + t.Fatal("disabled state Get succeeded") + } + if write := resources.State.Set(reactiveGroupDrafts, "session-a", "draft"); write.OK { + t.Fatal("disabled state Set succeeded") + } + assertWorkspaceWarning(t, resources.Warnings, "state offline") + }) + + t.Run("preferences degrade", func(t *testing.T) { + files := testWorkspaceFiles(t) + result := openWorkspaceWith(files, workspaceOpeners{ + Preferences: func(coreio.Medium, string) core.Result { + return core.Fail(core.E("test.preferences", "config offline", nil)) + }, + }) + if !result.OK { + t.Fatalf("preference failure blocked startup: %v", result.Value) + } + resources := result.Value.(*workspaceResources) + defer func() { _ = resources.Close() }() + if values := resources.Preferences.Values(); values.Theme != "midnight" || values.MaxTokens != 4096 { + t.Fatalf("degraded preference defaults = %#v", values) + } + if resources.Preferences.Warning() == nil { + t.Fatal("degraded preferences warning = nil") + } + if commit := resources.Preferences.Commit(); commit.OK { + t.Fatal("degraded preferences Commit succeeded") + } + assertWorkspaceWarning(t, resources.Warnings, "config offline") + }) +} + +type trackingWorkspaceRepository struct { + workspaceRepository + closeOrder *[]string + closeFailure string +} + +func (repository *trackingWorkspaceRepository) Close() core.Result { + *repository.closeOrder = append(*repository.closeOrder, "repository") + result := repository.workspaceRepository.Close() + if !result.OK { + return result + } + if repository.closeFailure != "" { + return core.Fail(core.E("test.repository.Close", repository.closeFailure, nil)) + } + return core.Ok(nil) +} + +type trackingReactiveState struct { + reactiveState + closeOrder *[]string + closeFailure string +} + +func (state *trackingReactiveState) Close() core.Result { + *state.closeOrder = append(*state.closeOrder, "state") + result := state.reactiveState.Close() + if !result.OK { + return result + } + if state.closeFailure != "" { + return core.Fail(core.E("test.state.Close", state.closeFailure, nil)) + } + return core.Ok(nil) +} + +func testWorkspaceFiles(t *testing.T) appFiles { + t.Helper() + root := t.TempDir() + pathsResult := appPathsAt(root) + if !pathsResult.OK { + t.Fatalf("appPathsAt(%q): %v", root, pathsResult.Value) + } + paths := pathsResult.Value.(appPaths) + if err := os.MkdirAll(paths.Workspaces, 0700); err != nil { + t.Fatalf("create local workspace state directory: %v", err) + } + return appFiles{Paths: paths, Medium: coreio.NewMockMedium()} +} + +func assertWorkspaceWarning(t *testing.T, warnings []string, want string) { + t.Helper() + for _, warning := range warnings { + if strings.Contains(warning, want) { + return + } + } + t.Fatalf("workspace warnings = %#v, want text %q", warnings, want) +} + +func fixtureAgentBootstrapFactories(t *testing.T, repository workspaceRepository, engine nativeAgentEngine, server *fixtureGitServer) agentBootstrapFactories { + t.Helper() + if contract := linkedAgentRuntimeContract(context.Background()); !contract.OK { + t.Skipf("native bootstrap is unavailable with the linked inference module: %s", contract.Error()) + } + return agentBootstrapFactories{ + OpenWorkspaceMedium: func(string) core.Result { return core.Ok(coreio.Medium(coreio.NewMockMedium())) }, + NewStore: func(workspaceRepository) core.Result { return newDuckAgentStore(repository) }, + GitAvailable: func() core.Result { return core.Ok("/usr/bin/git") }, + GitOptions: gitserver.DefaultOptions, + NewGitServer: func(gitserver.Options) core.Result { return core.Ok(gitserver.Service(server)) }, + NewWorkspaces: func(workspace.ManagerOptions) core.Result { return core.Ok(&workspace.Manager{}) }, + NewProviders: func(provider.Finder, map[string]provider.Config) core.Result { + return provider.NewRegistry(&fixtureNativeProvider{name: "codex", available: true}) + }, + DetectProviders: detectNativeProviders, + NewQueue: func(queue.Policy, work.QueueState, []work.ProviderState) core.Result { + return core.Ok(&queue.Controller{}) + }, + NewLauncher: func() core.Result { return core.Ok(orchestrator.Launcher(&fixtureAgentLauncher{})) }, + NewOrchestrator: func(orchestrator.Options) core.Result { + return core.Ok(engine) + }, + Now: func() time.Time { return time.Date(2026, time.July, 18, 12, 0, 0, 0, time.UTC) }, + IDs: &fixtureAgentIdentifiers{}, + } +} + +type fixtureGitServer struct { + startCalls int + closeCalls int + startResult core.Result +} + +func (server *fixtureGitServer) Start(context.Context) core.Result { + server.startCalls++ + if server.startResult.OK || server.startResult.Value != nil { + return server.startResult + } + return core.Ok(gitserver.Health{Running: true, Address: "127.0.0.1:0"}) +} + +func (server *fixtureGitServer) EnsureRepository(ctx context.Context, _ string) core.Result { + if started := server.Start(ctx); !started.OK { + return started + } + return core.Ok(gitserver.Repository{Name: "fixture", CloneURL: "ssh://127.0.0.1/fixture"}) +} + +func (*fixtureGitServer) Health(context.Context) core.Result { + return core.Ok(gitserver.Health{Reason: "private Git service is not started"}) +} + +func (server *fixtureGitServer) Close() core.Result { + server.closeCalls++ + return core.Ok(nil) +} + +type fixtureSnapshotStore struct { + orchestrator.Store + result core.Result +} + +func (store *fixtureSnapshotStore) Snapshot(string) core.Result { return store.result } + +type fixtureNativeProvider struct { + name string + available bool + reason string +} + +func (adapter *fixtureNativeProvider) Name() string { return adapter.name } + +func (adapter *fixtureNativeProvider) Detect(context.Context) core.Result { + return core.Ok(provider.Detection{Provider: adapter.name, Available: adapter.available, Reason: adapter.reason}) +} + +func (adapter *fixtureNativeProvider) Build(provider.Launch) core.Result { + return core.Ok(provider.Command{Provider: adapter.name, Executable: adapter.name, Receipt: adapter.name}) +} + +func (*fixtureNativeProvider) ParseLine(string, string) []provider.Output { return nil } + +type fixtureAgentLauncher struct { + closeCalls int + closeFailures int +} + +func (*fixtureAgentLauncher) DetectEnvironment([]string) core.Result { return core.Ok([]string{}) } + +func (*fixtureAgentLauncher) Start(context.Context, provider.Command, func(string, string)) core.Result { + return core.Fail(core.E("test.launcher", "not used", nil)) +} + +func (launcher *fixtureAgentLauncher) Close() core.Result { + launcher.closeCalls++ + if launcher.closeFailures > 0 { + launcher.closeFailures-- + return core.Fail(core.E("test.launcher", "ownership cleanup pending", nil)) + } + return core.Ok(nil) +} + +type fixtureAgentIdentifiers struct{ next int } + +func (ids *fixtureAgentIdentifiers) New() string { + ids.next++ + return core.Sprintf("fixture-%d", ids.next) +} + +type failingCloseAgent struct{ order *[]string } + +type retryCloseAgent struct { + calls int + repository workspaceRepository +} + +func (*retryCloseAgent) Capabilities() []agentCapability { return nil } +func (*retryCloseAgent) Snapshot(context.Context) core.Result { return core.Ok(agentSnapshot{}) } +func (*retryCloseAgent) Review(context.Context, agentReviewRequest) core.Result { + return core.Fail(core.E("test.retryAgent", "not used", nil)) +} +func (*retryCloseAgent) Run(context.Context, agentRequest) core.Result { + return core.Fail(core.E("test.retryAgent", "not used", nil)) +} +func (agent *retryCloseAgent) Close() core.Result { + agent.calls++ + if agent.calls == 1 { + return core.Fail(core.E("test.retryAgent", "ownership cleanup pending", nil)) + } + if agent.repository != nil { + if result := agent.repository.ListSessions(false); !result.OK { + return core.Fail(core.E("test.retryAgent", "repository unavailable during retry", result.Err())) + } + } + return core.Ok(nil) +} + +type retryCloseReactiveState struct{ closed bool } + +func (*retryCloseReactiveState) Get(string, string) (string, core.Result) { + return "", core.Ok(nil) +} +func (*retryCloseReactiveState) Set(string, string, string) core.Result { return core.Ok(nil) } +func (*retryCloseReactiveState) Delete(string, string) core.Result { return core.Ok(nil) } +func (state *retryCloseReactiveState) Close() core.Result { + state.closed = true + return core.Ok(nil) +} + +func (*failingCloseAgent) Capabilities() []agentCapability { return nil } +func (*failingCloseAgent) Snapshot(context.Context) core.Result { + return core.Ok(agentSnapshot{}) +} +func (*failingCloseAgent) Review(context.Context, agentReviewRequest) core.Result { + return core.Fail(core.E("test.agent", "not used", nil)) +} +func (*failingCloseAgent) Run(context.Context, agentRequest) core.Result { + return core.Fail(core.E("test.agent", "not used", nil)) +} +func (agent *failingCloseAgent) Close() core.Result { + *agent.order = append(*agent.order, "agent") + return core.Fail(core.E("test.agent", "agent close failed", nil)) +} + +type failingCloseReactiveState struct{ order *[]string } + +func (*failingCloseReactiveState) Get(string, string) (string, core.Result) { + return "", core.Fail(core.E("test.state", "not used", nil)) +} +func (*failingCloseReactiveState) Set(string, string, string) core.Result { + return core.Fail(core.E("test.state", "not used", nil)) +} +func (*failingCloseReactiveState) Delete(string, string) core.Result { + return core.Fail(core.E("test.state", "not used", nil)) +} +func (state *failingCloseReactiveState) Close() core.Result { + *state.order = append(*state.order, "state") + return core.Fail(core.E("test.state", "state close failed", nil)) +} + +type failingCloseWorkspaceRepository struct { + workspaceRepository + order *[]string +} + +func (repository *failingCloseWorkspaceRepository) Close() core.Result { + *repository.order = append(*repository.order, "repository") + return core.Fail(core.E("test.repository", "repository close failed", nil)) +} diff --git a/cli/tui/dataoverlay.go b/cli/tui/dataoverlay.go new file mode 100644 index 000000000..81b3d2756 --- /dev/null +++ b/cli/tui/dataoverlay.go @@ -0,0 +1,269 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package tui + +import ( + "github.com/charmbracelet/bubbles/textarea" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + + core "dappco.re/go" + "dappco.re/go/inference/dataset" +) + +// dataItemEditor is the edit-as-derived editor seam — two text areas +// (Prompt/Response) pre-filled from the selected item, Tab cycles focus, +// Ctrl+S saves as a new derived item. Mirrors workEditor's shape exactly +// (agentoverlay.go): no store dependency of its own — saving goes through +// dataPanel.EditAsDerived via app.saveDataEditor, exactly as workEditor's +// values() feed app.saveWorkEditor. +type dataItemEditor struct { + original dataset.Item + prompt textarea.Model + response textarea.Model + focus int +} + +func newDataItemEditor(item dataset.Item) *dataItemEditor { + initialPrompt, initialResponse, _ := dataItemExchange(item) + prompt := textarea.New() + prompt.Prompt = "" + prompt.Placeholder = "Prompt" + prompt.ShowLineNumbers = false + prompt.SetHeight(5) + prompt.SetValue(initialPrompt) + prompt.Focus() + response := textarea.New() + response.Prompt = "" + response.Placeholder = "Response" + response.ShowLineNumbers = false + response.SetHeight(8) + response.SetValue(initialResponse) + return &dataItemEditor{original: item, prompt: prompt, response: response} +} + +func (editor *dataItemEditor) Update(message tea.Msg) tea.Cmd { + if editor == nil { + return nil + } + if key, ok := message.(tea.KeyMsg); ok { + switch key.String() { + case "tab", "shift+tab": + editor.focus = (editor.focus + 1) % 2 + editor.applyFocus() + return nil + } + } + return editor.updateFocused(message) +} + +func (editor *dataItemEditor) updateFocused(message tea.Msg) tea.Cmd { + var command tea.Cmd + if editor.focus == 0 { + editor.prompt, command = editor.prompt.Update(message) + } else { + editor.response, command = editor.response.Update(message) + } + return command +} + +func (editor *dataItemEditor) applyFocus() { + if editor == nil { + return + } + editor.prompt.Blur() + editor.response.Blur() + if editor.focus == 0 { + editor.prompt.Focus() + } else { + editor.response.Focus() + } +} + +func (editor *dataItemEditor) values() (string, string) { + if editor == nil { + return "", "" + } + return editor.prompt.Value(), editor.response.Value() +} + +func (editor *dataItemEditor) View(width, height int, styles uiStyles) string { + if editor == nil { + return "" + } + fieldWidth := max(12, width-6) + editor.prompt.SetWidth(fieldWidth) + editor.response.SetWidth(fieldWidth) + promptLabel := "Prompt" + if editor.original.Kind == dataset.KindMessages { + promptLabel = "Prompt (context only — earlier turns are kept as-is)" + } + return fitPane(core.Join("\n", + "Edit as derived", "", + promptLabel, editor.prompt.View(), "", + "Response", editor.response.View(), "", + "tab changes field · ctrl+s saves as a new derived item · esc cancels", + ), width, height, styles.panel) +} + +// dataNoteOverlay collects one freeform line — a quarantine-clear +// justification or a tag label — for a single item (itemID set) or as +// the shared note/label a bulk action's confirm overlay applies to every +// filtered item (itemID empty). Update only reports a submit on Enter +// with a non-empty trimmed value — the note-required guard lives here, +// not in the caller, so an empty Enter is silently ignored rather than +// producing an empty-note write. +type dataNoteOverlay struct { + action dataAction + itemID string + title string + prompt string + input textinput.Model +} + +func newDataNoteOverlay(action dataAction, itemID, title, prompt, placeholder string) *dataNoteOverlay { + input := textinput.New() + input.Prompt = "" + input.Placeholder = placeholder + input.Focus() + return &dataNoteOverlay{action: action, itemID: itemID, title: title, prompt: prompt, input: input} +} + +// Update reports whether key completed a submit — Enter with a non-empty +// trimmed value. Any other key is forwarded to the text input. +func (overlay *dataNoteOverlay) Update(message tea.KeyMsg) bool { + if overlay == nil { + return false + } + if message.String() == "enter" { + return core.Trim(overlay.input.Value()) != "" + } + var command tea.Cmd + overlay.input, command = overlay.input.Update(message) + _ = command + return false +} + +func (overlay *dataNoteOverlay) Value() string { + if overlay == nil { + return "" + } + return core.Trim(overlay.input.Value()) +} + +// Bulk reports whether this note is the shared note for a bulk action +// (itemID empty) rather than a single item's. +func (overlay *dataNoteOverlay) Bulk() bool { + return overlay != nil && overlay.itemID == "" +} + +func (overlay *dataNoteOverlay) View(width, height int, styles uiStyles) string { + if overlay == nil { + return "" + } + overlay.input.Width = max(12, width-6) + return fitPane(core.Join("\n", overlay.title, "", overlay.prompt, overlay.input.View(), "", "enter submits · esc cancels"), width, height, styles.panel) +} + +// dataBulkOverlay gates a bulk-apply-to-current-filter action behind an +// explicit two-phase confirm (mirrors changeAcceptanceOverlay's arm/ +// confirm shape, agentoverlay.go) showing the exact item count about to +// be written — "no confirm, no writes": Confirm only returns true on the +// SECOND Enter, and every path that dismisses the overlay without +// reaching that (Escape, switching panels, quitting) never calls +// dataPanel.BulkApply at all. +type dataBulkOverlay struct { + action dataAction + count int + note string + armed bool +} + +func newDataBulkOverlay(action dataAction, count int, note string) *dataBulkOverlay { + return &dataBulkOverlay{action: action, count: count, note: core.Trim(note)} +} + +// Confirm reports whether key is the second, confirming Enter. Any other +// key is ignored — the overlay deliberately consumes input until an +// explicit confirm or cancel, matching changeAcceptanceOverlay's +// precedent. +func (overlay *dataBulkOverlay) Confirm(key string) bool { + if overlay == nil || key != "enter" { + return false + } + if !overlay.armed { + overlay.armed = true + return false + } + return true +} + +func (overlay *dataBulkOverlay) View(width, height int, styles uiStyles) string { + if overlay == nil { + return "" + } + prompt := "enter continues · esc cancels" + if overlay.armed { + prompt = "enter applies this action to every listed item · esc cancels" + } + lines := []string{ + "Bulk " + overlay.action.title(), + "", + core.Sprintf("This will apply to %d item(s) matching the current filter.", overlay.count), + } + if overlay.note != "" { + lines = append(lines, "", "Note: "+overlay.note) + } + lines = append(lines, "", prompt) + return fitPane(core.Join("\n", lines...), width, height, styles.panel) +} + +// dataFilterOverlay edits the Data panel's structural filter as one line +// of text in parseDataFilterExpr's grammar, pre-filled from the panel's +// current filter (FilterExpr) — unlike dataNoteOverlay, an empty value is +// a valid submit (it clears every filter dimension). +type dataFilterOverlay struct { + input textinput.Model +} + +func newDataFilterOverlay(current string) *dataFilterOverlay { + input := textinput.New() + input.Prompt = "" + input.Placeholder = "dataset=slug,status=pending,kind=pair,source=capture:serve,lek>=80" + input.SetValue(current) + input.Focus() + return &dataFilterOverlay{input: input} +} + +func (overlay *dataFilterOverlay) Update(message tea.KeyMsg) bool { + if overlay == nil { + return false + } + if message.String() == "enter" { + return true + } + var command tea.Cmd + overlay.input, command = overlay.input.Update(message) + _ = command + return false +} + +func (overlay *dataFilterOverlay) Value() string { + if overlay == nil { + return "" + } + return overlay.input.Value() +} + +func (overlay *dataFilterOverlay) View(width, height int, styles uiStyles) string { + if overlay == nil { + return "" + } + overlay.input.Width = max(12, width-6) + return fitPane(core.Join("\n", + "Filter", "", + "dataset= status= kind= source= , comma-separated", + overlay.input.View(), "", + "enter applies · esc cancels", + ), width, height, styles.panel) +} diff --git a/cli/tui/dataoverlay_test.go b/cli/tui/dataoverlay_test.go new file mode 100644 index 000000000..837d8d0fa --- /dev/null +++ b/cli/tui/dataoverlay_test.go @@ -0,0 +1,238 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package tui + +import ( + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + + core "dappco.re/go" + "dappco.re/go/inference/dataset" +) + +func keyMsg(s string) tea.KeyMsg { + switch s { + case "enter": + return tea.KeyMsg{Type: tea.KeyEnter} + case "tab": + return tea.KeyMsg{Type: tea.KeyTab} + case "esc": + return tea.KeyMsg{Type: tea.KeyEsc} + default: + return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} + } +} + +func testTime() time.Time { + return time.Date(2026, time.July, 19, 9, 0, 0, 0, time.UTC) +} + +func dataMessagesJSON(t *testing.T, turns []dataset.MessageTurn) []byte { + t.Helper() + marshalled := core.JSONMarshal(dataset.MessagesContent{Messages: turns}) + if !marshalled.OK { + t.Fatalf("marshal messages content: %v", marshalled.Value) + } + return marshalled.Value.([]byte) +} + +// ---- dataItemEditor ---- + +func TestNewDataItemEditor_Good(t *testing.T) { + item := conformancePairItem("ds", "original prompt", "original response", testTime()) + editor := newDataItemEditor(item) + prompt, response := editor.values() + if prompt != "original prompt" || response != "original response" { + t.Fatalf("values = %q / %q", prompt, response) + } +} + +func TestDataItemEditor_FocusCyclesAndRoutesInput(t *testing.T) { + item := conformancePairItem("ds", "p", "r", testTime()) + editor := newDataItemEditor(item) + if editor.focus != 0 { + t.Fatalf("initial focus = %d, want 0 (prompt)", editor.focus) + } + editor.Update(keyMsg("tab")) + if editor.focus != 1 { + t.Fatalf("focus after tab = %d, want 1 (response)", editor.focus) + } + editor.Update(keyMsg("x")) + _, response := editor.values() + if !strings.Contains(response, "x") { + t.Fatalf("response after typing on focus 1 = %q, want it to contain the typed rune", response) + } + editor.Update(keyMsg("tab")) + if editor.focus != 0 { + t.Fatalf("focus after second tab = %d, want 0", editor.focus) + } +} + +func TestDataItemEditor_View_MessagesKindNotesContextOnly(t *testing.T) { + content := dataMessagesJSON(t, []dataset.MessageTurn{{Role: "user", Content: "hi"}, {Role: "assistant", Content: "hello"}}) + item := dataset.Item{ID: "m1", DatasetID: "ds", Kind: dataset.KindMessages, Content: content} + editor := newDataItemEditor(item) + view := editor.View(80, 24, newUIStyles(midnightTheme())) + if !strings.Contains(view, "context only") { + t.Fatalf("messages-kind editor view does not flag the prompt field as context-only:\n%s", view) + } +} + +func TestDataItemEditor_Nil(t *testing.T) { + var editor *dataItemEditor + if got := editor.Update(keyMsg("a")); got != nil { + t.Fatalf("nil editor Update returned a non-nil cmd") + } + if prompt, response := editor.values(); prompt != "" || response != "" { + t.Fatalf("nil editor values = %q / %q", prompt, response) + } + if view := editor.View(80, 24, newUIStyles(midnightTheme())); view != "" { + t.Fatalf("nil editor view = %q", view) + } +} + +// ---- dataNoteOverlay ---- + +func TestDataNoteOverlay_RequiresNonEmptyValueToSubmit(t *testing.T) { + overlay := newDataNoteOverlay(dataActionTag, "item-1", "Tag", "Tag label", "label") + if overlay.Update(keyMsg("enter")) { + t.Fatal("empty note overlay reported a submit on Enter") + } + for _, r := range "favourite" { + overlay.Update(keyMsg(string(r))) + } + if !overlay.Update(keyMsg("enter")) { + t.Fatal("non-empty note overlay did not report a submit on Enter") + } + if got := overlay.Value(); got != "favourite" { + t.Fatalf("Value() = %q, want %q", got, "favourite") + } +} + +func TestDataNoteOverlay_WhitespaceOnlyDoesNotSubmit(t *testing.T) { + overlay := newDataNoteOverlay(dataActionQuarantineClear, "item-1", "Clear quarantine", "Why?", "note") + for _, r := range " " { + overlay.Update(keyMsg(string(r))) + } + if overlay.Update(keyMsg("enter")) { + t.Fatal("whitespace-only note overlay reported a submit on Enter") + } +} + +func TestDataNoteOverlay_Bulk(t *testing.T) { + single := newDataNoteOverlay(dataActionTag, "item-1", "Tag", "p", "ph") + if single.Bulk() { + t.Fatal("single-item note overlay reported Bulk() = true") + } + bulk := newDataNoteOverlay(dataActionTag, "", "Bulk tag", "p", "ph") + if !bulk.Bulk() { + t.Fatal("bulk note overlay (empty itemID) reported Bulk() = false") + } +} + +func TestDataNoteOverlay_Nil(t *testing.T) { + var overlay *dataNoteOverlay + if overlay.Update(keyMsg("enter")) { + t.Fatal("nil overlay reported a submit") + } + if overlay.Value() != "" { + t.Fatalf("nil overlay Value() = %q", overlay.Value()) + } + if overlay.Bulk() { + t.Fatal("nil overlay Bulk() = true") + } + if view := overlay.View(80, 24, newUIStyles(midnightTheme())); view != "" { + t.Fatalf("nil overlay view = %q", view) + } +} + +// ---- dataBulkOverlay: the count-confirmation gate ---- + +// TestDataBulkOverlay_TwoPhaseConfirm proves the exact gate the task brief +// requires: "no confirm, no writes". Confirm() is the ONLY signal +// app.confirmDataBulk ever acts on (see onOverlayKey's overlayDataBulk +// case), so this test is a direct proof that a single Enter — or any +// other key, including Escape's caller-side handling — never reports a +// confirm; only a SECOND Enter does. +func TestDataBulkOverlay_TwoPhaseConfirm(t *testing.T) { + overlay := newDataBulkOverlay(dataActionApprove, 42, "") + for _, key := range []string{"a", "j", "k", "tab", "q"} { + if overlay.Confirm(key) { + t.Fatalf("key %q reported a confirm before any Enter", key) + } + } + if overlay.armed { + t.Fatal("overlay armed itself without an Enter") + } + if overlay.Confirm("enter") { + t.Fatal("the FIRST enter reported a confirm — it must only arm") + } + if !overlay.armed { + t.Fatal("the first enter did not arm the overlay") + } + // Any non-enter key between the two enters must not consume the arm + // or falsely confirm. + if overlay.Confirm("j") { + t.Fatal("a non-enter key after arming reported a confirm") + } + if !overlay.Confirm("enter") { + t.Fatal("the SECOND enter did not report a confirm") + } +} + +func TestDataBulkOverlay_ViewShowsCountAndNote(t *testing.T) { + overlay := newDataBulkOverlay(dataActionQuarantineClear, 7, "false positive batch") + unarmed := overlay.View(80, 24, newUIStyles(midnightTheme())) + if !strings.Contains(unarmed, "7") || !strings.Contains(unarmed, "false positive batch") || !strings.Contains(unarmed, "continues") { + t.Fatalf("unarmed bulk view:\n%s", unarmed) + } + overlay.Confirm("enter") + armed := overlay.View(80, 24, newUIStyles(midnightTheme())) + if !strings.Contains(armed, "applies this action") { + t.Fatalf("armed bulk view did not change its prompt:\n%s", armed) + } +} + +func TestDataBulkOverlay_Nil(t *testing.T) { + var overlay *dataBulkOverlay + if overlay.Confirm("enter") { + t.Fatal("nil overlay reported a confirm") + } + if view := overlay.View(80, 24, newUIStyles(midnightTheme())); view != "" { + t.Fatalf("nil overlay view = %q", view) + } +} + +// ---- dataFilterOverlay ---- + +func TestDataFilterOverlay_EnterAlwaysSubmitsEvenEmpty(t *testing.T) { + overlay := newDataFilterOverlay("status=pending") + if got := overlay.Value(); got != "status=pending" { + t.Fatalf("pre-filled value = %q", got) + } + if !overlay.Update(keyMsg("enter")) { + t.Fatal("Enter did not report a submit") + } + // Clearing the field and submitting an empty value must still count + // as a submit — an empty filter is "show everything", a valid state. + cleared := newDataFilterOverlay("") + if !cleared.Update(keyMsg("enter")) { + t.Fatal("Enter on an empty filter overlay did not report a submit") + } +} + +func TestDataFilterOverlay_Nil(t *testing.T) { + var overlay *dataFilterOverlay + if overlay.Update(keyMsg("enter")) { + t.Fatal("nil overlay reported a submit") + } + if overlay.Value() != "" { + t.Fatalf("nil overlay Value() = %q", overlay.Value()) + } + if view := overlay.View(80, 24, newUIStyles(midnightTheme())); view != "" { + t.Fatalf("nil overlay view = %q", view) + } +} diff --git a/cli/tui/datapanel.go b/cli/tui/datapanel.go new file mode 100644 index 000000000..72c085daa --- /dev/null +++ b/cli/tui/datapanel.go @@ -0,0 +1,1097 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package tui + +import ( + "sort" + "time" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + core "dappco.re/go" + "dappco.re/go/inference/dataset" +) + +// dataAction names one review action the Data panel exposes — the design's +// "approve / reject / quarantine-clear / edit-as-derived / tag" list. +type dataAction uint8 + +const ( + dataActionApprove dataAction = iota + dataActionReject + dataActionQuarantineClear + dataActionEditAsDerived + dataActionTag +) + +// needsNote reports whether action requires a human-entered note/label +// before it can apply — quarantine-clear (a mandatory justification) and +// tag (the label itself). Approve/reject/edit-as-derived do not. +func (action dataAction) needsNote() bool { + switch action { + case dataActionQuarantineClear, dataActionTag: + return true + default: + return false + } +} + +func (action dataAction) title() string { + switch action { + case dataActionApprove: + return "Approve" + case dataActionReject: + return "Reject" + case dataActionQuarantineClear: + return "Clear quarantine" + case dataActionEditAsDerived: + return "Edit as derived" + case dataActionTag: + return "Tag" + default: + return "Unknown action" + } +} + +// dataSortMode is the list's sort key — the design's "sort by score/date". +type dataSortMode uint8 + +const ( + dataSortDate dataSortMode = iota + dataSortScore +) + +// dataFilterState narrows the Data panel's cross-dataset list, per the +// design's "filters by dataset / status / score expression / source". +// Every field's zero value means "any" — the same "narrowing dimension" +// convention dataset.ItemFilter itself uses. +type dataFilterState struct { + DatasetSlug string + Status dataset.ReviewStatus + Kind dataset.ItemKind + Source dataset.ItemSource + Score *dataset.ScoreExpression +} + +// Equal reports whether f and other narrow to the same filter — used by +// the filter round-trip tests; Score is a pointer, so it is compared by +// value, not identity. +func (f dataFilterState) Equal(other dataFilterState) bool { + if f.DatasetSlug != other.DatasetSlug || f.Status != other.Status || f.Kind != other.Kind || f.Source != other.Source { + return false + } + if (f.Score == nil) != (other.Score == nil) { + return false + } + if f.Score != nil && *f.Score != *other.Score { + return false + } + return true +} + +// String renders filter back in parseDataFilterExpr's grammar — the exact +// round trip the filter overlay pre-fills from and the list header shows. +func (f dataFilterState) String() string { + clauses := make([]string, 0, 5) + if f.DatasetSlug != "" { + clauses = append(clauses, "dataset="+f.DatasetSlug) + } + if f.Status != "" { + clauses = append(clauses, "status="+string(f.Status)) + } + if f.Kind != "" { + clauses = append(clauses, "kind="+string(f.Kind)) + } + if f.Source != "" { + clauses = append(clauses, "source="+string(f.Source)) + } + if f.Score != nil { + clauses = append(clauses, f.Score.String()) + } + return core.Join(",", clauses...) +} + +// parseDataFilterExpr parses the Data panel's filter grammar — the same +// tiny explicit grammar cli/data.go's --filter flag uses (comma-separated +// "field=value" clauses, or a bare dataset.ScoreExpression), plus +// "dataset=" to narrow the panel's own cross-dataset list. Duplicated +// rather than shared across the cli/tui <-> main package boundary (main is +// not importable, and the grammar is a CLI-shaped concern the root +// dataset package should not own) — kept intentionally tiny so the +// duplication stays cheap to eyeball against cli/data.go's parseItemFilter. +func parseDataFilterExpr(expr string) (dataFilterState, error) { + filter := dataFilterState{} + expr = core.Trim(expr) + if expr == "" { + return filter, nil + } + for _, clause := range core.Split(expr, ",") { + clause = core.Trim(clause) + if clause == "" { + continue + } + if key, value, ok := splitDataFilterClause(clause); ok { + switch key { + case "dataset": + filter.DatasetSlug = value + case "status": + filter.Status = dataset.ReviewStatus(value) + case "kind": + filter.Kind = dataset.ItemKind(value) + case "source": + filter.Source = dataset.ItemSource(value) + default: + return filter, core.NewError(core.Sprintf("tui: unknown filter field %q", key)) + } + continue + } + exprResult := dataset.ParseScoreExpression(clause) + if !exprResult.OK { + return filter, core.E("tui.parseDataFilterExpr", core.Sprintf("unrecognised filter clause %q", clause), exprResult.Err()) + } + parsed := exprResult.Value.(dataset.ScoreExpression) + filter.Score = &parsed + } + return filter, nil +} + +// splitDataFilterClause mirrors cli/data.go's splitFilterClause exactly: +// splits a "key=value" clause on the first bare '=', excluding >=/<=/!=/== +// so a bare score expression clause never matches. +func splitDataFilterClause(clause string) (key, value string, ok bool) { + idx := core.Index(clause, "=") + if idx < 0 { + return "", "", false + } + if idx > 0 { + switch clause[idx-1] { + case '>', '<', '!': + return "", "", false + } + } + if idx+1 < len(clause) && clause[idx+1] == '=' { + return "", "", false + } + return core.Trim(clause[:idx]), core.Trim(clause[idx+1:]), true +} + +// dataItemRow is one list row — an Item plus the denormalised context the +// list/detail panes render without a second round trip per keystroke: the +// owning Dataset (slug shown in the list, filtered on), the latest Review +// (status/kind/top-score columns), and the top score of the panel's +// score kind (ScoreKindLEK — the heuristic tier's composite, always +// computed first per the design). +type dataItemRow struct { + Item dataset.Item + Dataset dataset.Dataset + Review dataset.Review + TopScore dataset.Score + HasScore bool +} + +type dataListItem struct{ row dataItemRow } + +func (item dataListItem) Title() string { + score := "—" + if item.row.HasScore { + score = core.Sprintf("%.0f", item.row.TopScore.Value) + } + return core.Concat(dataStatusGlyph(item.row.Review.Status), " ", core.Upper(string(item.row.Review.Status)), " · ", string(item.row.Item.Kind), " · ", score) +} + +func (item dataListItem) Description() string { + detail := item.row.Dataset.Slug + if item.row.Item.Source != "" { + detail = core.Concat(detail, " · ", string(item.row.Item.Source)) + } + return detail +} + +func (item dataListItem) FilterValue() string { + return core.Concat( + item.row.Dataset.Slug, " ", string(item.row.Item.Kind), " ", string(item.row.Item.Source), " ", + string(item.row.Review.Status), " ", item.row.Item.SourceRef, + ) +} + +func dataStatusGlyph(status dataset.ReviewStatus) string { + switch status { + case dataset.StatusApproved: + return "✓" + case dataset.StatusRejected: + return "×" + case dataset.StatusQuarantined: + return "!" + default: + return "●" + } +} + +// dataPanel is the fifth primary TUI panel — the human review surface for +// the dataset loop (docs/superpowers/specs/2026-07-19-lem-dataset-loop- +// design.md, "Review surface (TUI)"). Data ONLY ever flows through store +// (a [dataset.Store], concretely [OpenDatasetStore]'s duckDatasetStore in +// production) — this panel never opens DuckDB directly, per the design. +type dataPanel struct { + store dataset.Store + markdown *markdownRenderer + ids func() string + now func() time.Time + + datasets []dataset.Dataset + rows []dataItemRow + filter dataFilterState + sort dataSortMode + + list list.Model +} + +// newDataPanel builds a ready Data panel over store — never nil; pass a +// [dataset.MemoryStore] for lightweight tests or a duckDatasetStore for +// full-conformance/integration tests, exactly as go/dataset's own test +// suite does. +func newDataPanel(store dataset.Store, markdown *markdownRenderer, ids func() string, now func() time.Time) core.Result { + if store == nil { + return core.Fail(core.E("tui.newDataPanel", "dataset store is required", nil)) + } + if ids == nil { + ids = newRecordID + } + if now == nil { + now = time.Now + } + model := list.New(nil, list.NewDefaultDelegate(), 48, 18) + model.Title = "Data" + model.SetShowStatusBar(false) + model.SetFilteringEnabled(true) + model.SetShowHelp(false) + panel := &dataPanel{store: store, markdown: markdown, ids: ids, now: now, list: model} + if result := panel.Refresh(); !result.OK { + return result + } + return core.Ok(panel) +} + +// Refresh reloads the dataset catalogue and the item list for the panel's +// current filter — items ACROSS every dataset matching filter.DatasetSlug +// (every dataset when empty), per the design's "items across datasets" +// list requirement. dataset.Store.Items requires one DatasetID per call +// (ItemFilter.DatasetID is mandatory), so a cross-dataset view means one +// Items call per matching dataset, merged and re-sorted here. +func (panel *dataPanel) Refresh() core.Result { + if panel == nil || panel.store == nil { + return core.Fail(core.E("tui.dataPanel.Refresh", "data panel is unavailable", nil)) + } + datasetsResult := panel.store.Datasets(false) + if !datasetsResult.OK { + return datasetsResult + } + datasets, ok := datasetsResult.Value.([]dataset.Dataset) + if !ok { + return core.Fail(core.E("tui.dataPanel.Refresh", "invalid datasets result", nil)) + } + panel.datasets = datasets + + rows := make([]dataItemRow, 0) + for _, ds := range datasets { + if panel.filter.DatasetSlug != "" && ds.Slug != panel.filter.DatasetSlug { + continue + } + itemsResult := panel.store.Items(dataset.ItemFilter{ + DatasetID: ds.ID, Kind: panel.filter.Kind, Source: panel.filter.Source, + Status: panel.filter.Status, Score: panel.filter.Score, + }) + if !itemsResult.OK { + return itemsResult + } + items, ok := itemsResult.Value.([]dataset.Item) + if !ok { + return core.Fail(core.E("tui.dataPanel.Refresh", "invalid items result", nil)) + } + for _, item := range items { + rows = append(rows, panel.buildRow(item, ds)) + } + } + sortDataRows(rows, panel.sort) + panel.rows = rows + panel.syncList() + return core.Ok(panel.rows) +} + +func (panel *dataPanel) buildRow(item dataset.Item, ds dataset.Dataset) dataItemRow { + row := dataItemRow{Item: item, Dataset: ds, Review: dataset.Review{Status: dataset.StatusPending}} + if reviewResult := panel.store.ReviewLatest(item.ID); reviewResult.OK { + if review, ok := reviewResult.Value.(dataset.Review); ok && review.Status != "" { + row.Review = review + } + } + if scoresResult := panel.store.Scores(item.ID); scoresResult.OK { + if scores, ok := scoresResult.Value.([]dataset.Score); ok { + if top, found := latestScoreOfKind(scores, dataset.ScoreKindLEK); found { + row.TopScore, row.HasScore = top, true + } + } + } + return row +} + +func latestScoreOfKind(scores []dataset.Score, kind dataset.ScoreKind) (dataset.Score, bool) { + found := false + var latest dataset.Score + for _, score := range scores { + if score.Kind != kind { + continue + } + if !found || !score.CreatedAt.Before(latest.CreatedAt) { + latest, found = score, true + } + } + return latest, found +} + +func sortDataRows(rows []dataItemRow, mode dataSortMode) { + sort.SliceStable(rows, func(i, j int) bool { + if mode == dataSortScore && rows[i].HasScore != rows[j].HasScore { + return rows[i].HasScore + } + if mode == dataSortScore && rows[i].HasScore && rows[i].TopScore.Value != rows[j].TopScore.Value { + return rows[i].TopScore.Value > rows[j].TopScore.Value + } + if !rows[i].Item.CreatedAt.Equal(rows[j].Item.CreatedAt) { + return rows[i].Item.CreatedAt.After(rows[j].Item.CreatedAt) + } + return rows[i].Item.ID < rows[j].Item.ID + }) +} + +func (panel *dataPanel) syncList() { + selectedID := "" + if selected, ok := panel.Selected(); ok { + selectedID = selected.Item.ID + } + items := make([]list.Item, 0, len(panel.rows)) + for _, row := range panel.rows { + items = append(items, dataListItem{row: row}) + } + panel.list.SetItems(items) + if selectedID != "" { + panel.selectItem(selectedID) + } +} + +func (panel *dataPanel) selectItem(id string) { + for index, raw := range panel.list.VisibleItems() { + item, ok := raw.(dataListItem) + if ok && item.row.Item.ID == id { + panel.list.Select(index) + return + } + } +} + +// Selected returns the list's current cursor row, if any. +func (panel *dataPanel) Selected() (dataItemRow, bool) { + if panel == nil { + return dataItemRow{}, false + } + item, ok := panel.list.SelectedItem().(dataListItem) + if !ok { + return dataItemRow{}, false + } + return item.row, true +} + +// Filter returns the panel's current structural filter. +func (panel *dataPanel) Filter() dataFilterState { + if panel == nil { + return dataFilterState{} + } + return panel.filter +} + +// SetFilter replaces the panel's structural filter and reloads the list. +func (panel *dataPanel) SetFilter(filter dataFilterState) core.Result { + if panel == nil { + return core.Fail(core.E("tui.dataPanel.SetFilter", "data panel is unavailable", nil)) + } + panel.filter = filter + return panel.Refresh() +} + +// FilterExpr renders the current filter in parseDataFilterExpr's grammar. +func (panel *dataPanel) FilterExpr() string { + if panel == nil { + return "" + } + return panel.filter.String() +} + +// SetFilterExpr parses expr and applies it as the panel's new filter — the +// inverse of FilterExpr, so FilterExpr()/SetFilterExpr() round-trip. +func (panel *dataPanel) SetFilterExpr(expr string) core.Result { + if panel == nil { + return core.Fail(core.E("tui.dataPanel.SetFilterExpr", "data panel is unavailable", nil)) + } + filter, err := parseDataFilterExpr(expr) + if err != nil { + return core.Fail(core.E("tui.dataPanel.SetFilterExpr", err.Error(), err)) + } + return panel.SetFilter(filter) +} + +// ToggleSort flips between date and score sort and re-orders the list in +// place (no store round trip — every row already carries its top score). +func (panel *dataPanel) ToggleSort() core.Result { + if panel == nil { + return core.Fail(core.E("tui.dataPanel.ToggleSort", "data panel is unavailable", nil)) + } + if panel.sort == dataSortDate { + panel.sort = dataSortScore + } else { + panel.sort = dataSortDate + } + sortDataRows(panel.rows, panel.sort) + panel.syncList() + return core.Ok(panel.sort) +} + +func (panel *dataPanel) SortMode() dataSortMode { + if panel == nil { + return dataSortDate + } + return panel.sort +} + +// FilteredCount is how many items the current filter matches — the count +// a bulk action's confirmation overlay names. +func (panel *dataPanel) FilteredCount() int { + if panel == nil { + return 0 + } + return len(panel.rows) +} + +func (panel *dataPanel) Update(message tea.Msg) tea.Cmd { + if panel == nil { + return nil + } + var command tea.Cmd + panel.list, command = panel.list.Update(message) + return command +} + +func (panel *dataPanel) View(width, height int, styles uiStyles) string { + if panel == nil || width <= 0 || height <= 0 { + return "" + } + panel.list.Styles.Title = styles.title + listWidth, listHeight, detailWidth, detailHeight := width, height, width, height + if width >= 100 { + listWidth = min(48, max(32, width/3)) + detailWidth = max(1, width-listWidth-1) + } else { + listHeight = max(4, height/2) + detailHeight = max(1, height-listHeight-1) + } + listView := panel.renderList(listWidth, listHeight, styles) + detailView := panel.renderDetail(detailWidth, detailHeight, styles) + var view string + if width >= 100 { + separator := fitPane("│", 1, height, styles.separator) + view = lipgloss.JoinHorizontal(lipgloss.Top, + fitPane(listView, listWidth, height, styles.panel), + separator, + fitPane(detailView, detailWidth, height, styles.panel), + ) + } else { + view = lipgloss.JoinVertical(lipgloss.Left, + fitPane(listView, listWidth, listHeight, styles.panel), + "", + fitPane(detailView, detailWidth, detailHeight, styles.panel), + ) + } + return fitPane(view, width, height, styles.panel) +} + +func (panel *dataPanel) renderList(width, height int, styles uiStyles) string { + builder := core.NewBuilder() + builder.WriteString(styles.title.Render("DATA")) + builder.WriteString(" ") + sortLabel := "date" + if panel.sort == dataSortScore { + sortLabel = "score" + } + builder.WriteString(styles.status.Render(core.Sprintf("%d items · sort %s", len(panel.rows), sortLabel))) + if filterExpr := panel.filter.String(); filterExpr != "" { + builder.WriteString(" ") + builder.WriteString(styles.thought.Render("filter " + filterExpr)) + } + builder.WriteString("\n") + if panel.list.SettingFilter() || panel.list.FilterState() == list.FilterApplied { + builder.WriteString(panel.list.FilterInput.View()) + builder.WriteString("\n") + } + visible := panel.list.VisibleItems() + if len(visible) == 0 { + builder.WriteString("\n") + builder.WriteString(styles.status.Render("○ No items match this filter")) + builder.WriteString("\n") + builder.WriteString(styles.thought.Render("Import or capture data with `lem data import` / `lem serve --capture`.")) + return fitPane(builder.String(), width, height, styles.panel) + } + for index, raw := range visible { + item, ok := raw.(dataListItem) + if !ok { + continue + } + cursor := " " + rowStyle := styles.answer + if index == panel.list.Index() { + cursor = "› " + rowStyle = styles.accent + } + score := "—" + if item.row.HasScore { + score = core.Sprintf("%.0f", item.row.TopScore.Value) + } + builder.WriteString(cursor) + builder.WriteString(styles.status.Render(dataStatusGlyph(item.row.Review.Status) + " " + core.Upper(string(item.row.Review.Status)))) + builder.WriteString(" ") + builder.WriteString(styles.thought.Render(string(item.row.Item.Kind) + " · " + score)) + builder.WriteString(" ") + builder.WriteString(rowStyle.Render(item.row.Dataset.Slug)) + builder.WriteString("\n") + } + builder.WriteString(styles.thought.Render("/ filter · j/k select · f filters · s sort · a/r/c/e/t act · A/R/C/T bulk")) + return fitPane(builder.String(), width, height, styles.panel) +} + +func (panel *dataPanel) renderDetail(width, height int, styles uiStyles) string { + row, ok := panel.Selected() + if !ok { + return styles.status.Render("Select an item for its content, scores, and lineage.") + } + builder := core.NewBuilder() + builder.WriteString(styles.title.Render(row.Dataset.Slug)) + builder.WriteString(" ") + builder.WriteString(styles.status.Render(dataStatusGlyph(row.Review.Status) + " " + core.Upper(string(row.Review.Status)))) + builder.WriteString("\n\n") + dataDetailRow(builder, styles, "kind", string(row.Item.Kind)) + dataDetailRow(builder, styles, "source", string(row.Item.Source)) + dataDetailRow(builder, styles, "source ref", row.Item.SourceRef) + dataDetailRow(builder, styles, "fingerprint", row.Item.ModelFingerprint) + dataDetailRow(builder, styles, "created", row.Item.CreatedAt.Format(time.RFC3339)) + + if row.Review.Reviewer == dataset.ReviewerAutoWelfare { + builder.WriteString("\n") + builder.WriteString(styles.attention.Render("! WELFARE FLAG")) + builder.WriteString("\n") + note := "the welfare screen quarantined this item at ingest" + if row.Review.Note != "" { + note = core.Concat(note, " — ", row.Review.Note) + } + builder.WriteString(styles.answer.Render(note)) + builder.WriteString("\n") + } + + builder.WriteString("\n") + builder.WriteString(styles.accent.Render("CONTENT")) + builder.WriteString("\n") + builder.WriteString(panel.renderContent(row.Item, width)) + builder.WriteString("\n") + + builder.WriteString("\n") + builder.WriteString(styles.accent.Render("SCORES")) + builder.WriteString("\n") + builder.WriteString(panel.renderScores(row.Item.ID, width)) + + builder.WriteString("\n") + builder.WriteString(styles.accent.Render("REVIEW")) + builder.WriteString("\n") + builder.WriteString(panel.renderReviewHistory(row.Item.ID, styles)) + + builder.WriteString("\n") + builder.WriteString(styles.accent.Render("LINEAGE")) + builder.WriteString("\n") + builder.WriteString(panel.renderLineage(row.Item, styles)) + + return fitPane(builder.String(), width, height, styles.panel) +} + +// renderContent renders an item's prompt/response (or, for a KindTrace +// item, its raw opaque JSON) through the same Glamour markdown path Chat +// uses for turns (markdown.go) — the design's "existing Glamour path". +func (panel *dataPanel) renderContent(item dataset.Item, width int) string { + prompt, response, ok := dataItemExchange(item) + if !ok { + return core.AsString(item.Content) + } + body := core.Concat("**Prompt**\n\n", prompt, "\n\n**Response**\n\n", response) + return panel.markdown.Render(item.ID, body, max(1, width-2)) +} + +// dataItemExchange extracts the (prompt, response) pair renderContent and +// the edit-as-derived editor both use: KindPair carries it directly; +// KindMessages reduces via MessagesContent.LastExchange (the same +// trailing-assistant-turn reduction go/dataset's own heuristic scorer and +// export writers use); KindTrace is opaque (ok=false). +func dataItemExchange(item dataset.Item) (prompt, response string, ok bool) { + switch item.Kind { + case dataset.KindPair: + var pc dataset.PairContent + if r := core.JSONUnmarshal(item.Content, &pc); !r.OK { + return "", "", false + } + return pc.Prompt, pc.Response, true + case dataset.KindMessages: + var mc dataset.MessagesContent + if r := core.JSONUnmarshal(item.Content, &mc); !r.OK { + return "", "", false + } + pc, found := mc.LastExchange() + if !found { + return "", "", false + } + return pc.Prompt, pc.Response, true + default: + return "", "", false + } +} + +// renderScores renders every Score row for itemID as Markdown — kind, +// value, scorer/judge identity, and timestamp, followed by the full +// payload pretty-printed in a fenced code block — the design's "full +// score breakdown from the Score payloads", generic across the heuristic +// kinds (lek/hostility/sycophancy) and any judge: kind without this +// panel special-casing a payload shape. +func (panel *dataPanel) renderScores(itemID string, width int) string { + scoresResult := panel.store.Scores(itemID) + if !scoresResult.OK { + return "○ scores unavailable: " + scoresResult.Error() + } + scores, ok := scoresResult.Value.([]dataset.Score) + if !ok || len(scores) == 0 { + return "○ not yet scored" + } + body := core.NewBuilder() + for _, score := range scores { + identity := score.ScorerName + if score.ScorerVersion != "" { + identity = core.Concat(identity, " v", score.ScorerVersion) + } + if identity == "" { + identity = "judge:" + score.JudgeFingerprint + } + body.WriteString(core.Sprintf("**%s** = %.2f — %s @ %s\n\n", score.Kind, score.Value, identity, score.CreatedAt.Format(time.RFC3339))) + body.WriteString("```json\n") + body.WriteString(prettyJSON(score.Payload)) + body.WriteString("\n```\n\n") + } + return panel.markdown.Render("scores:"+itemID, body.String(), max(1, width-2)) +} + +// prettyJSON re-indents raw JSON bytes for display, degrading to the raw +// bytes verbatim if they do not parse (never hides a malformed payload). +func prettyJSON(raw []byte) string { + if len(raw) == 0 { + return "{}" + } + var value any + if r := core.JSONUnmarshal(raw, &value); !r.OK { + return core.AsString(raw) + } + if r := core.JSONMarshalIndent(value, "", " "); r.OK { + return core.AsString(r.Bytes()) + } + return core.AsString(raw) +} + +// renderReviewHistory prefers the full append-only history (see +// [ReviewHistoryStore], a CLI-side optional capability duckDatasetStore +// implements) and falls back to ReviewLatest — the one call every +// dataset.Store implementation (including the root-module MemoryStore) +// supports — when the connected store does not expose it. +func (panel *dataPanel) renderReviewHistory(itemID string, styles uiStyles) string { + if historyStore, ok := panel.store.(ReviewHistoryStore); ok { + if historyResult := historyStore.ReviewHistory(itemID); historyResult.OK { + if history, ok := historyResult.Value.([]dataset.Review); ok && len(history) > 0 { + lines := make([]string, 0, len(history)) + for _, review := range history { + lines = append(lines, dataReviewLine(review, styles)) + } + return core.Join("\n", lines...) + "\n" + } + } + } + latestResult := panel.store.ReviewLatest(itemID) + if !latestResult.OK { + return styles.status.Render("○ review unavailable: " + latestResult.Error()) + } + latest, ok := latestResult.Value.(dataset.Review) + if !ok || latest.Status == "" || latest.Status == dataset.StatusPending { + return styles.status.Render("○ pending review") + } + return dataReviewLine(latest, styles) + "\n" +} + +func dataReviewLine(review dataset.Review, styles uiStyles) string { + line := styles.status.Render(core.Sprintf("· %-12s %-14s ", review.Status, review.Reviewer)) + + styles.thought.Render(review.CreatedAt.Format(time.RFC3339)) + if review.Note != "" { + line = core.Concat(line, "\n ", styles.answer.Render(review.Note)) + } + return line +} + +// renderLineage shows the item's parent (if edit-as-derived produced it) +// and any derived children — items in the SAME dataset whose +// ParentItemID names this one. dataset.Store has no "children of X" +// query, so children are found by scanning the dataset's full item list +// (including archived — the original is archived once superseded); an +// honest O(dataset size) scan, acceptable for a human review surface, not +// a hot path. +func (panel *dataPanel) renderLineage(item dataset.Item, styles uiStyles) string { + lines := make([]string, 0, 2) + if item.ParentItemID != "" { + if parentResult := panel.store.Item(item.ParentItemID); parentResult.OK { + if parent, ok := parentResult.Value.(dataset.Item); ok { + note := "" + if parent.Archived { + note = " (archived)" + } + lines = append(lines, styles.status.Render("parent ")+styles.answer.Render(parent.ID)+styles.thought.Render(note)) + } + } + } + if childrenResult := panel.store.Items(dataset.ItemFilter{DatasetID: item.DatasetID, IncludeArchived: true}); childrenResult.OK { + if siblings, ok := childrenResult.Value.([]dataset.Item); ok { + for _, candidate := range siblings { + if candidate.ParentItemID == item.ID { + lines = append(lines, styles.status.Render("derived ")+styles.answer.Render(candidate.ID)) + } + } + } + } + if len(lines) == 0 { + return styles.status.Render("○ no lineage — an original item") + } + return core.Join("\n", lines...) + "\n" +} + +func dataDetailRow(builder *core.Builder, styles uiStyles, label, value string) { + if value == "" { + return + } + builder.WriteString(styles.status.Render(label + " ")) + builder.WriteString(styles.answer.Render(value)) + builder.WriteString("\n") +} + +// currentReviewer identifies the human reviewer a manual Approve/Reject/ +// QuarantineClear/Tag action records — the OS username (falling back to +// "local" when it cannot be resolved), distinguishing manual review rows +// from the auto:welfare / auto:threshold reviewer identities the design +// reserves for automated ones. +func currentReviewer() string { + name := core.Trim(core.Username()) + if name == "" { + return "local" + } + return name +} + +// ---- actions ---- + +// Approve records an approved Review for itemID. +func (panel *dataPanel) Approve(itemID string) core.Result { + if result := panel.reviewNoRefresh(itemID, dataset.StatusApproved, ""); !result.OK { + return result + } + return panel.refreshAndSelect(itemID) +} + +// Reject records a rejected Review for itemID. +func (panel *dataPanel) Reject(itemID string) core.Result { + if result := panel.reviewNoRefresh(itemID, dataset.StatusRejected, ""); !result.OK { + return result + } + return panel.refreshAndSelect(itemID) +} + +// QuarantineClear approves a quarantined item with a mandatory +// justification note — the design's "quarantine-clear (requires a note)". +func (panel *dataPanel) QuarantineClear(itemID, note string) core.Result { + note = core.Trim(note) + if note == "" { + return core.Fail(core.E("tui.dataPanel.QuarantineClear", "a note is required to clear a quarantine", nil)) + } + if result := panel.reviewNoRefresh(itemID, dataset.StatusApproved, note); !result.OK { + return result + } + return panel.refreshAndSelect(itemID) +} + +// Tag records a freeform label on an item via a Review note — go/dataset +// carries no dedicated tag field (Task 1-7's shipped domain model has +// none, and widening it is out of this task's go/ lane; see +// datasetstore.go's "CLI-only capability extensions" for the same +// reasoning applied to item-archive/review-history), so a tag rides the +// same append-only Review channel notes already use, preserving the +// item's current review status rather than changing it — tagging is +// never itself a review decision. +func (panel *dataPanel) Tag(itemID, label string) core.Result { + label = core.Trim(label) + if label == "" { + return core.Fail(core.E("tui.dataPanel.Tag", "a tag label is required", nil)) + } + if panel == nil || panel.store == nil { + return core.Fail(core.E("tui.dataPanel.Tag", "data panel is unavailable", nil)) + } + trimmedID := core.Trim(itemID) + status := dataset.StatusPending + if latestResult := panel.store.ReviewLatest(trimmedID); latestResult.OK { + if latest, ok := latestResult.Value.(dataset.Review); ok && latest.Status != "" { + status = latest.Status + } + } + if result := panel.reviewNoRefresh(trimmedID, status, "tag: "+label); !result.OK { + return result + } + return panel.refreshAndSelect(trimmedID) +} + +func (panel *dataPanel) reviewNoRefresh(itemID string, status dataset.ReviewStatus, note string) core.Result { + if panel == nil || panel.store == nil { + return core.Fail(core.E("tui.dataPanel.review", "data panel is unavailable", nil)) + } + itemID = core.Trim(itemID) + if itemID == "" { + return core.Fail(core.E("tui.dataPanel.review", "an item id is required", nil)) + } + review := dataset.Review{ItemID: itemID, Status: status, Reviewer: currentReviewer(), Note: note, CreatedAt: panel.now().UTC()} + return panel.store.ReviewAppend(review) +} + +func (panel *dataPanel) refreshAndSelect(itemID string) core.Result { + if result := panel.Refresh(); !result.OK { + return result + } + panel.selectItem(core.Trim(itemID)) + return core.Ok(itemID) +} + +// dataEditedContent rebuilds an Item's Content with prompt/response +// substituted, per Kind: KindPair replaces both fields directly; a +// KindMessages item keeps every turn but the last assistant turn's +// content (the same reduction dataItemExchange used to populate the +// editor), so the derived item preserves multi-turn structure rather than +// collapsing it to a bare pair. KindTrace is opaque and not editable this +// way. +func dataEditedContent(item dataset.Item, prompt, response string) ([]byte, error) { + switch item.Kind { + case dataset.KindPair: + marshalled := core.JSONMarshal(dataset.PairContent{Prompt: prompt, Response: response}) + if !marshalled.OK { + return nil, marshalled.Err() + } + return marshalled.Bytes(), nil + case dataset.KindMessages: + var mc dataset.MessagesContent + if r := core.JSONUnmarshal(item.Content, &mc); !r.OK { + return nil, r.Err() + } + assistantIdx := -1 + for i := len(mc.Messages) - 1; i >= 0; i-- { + if mc.Messages[i].Role == "assistant" { + assistantIdx = i + break + } + } + if assistantIdx < 0 { + return nil, core.NewError("dataset: messages content has no assistant turn to edit") + } + edited := append([]dataset.MessageTurn(nil), mc.Messages...) + edited[assistantIdx].Content = response + marshalled := core.JSONMarshal(dataset.MessagesContent{Messages: edited}) + if !marshalled.OK { + return nil, marshalled.Err() + } + return marshalled.Bytes(), nil + default: + return nil, core.NewError("dataset: this item kind cannot be edited as a derived item") + } +} + +// EditAsDerived implements the design's edit-and-approve lineage flow +// exactly (go/dataset's own Item doc comment): create a derived Item +// (ParentItemID set, fresh content/content-hash), archive the original, +// approve the child. Requires the connected store to implement +// [ItemArchiver] — go/dataset's Store interface has no item-level archive +// (only DatasetArchive, one level up); a store that lacks it (e.g. the +// root-module dataset.MemoryStore in a lightweight test) fails loudly +// rather than silently skipping the archive step. +func (panel *dataPanel) EditAsDerived(original dataset.Item, prompt, response string) core.Result { + if panel == nil || panel.store == nil { + return core.Fail(core.E("tui.dataPanel.EditAsDerived", "data panel is unavailable", nil)) + } + archiver, ok := panel.store.(ItemArchiver) + if !ok { + return core.Fail(core.E("tui.dataPanel.EditAsDerived", "the connected dataset store cannot archive the superseded original", nil)) + } + content, contentErr := dataEditedContent(original, prompt, response) + if contentErr != nil { + return core.Fail(core.E("tui.dataPanel.EditAsDerived", contentErr.Error(), contentErr)) + } + hashResult := dataset.ContentHash(original.Kind, content) + if !hashResult.OK { + return hashResult + } + now := panel.now().UTC() + derived := dataset.Item{ + ID: panel.ids(), DatasetID: original.DatasetID, Kind: original.Kind, Content: content, + Source: original.Source, SourceRef: original.SourceRef, ModelFingerprint: original.ModelFingerprint, + ContentHash: hashResult.String(), ParentItemID: original.ID, CreatedAt: now, + } + if result := panel.store.ItemAppend(derived); !result.OK { + return result + } + if result := archiver.ItemArchive(original.ID); !result.OK { + return result + } + review := dataset.Review{ItemID: derived.ID, Status: dataset.StatusApproved, Reviewer: currentReviewer(), Note: "edited from " + original.ID, CreatedAt: now} + if result := panel.store.ReviewAppend(review); !result.OK { + return result + } + // Returns the full derived Item (not just its id, unlike + // refreshAndSelect's other callers) — the one action here whose + // result a caller plausibly wants to inspect (e.g. to render the new + // item immediately without a second Item() round trip). + if result := panel.Refresh(); !result.OK { + return result + } + panel.selectItem(derived.ID) + return core.Ok(derived) +} + +// BulkApply applies action to every item in the panel's CURRENT filtered +// view (panel.rows — every non-archived item matching filter, across every +// matching dataset), per the design's "bulk-apply-to-current-filter". The +// caller (the confirmation overlay) is the only gate: BulkApply itself +// performs no confirmation — "no confirm, no writes" is enforced by never +// calling this until the overlay's two-phase arm/confirm has completed. +// edit-as-derived has no bulk form (each item's edited text is unique; +// Capabilities() never offers it as a bulk action). +func (panel *dataPanel) BulkApply(action dataAction, note string) core.Result { + if panel == nil || panel.store == nil { + return core.Fail(core.E("tui.dataPanel.BulkApply", "data panel is unavailable", nil)) + } + if action == dataActionEditAsDerived { + return core.Fail(core.E("tui.dataPanel.BulkApply", "edit-as-derived has no bulk form", nil)) + } + note = core.Trim(note) + if action.needsNote() && note == "" { + return core.Fail(core.E("tui.dataPanel.BulkApply", "a note is required for this bulk action", nil)) + } + targets := append([]dataItemRow(nil), panel.rows...) + applied := 0 + for _, row := range targets { + var result core.Result + switch action { + case dataActionApprove: + result = panel.reviewNoRefresh(row.Item.ID, dataset.StatusApproved, note) + case dataActionReject: + result = panel.reviewNoRefresh(row.Item.ID, dataset.StatusRejected, note) + case dataActionQuarantineClear: + result = panel.reviewNoRefresh(row.Item.ID, dataset.StatusApproved, note) + case dataActionTag: + status := row.Review.Status + if status == "" { + status = dataset.StatusPending + } + result = panel.reviewNoRefresh(row.Item.ID, status, "tag: "+note) + default: + result = core.Fail(core.E("tui.dataPanel.BulkApply", "unknown bulk action", nil)) + } + if !result.OK { + _ = panel.Refresh() // best-effort resync before surfacing the partial-batch error + return core.Fail(core.E("tui.dataPanel.BulkApply", core.Sprintf("item %s: %s", row.Item.ID, result.Error()), result.Err())) + } + applied++ + } + if result := panel.Refresh(); !result.OK { + return result + } + return core.Ok(applied) +} + +// dataCapability mirrors the agentcap pattern (agentcap.go) for the Data +// panel's own action set: always present in the palette, Available/Reason +// render honestly rather than hiding an action outright. +type dataCapability struct { + Action dataAction + Bulk bool + Title string + Available bool + Reason string +} + +// Capabilities reports every action the palette should mirror — single- +// item actions against the current selection, plus their bulk-apply-to- +// filter counterparts (excluding edit-as-derived, which has no bulk +// form), always present but rendered unavailable with an honest reason +// when a precondition is not met. +func (panel *dataPanel) Capabilities() []dataCapability { + if panel == nil || panel.store == nil { + reason := "dataset store is not connected" + return []dataCapability{ + {Action: dataActionApprove, Title: "Approve", Reason: reason}, + {Action: dataActionReject, Title: "Reject", Reason: reason}, + {Action: dataActionQuarantineClear, Title: "Clear quarantine", Reason: reason}, + {Action: dataActionEditAsDerived, Title: "Edit as derived", Reason: reason}, + {Action: dataActionTag, Title: "Tag", Reason: reason}, + {Action: dataActionApprove, Bulk: true, Title: "Bulk approve", Reason: reason}, + {Action: dataActionReject, Bulk: true, Title: "Bulk reject", Reason: reason}, + {Action: dataActionQuarantineClear, Bulk: true, Title: "Bulk clear quarantine", Reason: reason}, + {Action: dataActionTag, Bulk: true, Title: "Bulk tag", Reason: reason}, + } + } + selected, hasSelection := panel.Selected() + _, archiver := panel.store.(ItemArchiver) + count := panel.FilteredCount() + + single := func(action dataAction, title string) dataCapability { + capability := dataCapability{Action: action, Title: title, Available: hasSelection} + if !hasSelection { + capability.Reason = "a selected item is required" + return capability + } + switch { + case action == dataActionQuarantineClear && selected.Review.Status != dataset.StatusQuarantined: + capability.Available, capability.Reason = false, "the selected item is not quarantined" + case action == dataActionEditAsDerived && !archiver: + capability.Available, capability.Reason = false, "the connected dataset store cannot archive the superseded original" + } + return capability + } + bulk := func(action dataAction, title string) dataCapability { + capability := dataCapability{Action: action, Bulk: true, Title: title, Available: count > 0} + if count == 0 { + capability.Reason = "no items match the current filter" + } + return capability + } + + return []dataCapability{ + single(dataActionApprove, "Approve"), + single(dataActionReject, "Reject"), + single(dataActionQuarantineClear, "Clear quarantine"), + single(dataActionEditAsDerived, "Edit as derived"), + single(dataActionTag, "Tag"), + bulk(dataActionApprove, "Bulk approve"), + bulk(dataActionReject, "Bulk reject"), + bulk(dataActionQuarantineClear, "Bulk clear quarantine"), + bulk(dataActionTag, "Bulk tag"), + } +} diff --git a/cli/tui/datapanel_test.go b/cli/tui/datapanel_test.go new file mode 100644 index 000000000..e423ea7b5 --- /dev/null +++ b/cli/tui/datapanel_test.go @@ -0,0 +1,685 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package tui + +import ( + "strings" + "testing" + "time" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + + core "dappco.re/go" + "dappco.re/go/inference/dataset" +) + +// ---- fixtures ---- + +func newTestDataPanelStore(t *testing.T) *duckDatasetStore { + t.Helper() + path := t.TempDir() + "/datasets.duckdb" + s := newTestDuckDatasetStore(t, path) + t.Cleanup(func() { closeTestDuckDatasetStore(t, s) }) + return s +} + +func seedDataDataset(t *testing.T, store dataset.Store, slug string, at time.Time) dataset.Dataset { + t.Helper() + ds := dataset.Dataset{ID: dataset.NewID(), Slug: slug, Title: slug, CreatedAt: at} + if result := store.DatasetCreate(ds); !result.OK { + t.Fatalf("DatasetCreate(%s): %v", slug, result.Value) + } + return ds +} + +func seedDataItem(t *testing.T, store dataset.Store, datasetID, prompt, response string, at time.Time) dataset.Item { + t.Helper() + item := conformancePairItem(datasetID, prompt, response, at) + if result := store.ItemAppend(item); !result.OK { + t.Fatalf("ItemAppend: %v", result.Value) + } + return item +} + +func seedDataScore(t *testing.T, store dataset.Store, itemID string, value float64, at time.Time) dataset.Score { + t.Helper() + score := dataset.Score{ID: dataset.NewID(), ItemID: itemID, Kind: dataset.ScoreKindLEK, Value: value, ScorerName: "test", ScorerVersion: "1", CreatedAt: at} + if result := store.ScoreAppend(score); !result.OK { + t.Fatalf("ScoreAppend: %v", result.Value) + } + return score +} + +// ---- construction / state machine ---- + +func TestNewDataPanel_Good(t *testing.T) { + store := newTestDataPanelStore(t) + at := time.Date(2026, time.July, 19, 9, 0, 0, 0, time.UTC) + ds := seedDataDataset(t, store, "evening-vents", at) + seedDataItem(t, store, ds.ID, "hello", "hi there", at) + seedDataItem(t, store, ds.ID, "second", "second reply", at.Add(time.Minute)) + + opened := newDataPanel(store, nil, sequenceIDs("unused"), func() time.Time { return at }) + if !opened.OK { + t.Fatalf("newDataPanel: %v", opened.Value) + } + panel := opened.Value.(*dataPanel) + if len(panel.rows) != 2 { + t.Fatalf("rows = %d, want 2", len(panel.rows)) + } + if panel.rows[0].Item.CreatedAt.Before(panel.rows[1].Item.CreatedAt) { + t.Fatalf("default sort is not newest-first: %#v", panel.rows) + } + for _, row := range panel.rows { + if row.Review.Status != dataset.StatusPending { + t.Fatalf("fresh item review status = %q, want pending", row.Review.Status) + } + if row.HasScore { + t.Fatalf("fresh item unexpectedly scored: %#v", row) + } + } +} + +func TestNewDataPanel_Bad(t *testing.T) { + if opened := newDataPanel(nil, nil, nil, nil); opened.OK { + t.Fatal("newDataPanel(nil store) succeeded") + } +} + +func TestDataPanel_SelectionPreservedAcrossRefresh(t *testing.T) { + store := newTestDataPanelStore(t) + at := time.Now() + ds := seedDataDataset(t, store, "vents", at) + first := seedDataItem(t, store, ds.ID, "first", "first", at) + seedDataItem(t, store, ds.ID, "second", "second", at.Add(time.Second)) + + opened := newDataPanel(store, nil, nil, nil) + if !opened.OK { + t.Fatalf("newDataPanel: %v", opened.Value) + } + panel := opened.Value.(*dataPanel) + panel.selectItem(first.ID) + if selected, ok := panel.Selected(); !ok || selected.Item.ID != first.ID { + t.Fatalf("selected = %#v ok=%v", selected, ok) + } + if result := panel.Refresh(); !result.OK { + t.Fatalf("Refresh: %v", result.Value) + } + if selected, ok := panel.Selected(); !ok || selected.Item.ID != first.ID { + t.Fatalf("selection lost after refresh: %#v ok=%v", selected, ok) + } +} + +func TestDataPanel_ToggleSort(t *testing.T) { + store := newTestDataPanelStore(t) + at := time.Date(2026, time.July, 19, 9, 0, 0, 0, time.UTC) + ds := seedDataDataset(t, store, "vents", at) + // Deliberately seeded so date order and score order disagree: "low" is + // newer (date-sorts first) but scores lower (score-sorts second). + low := seedDataItem(t, store, ds.ID, "low", "low", at.Add(time.Minute)) + high := seedDataItem(t, store, ds.ID, "high", "high", at) + seedDataScore(t, store, low.ID, 10, at) + seedDataScore(t, store, high.ID, 90, at) + + opened := newDataPanel(store, nil, nil, nil) + if !opened.OK { + t.Fatalf("newDataPanel: %v", opened.Value) + } + panel := opened.Value.(*dataPanel) + if panel.SortMode() != dataSortDate { + t.Fatalf("default sort = %d, want dataSortDate", panel.SortMode()) + } + if panel.rows[0].Item.ID != low.ID { + t.Fatalf("date sort rows = %#v, want low (newer) first", panel.rows) + } + if result := panel.ToggleSort(); !result.OK { + t.Fatalf("ToggleSort: %v", result.Value) + } + if panel.SortMode() != dataSortScore || panel.rows[0].Item.ID != high.ID { + t.Fatalf("score sort mode=%d rows=%#v, want dataSortScore with high first", panel.SortMode(), panel.rows) + } + if result := panel.ToggleSort(); !result.OK { + t.Fatalf("ToggleSort back: %v", result.Value) + } + if panel.SortMode() != dataSortDate || panel.rows[0].Item.ID != low.ID { + t.Fatalf("sort after second toggle = %d rows=%#v", panel.SortMode(), panel.rows) + } +} + +// ---- filter grammar + round-trips ---- + +func TestParseDataFilterExpr_Good(t *testing.T) { + for _, tc := range []struct { + name string + expr string + want dataFilterState + }{ + {"empty", "", dataFilterState{}}, + {"dataset", "dataset=evening-vents", dataFilterState{DatasetSlug: "evening-vents"}}, + {"status", "status=approved", dataFilterState{Status: dataset.StatusApproved}}, + {"kind", "kind=pair", dataFilterState{Kind: dataset.KindPair}}, + {"source", "source=capture:serve", dataFilterState{Source: dataset.SourceCaptureServe}}, + {"score", "lek>=80", dataFilterState{Score: &dataset.ScoreExpression{Kind: dataset.ScoreKindLEK, Op: dataset.OpGTE, Threshold: 80}}}, + {"combined", "dataset=evening-vents,status=pending,kind=pair,source=capture:serve,lek>=80", dataFilterState{ + DatasetSlug: "evening-vents", Status: dataset.StatusPending, Kind: dataset.KindPair, Source: dataset.SourceCaptureServe, + Score: &dataset.ScoreExpression{Kind: dataset.ScoreKindLEK, Op: dataset.OpGTE, Threshold: 80}, + }}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := parseDataFilterExpr(tc.expr) + if err != nil { + t.Fatalf("parseDataFilterExpr(%q): %v", tc.expr, err) + } + if !got.Equal(tc.want) { + t.Fatalf("parseDataFilterExpr(%q) = %#v, want %#v", tc.expr, got, tc.want) + } + }) + } +} + +func TestParseDataFilterExpr_Bad(t *testing.T) { + for _, expr := range []string{"unknownfield=x", "notascoreexpr", "lek>=notanumber"} { + if _, err := parseDataFilterExpr(expr); err == nil { + t.Fatalf("parseDataFilterExpr(%q) succeeded, want an error", expr) + } + } +} + +func TestParseDataFilterExpr_Ugly(t *testing.T) { + // Blank/whitespace-only clauses between commas are tolerated (skipped), + // mirroring cli/data.go's parseItemFilter idiom. + got, err := parseDataFilterExpr(" , status=approved ,, ") + if err != nil { + t.Fatalf("parseDataFilterExpr: %v", err) + } + if got.Status != dataset.StatusApproved { + t.Fatalf("got = %#v", got) + } +} + +func TestDataFilterState_StringRoundTrip(t *testing.T) { + score := dataset.ScoreExpression{Kind: dataset.ScoreKindHostility, Op: dataset.OpLT, Threshold: 0.5} + original := dataFilterState{DatasetSlug: "vents", Status: dataset.StatusQuarantined, Kind: dataset.KindMessages, Source: dataset.SourceImportJSONL, Score: &score} + roundTripped, err := parseDataFilterExpr(original.String()) + if err != nil { + t.Fatalf("parseDataFilterExpr(%q): %v", original.String(), err) + } + if !roundTripped.Equal(original) { + t.Fatalf("round trip = %#v, want %#v (expr %q)", roundTripped, original, original.String()) + } +} + +func TestDataPanel_SetFilterExpr_RoundTrip(t *testing.T) { + store := newTestDataPanelStore(t) + at := time.Now() + dsA := seedDataDataset(t, store, "alpha", at) + dsB := seedDataDataset(t, store, "beta", at) + seedDataItem(t, store, dsA.ID, "a-prompt", "a-response", at) + seedDataItem(t, store, dsB.ID, "b-prompt", "b-response", at) + + opened := newDataPanel(store, nil, nil, nil) + if !opened.OK { + t.Fatalf("newDataPanel: %v", opened.Value) + } + panel := opened.Value.(*dataPanel) + if len(panel.rows) != 2 { + t.Fatalf("unfiltered rows = %d, want 2", len(panel.rows)) + } + + if result := panel.SetFilterExpr("dataset=alpha"); !result.OK { + t.Fatalf("SetFilterExpr: %v", result.Value) + } + if len(panel.rows) != 1 || panel.rows[0].Dataset.Slug != "alpha" { + t.Fatalf("filtered rows = %#v", panel.rows) + } + if got := panel.FilterExpr(); got != "dataset=alpha" { + t.Fatalf("FilterExpr() = %q, want %q", got, "dataset=alpha") + } + // The exact round trip: re-apply the panel's own rendered expression. + if result := panel.SetFilterExpr(panel.FilterExpr()); !result.OK { + t.Fatalf("SetFilterExpr(FilterExpr()): %v", result.Value) + } + if len(panel.rows) != 1 || panel.rows[0].Dataset.Slug != "alpha" { + t.Fatalf("round-tripped filter rows = %#v", panel.rows) + } + + if result := panel.SetFilterExpr(""); !result.OK { + t.Fatalf("SetFilterExpr(\"\"): %v", result.Value) + } + if len(panel.rows) != 2 { + t.Fatalf("cleared filter rows = %d, want 2", len(panel.rows)) + } +} + +func TestDataPanel_SetFilterExpr_Bad(t *testing.T) { + store := newTestDataPanelStore(t) + opened := newDataPanel(store, nil, nil, nil) + panel := opened.Value.(*dataPanel) + before := panel.Filter() + if result := panel.SetFilterExpr("garbage clause"); result.OK { + t.Fatal("SetFilterExpr(garbage) succeeded") + } + if !panel.Filter().Equal(before) { + t.Fatalf("a rejected filter expression mutated the panel's filter: %#v", panel.Filter()) + } +} + +// ---- actions ---- + +func TestDataPanel_Approve(t *testing.T) { + store := newTestDataPanelStore(t) + at := time.Now() + ds := seedDataDataset(t, store, "vents", at) + item := seedDataItem(t, store, ds.ID, "p", "r", at) + opened := newDataPanel(store, nil, nil, func() time.Time { return at }) + panel := opened.Value.(*dataPanel) + + if result := panel.Approve(item.ID); !result.OK { + t.Fatalf("Approve: %v", result.Value) + } + latest := core.MustCast[dataset.Review](store.ReviewLatest(item.ID)) + if latest.Status != dataset.StatusApproved || latest.Reviewer == "" { + t.Fatalf("approved review = %#v", latest) + } +} + +func TestDataPanel_Reject(t *testing.T) { + store := newTestDataPanelStore(t) + at := time.Now() + ds := seedDataDataset(t, store, "vents", at) + item := seedDataItem(t, store, ds.ID, "p", "r", at) + opened := newDataPanel(store, nil, nil, func() time.Time { return at }) + panel := opened.Value.(*dataPanel) + + if result := panel.Reject(item.ID); !result.OK { + t.Fatalf("Reject: %v", result.Value) + } + latest := core.MustCast[dataset.Review](store.ReviewLatest(item.ID)) + if latest.Status != dataset.StatusRejected { + t.Fatalf("rejected review = %#v", latest) + } +} + +func TestDataPanel_QuarantineClear_Good(t *testing.T) { + store := newTestDataPanelStore(t) + at := time.Now() + ds := seedDataDataset(t, store, "vents", at) + item := seedDataItem(t, store, ds.ID, "p", "r", at) + if result := store.ReviewAppend(dataset.Review{ItemID: item.ID, Status: dataset.StatusQuarantined, Reviewer: dataset.ReviewerAutoWelfare, Note: "slur match", CreatedAt: at}); !result.OK { + t.Fatalf("seed quarantine: %v", result.Value) + } + opened := newDataPanel(store, nil, nil, func() time.Time { return at.Add(time.Minute) }) + panel := opened.Value.(*dataPanel) + + if result := panel.QuarantineClear(item.ID, "reviewed: clinical context, not a real hit"); !result.OK { + t.Fatalf("QuarantineClear: %v", result.Value) + } + latest := core.MustCast[dataset.Review](store.ReviewLatest(item.ID)) + if latest.Status != dataset.StatusApproved || !core.Contains(latest.Note, "clinical context") { + t.Fatalf("cleared review = %#v", latest) + } +} + +func TestDataPanel_QuarantineClear_Bad(t *testing.T) { + store := newTestDataPanelStore(t) + at := time.Now() + ds := seedDataDataset(t, store, "vents", at) + item := seedDataItem(t, store, ds.ID, "p", "r", at) + opened := newDataPanel(store, nil, nil, func() time.Time { return at }) + panel := opened.Value.(*dataPanel) + + if result := panel.QuarantineClear(item.ID, " "); result.OK { + t.Fatal("QuarantineClear with a blank note succeeded") + } + if result := panel.QuarantineClear(item.ID, ""); result.OK { + t.Fatal("QuarantineClear with an empty note succeeded") + } + reviews := core.MustCast[[]dataset.Review](store.ReviewHistory(item.ID)) + if len(reviews) != 0 { + t.Fatalf("a note-less QuarantineClear wrote a review: %#v", reviews) + } +} + +func TestDataPanel_Tag_Good(t *testing.T) { + store := newTestDataPanelStore(t) + at := time.Now() + ds := seedDataDataset(t, store, "vents", at) + item := seedDataItem(t, store, ds.ID, "p", "r", at) + opened := newDataPanel(store, nil, nil, func() time.Time { return at }) + panel := opened.Value.(*dataPanel) + + if result := panel.Approve(item.ID); !result.OK { + t.Fatalf("Approve: %v", result.Value) + } + if result := panel.Tag(item.ID, "favourite"); !result.OK { + t.Fatalf("Tag: %v", result.Value) + } + latest := core.MustCast[dataset.Review](store.ReviewLatest(item.ID)) + if latest.Status != dataset.StatusApproved || latest.Note != "tag: favourite" { + t.Fatalf("tag review = %#v, want status preserved + tag note", latest) + } +} + +func TestDataPanel_Tag_Bad(t *testing.T) { + store := newTestDataPanelStore(t) + at := time.Now() + ds := seedDataDataset(t, store, "vents", at) + item := seedDataItem(t, store, ds.ID, "p", "r", at) + opened := newDataPanel(store, nil, nil, func() time.Time { return at }) + panel := opened.Value.(*dataPanel) + + if result := panel.Tag(item.ID, " "); result.OK { + t.Fatal("Tag with a blank label succeeded") + } + reviews := core.MustCast[[]dataset.Review](store.ReviewHistory(item.ID)) + if len(reviews) != 0 { + t.Fatalf("a blank-label Tag wrote a review: %#v", reviews) + } +} + +func TestDataPanel_EditAsDerived_Good(t *testing.T) { + store := newTestDataPanelStore(t) + at := time.Date(2026, time.July, 19, 9, 0, 0, 0, time.UTC) + ds := seedDataDataset(t, store, "vents", at) + original := seedDataItem(t, store, ds.ID, "original prompt", "original response", at) + + opened := newDataPanel(store, nil, sequenceIDs("derived-1"), func() time.Time { return at.Add(time.Hour) }) + panel := opened.Value.(*dataPanel) + + result := panel.EditAsDerived(original, "original prompt", "edited response") + if !result.OK { + t.Fatalf("EditAsDerived: %v", result.Value) + } + derived := result.Value.(dataset.Item) + if derived.ID != "derived-1" || derived.ParentItemID != original.ID || derived.DatasetID != ds.ID || derived.ContentHash == original.ContentHash { + t.Fatalf("derived item = %#v", derived) + } + _, editedResponse, ok := dataItemExchange(derived) + if !ok || editedResponse != "edited response" { + t.Fatalf("derived content = %s, ok=%v", derived.Content, ok) + } + + archived := core.MustCast[dataset.Item](store.Item(original.ID)) + if !archived.Archived { + t.Fatalf("original was not archived: %#v", archived) + } + review := core.MustCast[dataset.Review](store.ReviewLatest(derived.ID)) + if review.Status != dataset.StatusApproved { + t.Fatalf("derived review = %#v", review) + } +} + +func TestDataPanel_EditAsDerived_Bad(t *testing.T) { + // dataset.MemoryStore does not implement ItemArchiver — EditAsDerived + // must refuse loudly rather than create a derived item it cannot + // finish superseding the original for. + store := dataset.NewMemoryStore() + at := time.Now() + ds := dataset.Dataset{ID: dataset.NewID(), Slug: "vents", Title: "vents", CreatedAt: at} + if result := store.DatasetCreate(ds); !result.OK { + t.Fatalf("DatasetCreate: %v", result.Value) + } + original := conformancePairItem(ds.ID, "p", "r", at) + if result := store.ItemAppend(original); !result.OK { + t.Fatalf("ItemAppend: %v", result.Value) + } + + opened := newDataPanel(store, nil, nil, nil) + panel := opened.Value.(*dataPanel) + + if result := panel.EditAsDerived(original, "p", "edited"); result.OK { + t.Fatal("EditAsDerived succeeded against a store without ItemArchiver") + } + items := core.MustCast[[]dataset.Item](store.Items(dataset.ItemFilter{DatasetID: ds.ID, IncludeArchived: true})) + if len(items) != 1 { + t.Fatalf("EditAsDerived left extra items behind: %#v", items) + } + if items[0].Archived { + t.Fatal("EditAsDerived archived the original despite failing") + } +} + +// ---- bulk apply ---- + +func TestDataPanel_BulkApply_Good(t *testing.T) { + store := newTestDataPanelStore(t) + at := time.Now() + ds := seedDataDataset(t, store, "vents", at) + itemA := seedDataItem(t, store, ds.ID, "a", "a", at) + itemB := seedDataItem(t, store, ds.ID, "b", "b", at.Add(time.Second)) + itemC := seedDataItem(t, store, ds.ID, "c", "c", at.Add(2*time.Second)) + + opened := newDataPanel(store, nil, nil, func() time.Time { return at }) + panel := opened.Value.(*dataPanel) + if len(panel.rows) != 3 { + t.Fatalf("seeded rows = %d, want 3", len(panel.rows)) + } + + result := panel.BulkApply(dataActionApprove, "") + if !result.OK { + t.Fatalf("BulkApply approve: %v", result.Value) + } + if applied := result.Value.(int); applied != 3 { + t.Fatalf("BulkApply applied = %d, want 3", applied) + } + for _, item := range []dataset.Item{itemA, itemB, itemC} { + review := core.MustCast[dataset.Review](store.ReviewLatest(item.ID)) + if review.Status != dataset.StatusApproved { + t.Fatalf("item %s status = %q, want approved", item.ID, review.Status) + } + } +} + +func TestDataPanel_BulkApply_Good_TagPreservesStatus(t *testing.T) { + store := newTestDataPanelStore(t) + at := time.Now() + ds := seedDataDataset(t, store, "vents", at) + approved := seedDataItem(t, store, ds.ID, "a", "a", at) + pending := seedDataItem(t, store, ds.ID, "b", "b", at.Add(time.Second)) + if result := store.ReviewAppend(dataset.Review{ItemID: approved.ID, Status: dataset.StatusApproved, Reviewer: "snider", CreatedAt: at}); !result.OK { + t.Fatalf("seed approved: %v", result.Value) + } + + opened := newDataPanel(store, nil, nil, func() time.Time { return at.Add(time.Hour) }) + panel := opened.Value.(*dataPanel) + + if result := panel.BulkApply(dataActionTag, "batch-1"); !result.OK { + t.Fatalf("BulkApply tag: %v", result.Value) + } + approvedReview := core.MustCast[dataset.Review](store.ReviewLatest(approved.ID)) + if approvedReview.Status != dataset.StatusApproved || approvedReview.Note != "tag: batch-1" { + t.Fatalf("approved item after bulk tag = %#v, want status preserved", approvedReview) + } + pendingReview := core.MustCast[dataset.Review](store.ReviewLatest(pending.ID)) + if pendingReview.Status != dataset.StatusPending || pendingReview.Note != "tag: batch-1" { + t.Fatalf("pending item after bulk tag = %#v, want status preserved", pendingReview) + } +} + +func TestDataPanel_BulkApply_Bad(t *testing.T) { + store := newTestDataPanelStore(t) + at := time.Now() + ds := seedDataDataset(t, store, "vents", at) + original := seedDataItem(t, store, ds.ID, "a", "a", at) + + opened := newDataPanel(store, nil, nil, nil) + panel := opened.Value.(*dataPanel) + + if result := panel.BulkApply(dataActionEditAsDerived, ""); result.OK { + t.Fatal("BulkApply(edit-as-derived) succeeded — it has no bulk form") + } + if result := panel.BulkApply(dataActionTag, " "); result.OK { + t.Fatal("BulkApply(tag) with a blank note succeeded") + } + if result := panel.BulkApply(dataActionQuarantineClear, ""); result.OK { + t.Fatal("BulkApply(quarantine-clear) with an empty note succeeded") + } + reviews := core.MustCast[[]dataset.Review](store.ReviewHistory(original.ID)) + if len(reviews) != 0 { + t.Fatalf("a rejected bulk call wrote a review: %#v", reviews) + } +} + +// ---- capabilities (the agentcap pattern, honest unavailable states) ---- + +func TestDataPanel_Capabilities_Good(t *testing.T) { + store := newTestDataPanelStore(t) + at := time.Now() + ds := seedDataDataset(t, store, "vents", at) + item := seedDataItem(t, store, ds.ID, "p", "r", at) + opened := newDataPanel(store, nil, nil, nil) + panel := opened.Value.(*dataPanel) + panel.selectItem(item.ID) + + byKey := dataCapabilityIndex(panel.Capabilities()) + if !byKey["approve"].Available { + t.Fatalf("approve unavailable with a selection: %#v", byKey["approve"]) + } + if byKey["quarantine-clear"].Available { + t.Fatalf("quarantine-clear available on a pending item: %#v", byKey["quarantine-clear"]) + } + if !byKey["edit-as-derived"].Available { + t.Fatalf("edit-as-derived unavailable against a real ItemArchiver store: %#v", byKey["edit-as-derived"]) + } + if !byKey["bulk.approve"].Available { + t.Fatalf("bulk approve unavailable with 1 filtered item: %#v", byKey["bulk.approve"]) + } + + // Clearing the quarantine flag flips availability the other way. + if result := store.ReviewAppend(dataset.Review{ItemID: item.ID, Status: dataset.StatusQuarantined, Reviewer: dataset.ReviewerAutoWelfare, CreatedAt: at}); !result.OK { + t.Fatalf("seed quarantine: %v", result.Value) + } + if result := panel.Refresh(); !result.OK { + t.Fatalf("Refresh: %v", result.Value) + } + panel.selectItem(item.ID) + byKey = dataCapabilityIndex(panel.Capabilities()) + if !byKey["quarantine-clear"].Available { + t.Fatalf("quarantine-clear unavailable on a quarantined item: %#v", byKey["quarantine-clear"]) + } +} + +func TestDataPanel_Capabilities_Bad(t *testing.T) { + var nilPanel *dataPanel + for _, capability := range nilPanel.Capabilities() { + if capability.Available || capability.Reason == "" { + t.Fatalf("nil-panel capability rendered available or reasonless: %#v", capability) + } + } + + store := dataset.NewMemoryStore() + opened := newDataPanel(store, nil, nil, nil) + empty := opened.Value.(*dataPanel) + for _, capability := range empty.Capabilities() { + if capability.Bulk { + if capability.Available { + t.Fatalf("bulk %#v available with zero filtered items", capability) + } + continue + } + if capability.Available { + t.Fatalf("single-item %#v available with no selection", capability) + } + } + + at := time.Now() + ds := dataset.Dataset{ID: dataset.NewID(), Slug: "vents", Title: "vents", CreatedAt: at} + if result := store.DatasetCreate(ds); !result.OK { + t.Fatalf("DatasetCreate: %v", result.Value) + } + item := conformancePairItem(ds.ID, "p", "r", at) + if result := store.ItemAppend(item); !result.OK { + t.Fatalf("ItemAppend: %v", result.Value) + } + if result := empty.Refresh(); !result.OK { + t.Fatalf("Refresh: %v", result.Value) + } + empty.selectItem(item.ID) + byKey := dataCapabilityIndex(empty.Capabilities()) + if byKey["edit-as-derived"].Available { + t.Fatalf("edit-as-derived available against a MemoryStore-backed panel: %#v", byKey["edit-as-derived"]) + } +} + +func dataCapabilityIndex(capabilities []dataCapability) map[string]dataCapability { + byKey := make(map[string]dataCapability, len(capabilities)) + for _, capability := range capabilities { + key := dataActionSlug(capability.Action) + if capability.Bulk { + key = "bulk." + key + } + byKey[key] = capability + } + return byKey +} + +// ---- render snapshots (the layout-test idiom: structural assertions + +// a hard width bound, not literal golden files) ---- + +func TestDataPanel_View_Ugly(t *testing.T) { + store := newTestDataPanelStore(t) + at := time.Date(2026, time.July, 19, 9, 0, 0, 0, time.UTC) + ds := seedDataDataset(t, store, "evening-vents", at) + item := seedDataItem(t, store, ds.ID, "hello there", "hi general kenobi", at) + seedDataScore(t, store, item.ID, 87, at) + if result := store.ReviewAppend(dataset.Review{ItemID: item.ID, Status: dataset.StatusQuarantined, Reviewer: dataset.ReviewerAutoWelfare, Note: "flagged", CreatedAt: at}); !result.OK { + t.Fatalf("seed quarantine: %v", result.Value) + } + + opened := newDataPanel(store, newMarkdownRenderer("dark"), nil, nil) + if !opened.OK { + t.Fatalf("newDataPanel: %v", opened.Value) + } + panel := opened.Value.(*dataPanel) + panel.selectItem(item.ID) + + // height=90: renderDetail (like workPanel.renderDetail) has no + // internal viewport/scroll — a short pane height clips the tail of the + // detail pane, exactly as it does for the Work panel, so this needs + // enough room to fit every section for the presence assertions below + // to be meaningful. The width-overflow bound is independent of that + // and holds regardless of height. + styles := newUIStyles(midnightTheme()) + for _, width := range []int{72, 132} { + view := panel.View(width, 90, styles) + plain := ansi.Strip(view) + for _, want := range []string{ + "DATA", "QUARANTINED", "evening-vents", "pair", + "CONTENT", "hello there", "hi general kenobi", + "SCORES", "lek", "REVIEW", "WELFARE FLAG", "LINEAGE", "no lineage", + } { + if !strings.Contains(plain, want) { + t.Fatalf("width %d missing %q:\n%s", width, want, plain) + } + } + for line, text := range strings.Split(view, "\n") { + if got := lipgloss.Width(text); got > width { + t.Fatalf("width %d line %d overflows at %d: %q", width, line, got, text) + } + } + } +} + +func TestDataPanel_View_Empty(t *testing.T) { + store := dataset.NewMemoryStore() + opened := newDataPanel(store, nil, nil, nil) + panel := opened.Value.(*dataPanel) + view := panel.View(100, 24, newUIStyles(midnightTheme())) + if !strings.Contains(view, "No items match this filter") || !strings.Contains(view, "Select an item") { + t.Fatalf("empty view:\n%s", view) + } +} + +func TestDataPanel_View_ZeroSize(t *testing.T) { + store := dataset.NewMemoryStore() + opened := newDataPanel(store, nil, nil, nil) + panel := opened.Value.(*dataPanel) + if view := panel.View(0, 24, newUIStyles(midnightTheme())); view != "" { + t.Fatalf("zero-width view = %q, want empty", view) + } + var nilPanel *dataPanel + if view := nilPanel.View(80, 24, newUIStyles(midnightTheme())); view != "" { + t.Fatalf("nil-panel view = %q, want empty", view) + } +} diff --git a/cli/tui/datasetmigrations.go b/cli/tui/datasetmigrations.go new file mode 100644 index 000000000..47cf44928 --- /dev/null +++ b/cli/tui/datasetmigrations.go @@ -0,0 +1,242 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package tui + +import ( + "time" + + core "dappco.re/go" + "dappco.re/go/store" +) + +// datasetMigration mirrors workspaceMigration's shape (see migrations.go) +// but versions its own dataset_schema_versions table — a separate +// sequence from the workspace migrations, because datasets.duckdb is a +// wholly separate file from lem.duckdb (the design's bulk/lifecycle/ +// blast-radius rationale: a bloated or damaged dataset file must never +// take the agent/TUI state down with it). +type datasetMigration struct { + Version int64 + Statements []string +} + +// datasetMigrations is versioned exactly like workspaceMigrations: each +// entry runs once, inside one transaction, recorded in +// dataset_schema_versions so a later reopen is a no-op. +// +// Two "latest per kind" views back the score-expression and review-status +// filters Items() compiles ([duckDatasetStore.Items]): +// - dataset_latest_scores: one row per (item_id, kind) — the most +// recent Score row by (created_at, seq), matching +// [dataset.ScoreExpression.Matches]'s "latest value, ties broken by +// later insertion" rule. +// - dataset_latest_reviews: one row per item_id — the most recent +// Review row by (created_at, seq), matching [dataset.Store]'s +// "latest row wins" Review semantics. +// +// seq is a monotonically increasing column populated by the store (see +// duckDatasetStore.nextSeqLocked) purely to break created_at ties in +// insertion order — Review carries no ID in the domain model, and Score +// IDs are random UUIDs uncorrelated with insertion order, so created_at +// alone cannot reproduce dataset.MemoryStore's append-order tie-break. +// +// dataset_scores.id and dataset_exports.id are deliberately NOT primary +// keys, and seq (guaranteed unique — derived under duckDatasetStore.mu) +// carries dataset_scores' and dataset_reviews' uniqueness instead. This +// mirrors dataset.MemoryStore precisely: Store's interface doc promises +// "fails if ID already exists" only for DatasetCreate and ItemAppend — +// ScoreAppend and ExportAppend's docs say only "fails if +// DatasetID/ItemID does not exist", and MemoryStore's implementations +// confirm it (append.Scores/append.Exports have no id-collision check). +// A conformance run caught this: an early draft PRIMARY KEY'd both id +// columns and TestDatasetStoreConformance/MemoryStore/ExportLifecycle +// failed — MemoryStore silently accepts a re-appended Export id, so +// rejecting it here would have been a real divergence, not a safety +// improvement. +var datasetMigrations = []datasetMigration{ + { + Version: 1, + Statements: []string{ + "CREATE TABLE IF NOT EXISTS dataset_schema_versions (version BIGINT PRIMARY KEY, applied_at TIMESTAMP NOT NULL)", + "CREATE TABLE dataset_datasets (id TEXT PRIMARY KEY, slug TEXT NOT NULL UNIQUE, title TEXT NOT NULL, purpose TEXT NOT NULL, created_at TIMESTAMP NOT NULL, archived BOOLEAN NOT NULL, archived_at TIMESTAMP NOT NULL)", + "CREATE TABLE dataset_items (id TEXT PRIMARY KEY, dataset_id TEXT NOT NULL, kind TEXT NOT NULL, content BLOB NOT NULL, source TEXT NOT NULL, source_ref TEXT NOT NULL, model_fingerprint TEXT NOT NULL, content_hash TEXT NOT NULL, parent_item_id TEXT NOT NULL, archived BOOLEAN NOT NULL, archived_at TIMESTAMP NOT NULL, created_at TIMESTAMP NOT NULL)", + "CREATE TABLE dataset_scores (id TEXT NOT NULL, item_id TEXT NOT NULL, kind TEXT NOT NULL, value DOUBLE NOT NULL, payload BLOB NOT NULL, scorer_name TEXT NOT NULL, scorer_version TEXT NOT NULL, judge_fingerprint TEXT NOT NULL, created_at TIMESTAMP NOT NULL, seq BIGINT PRIMARY KEY)", + "CREATE TABLE dataset_reviews (item_id TEXT NOT NULL, status TEXT NOT NULL, reviewer TEXT NOT NULL, note TEXT NOT NULL, created_at TIMESTAMP NOT NULL, seq BIGINT PRIMARY KEY)", + "CREATE TABLE dataset_exports (id TEXT NOT NULL, dataset_id TEXT NOT NULL, format TEXT NOT NULL, filter_description TEXT NOT NULL, item_count BIGINT NOT NULL, output_path TEXT NOT NULL, manifest_hash TEXT NOT NULL, created_at TIMESTAMP NOT NULL)", + "CREATE INDEX IF NOT EXISTS dataset_datasets_archived_idx ON dataset_datasets(archived, created_at)", + "CREATE INDEX IF NOT EXISTS dataset_items_dataset_idx ON dataset_items(dataset_id, archived, created_at)", + "CREATE INDEX IF NOT EXISTS dataset_items_hash_idx ON dataset_items(dataset_id, content_hash)", + "CREATE INDEX IF NOT EXISTS dataset_scores_item_idx ON dataset_scores(item_id, kind, created_at)", + "CREATE INDEX IF NOT EXISTS dataset_reviews_item_idx ON dataset_reviews(item_id, created_at)", + "CREATE INDEX IF NOT EXISTS dataset_exports_dataset_idx ON dataset_exports(dataset_id, created_at)", + `CREATE VIEW dataset_latest_scores AS + SELECT id, item_id, kind, value, payload, scorer_name, scorer_version, judge_fingerprint, created_at, seq + FROM ( + SELECT *, ROW_NUMBER() OVER (PARTITION BY item_id, kind ORDER BY created_at DESC, seq DESC) AS rn + FROM dataset_scores + ) ranked + WHERE rn = 1`, + `CREATE VIEW dataset_latest_reviews AS + SELECT item_id, status, reviewer, note, created_at, seq + FROM ( + SELECT *, ROW_NUMBER() OVER (PARTITION BY item_id ORDER BY created_at DESC, seq DESC) AS rn + FROM dataset_reviews + ) ranked + WHERE rn = 1`, + }, + }, +} + +// datasetDatabase is the opened+migrated datasets.duckdb handle — the +// dataset-store analogue of workspaceDatabase, but scoped to its own +// file and its own migration sequence. It deliberately does not mount +// the orm medium/runtime: every duckDatasetStore query is hand-rolled +// SQL (see datasetstore.go), matching duckAgentStore's precedent for +// implementing an external Store contract, because the score-expression +// and latest-review-wins filters need window-function views the orm +// DuckDB medium cannot express (no subquery/alias support in its query +// builder). +type datasetDatabase struct { + store *store.DuckDB +} + +// openDatasetDatabase opens (creating if absent) the DuckDB file at path +// and applies datasetMigrations. path must be non-empty — callers resolve +// it from appPaths.Datasets (the medium-backed path contract: only this +// adapter receives the resolved filename). +func openDatasetDatabase(path string) core.Result { + path = core.Trim(path) + if path == "" { + return core.Fail(core.E("tui.openDatasetDatabase", "database path is required", nil)) + } + + storeResult := store.OpenDuckDBReadWrite(path) + if !storeResult.OK { + return core.Fail(core.E("tui.openDatasetDatabase", core.Concat("open DuckDB: ", path), resultError(storeResult))) + } + databaseStore, ok := storeResult.Value.(*store.DuckDB) + if !ok { + return core.Fail(core.E("tui.openDatasetDatabase", "invalid go-store DuckDB result", nil)) + } + if result := applyDatasetMigrations(databaseStore, datasetMigrations, time.Now); !result.OK { + closeStoreAfterFailure(databaseStore) + return core.Fail(core.E("tui.openDatasetDatabase", core.Concat("migrate DuckDB: ", path), resultError(result))) + } + + return core.Ok(&datasetDatabase{store: databaseStore}) +} + +// applyDatasetMigrations runs migrations against database, skipping any +// version already recorded in dataset_schema_versions. Mirrors +// applyWorkspaceMigrations exactly (own transaction per version, full +// rollback on any failed statement) but targets dataset_schema_versions +// instead of lem_schema_versions — the two migration sequences never +// share a version table because they never share a file. +func applyDatasetMigrations(database *store.DuckDB, migrations []datasetMigration, now func() time.Time) core.Result { + if database == nil || database.Conn() == nil { + return core.Fail(core.E("tui.applyDatasetMigrations", "DuckDB connection is required", nil)) + } + if now == nil { + now = time.Now + } + + for _, migration := range migrations { + if migration.Version < 1 { + return core.Fail(core.E("tui.applyDatasetMigrations", "migration version must be positive", nil)) + } + applied, err := datasetMigrationApplied(database, migration.Version) + if err != nil { + return core.Fail(core.E( + "tui.applyDatasetMigrations", + core.Sprintf("read migration version %d", migration.Version), + err, + )) + } + if applied { + continue + } + + transaction, err := database.Conn().Begin() + if err != nil { + return core.Fail(core.E( + "tui.applyDatasetMigrations", + core.Sprintf("begin migration version %d", migration.Version), + err, + )) + } + for _, statement := range migration.Statements { + if core.Trim(statement) == "" { + rollbackWorkspaceMigration(transaction) + return core.Fail(core.E( + "tui.applyDatasetMigrations", + core.Sprintf("migration version %d contains an empty statement", migration.Version), + nil, + )) + } + if _, err := transaction.Exec(statement); err != nil { + rollbackWorkspaceMigration(transaction) + return core.Fail(core.E( + "tui.applyDatasetMigrations", + core.Sprintf("execute migration version %d", migration.Version), + err, + )) + } + } + if _, err := transaction.Exec( + "INSERT INTO dataset_schema_versions (version, applied_at) VALUES (?, ?)", + migration.Version, + now().UTC(), + ); err != nil { + rollbackWorkspaceMigration(transaction) + return core.Fail(core.E( + "tui.applyDatasetMigrations", + core.Sprintf("record migration version %d", migration.Version), + err, + )) + } + if err := transaction.Commit(); err != nil { + rollbackWorkspaceMigration(transaction) + return core.Fail(core.E( + "tui.applyDatasetMigrations", + core.Sprintf("commit migration version %d", migration.Version), + err, + )) + } + } + + return core.Ok(nil) +} + +func datasetMigrationApplied(database *store.DuckDB, version int64) (bool, error) { + var tableCount int + if err := database.Conn().QueryRow(` + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = 'main' AND table_name = 'dataset_schema_versions' + `).Scan(&tableCount); err != nil { + return false, err + } + if tableCount == 0 { + return false, nil + } + + var versionCount int + if err := database.Conn().QueryRow( + "SELECT COUNT(*) FROM dataset_schema_versions WHERE version = ?", + version, + ).Scan(&versionCount); err != nil { + return false, err + } + return versionCount > 0, nil +} + +// Close releases the underlying DuckDB connection. Safe to call on a nil +// receiver or an already-closed database. +func (database *datasetDatabase) Close() core.Result { + if database == nil || database.store == nil { + return core.Ok(nil) + } + result := database.store.Close() + database.store = nil + return result +} diff --git a/cli/tui/datasetmigrations_test.go b/cli/tui/datasetmigrations_test.go new file mode 100644 index 000000000..4cb51a1d1 --- /dev/null +++ b/cli/tui/datasetmigrations_test.go @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package tui + +import ( + "testing" + "time" +) + +func TestDatasetMigrations_Good(t *testing.T) { + path := t.TempDir() + "/datasets.duckdb" + opened := openDatasetDatabase(path) + if !opened.OK { + t.Fatalf("openDatasetDatabase(%q) failed: %v", path, opened.Value) + } + database, ok := opened.Value.(*datasetDatabase) + if !ok { + t.Fatalf("openDatasetDatabase value = %T, want *datasetDatabase", opened.Value) + } + defer func() { + if result := database.Close(); !result.OK { + t.Errorf("close dataset database: %v", result.Value) + } + }() + + var tables int + if err := database.store.Conn().QueryRow(` + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = 'main' + AND table_name IN ( + 'dataset_schema_versions', 'dataset_datasets', 'dataset_items', + 'dataset_scores', 'dataset_reviews', 'dataset_exports' + )`).Scan(&tables); err != nil { + t.Fatalf("count dataset tables: %v", err) + } + if tables != 6 { + t.Fatalf("dataset tables = %d, want 6", tables) + } + + var views int + if err := database.store.Conn().QueryRow(` + SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema = 'main' AND table_name IN ('dataset_latest_scores', 'dataset_latest_reviews') + `).Scan(&views); err != nil { + t.Fatalf("count dataset views: %v", err) + } + if views != 2 { + t.Fatalf("dataset views = %d, want 2", views) + } + + var versions int + if err := database.store.Conn().QueryRow("SELECT COUNT(*) FROM dataset_schema_versions").Scan(&versions); err != nil { + t.Fatalf("count schema versions: %v", err) + } + if versions != 1 { + t.Fatalf("schema version rows = %d, want 1", versions) + } +} + +// TestDatasetMigrations_Bad proves a failing statement rolls back the +// whole migration — no partial table, no recorded version — mirroring +// TestMigrations_Bad's shape for the workspace migrations. +func TestDatasetMigrations_Bad(t *testing.T) { + database := openTestDatasetStore(t, t.TempDir()+"/datasets.duckdb") + defer closeTestDatasetStore(t, database) + + first := applyDatasetMigrations(database, datasetMigrations, time.Now) + if !first.OK { + t.Fatalf("apply base migrations: %v", first.Value) + } + bad := []datasetMigration{{ + Version: 2, + Statements: []string{ + "CREATE TABLE dataset_rollback_probe (id BIGINT)", + "THIS IS NOT VALID SQL", + }, + }} + result := applyDatasetMigrations(database, bad, time.Now) + if result.OK { + t.Fatalf("applyDatasetMigrations(invalid) = %#v, want failure", result.Value) + } + + var versions int + if err := database.Conn().QueryRow("SELECT COUNT(*) FROM dataset_schema_versions WHERE version = 2").Scan(&versions); err != nil { + t.Fatalf("count failed schema version: %v", err) + } + if versions != 0 { + t.Fatalf("failed schema version rows = %d, want 0", versions) + } + var probeTables int + if err := database.Conn().QueryRow(` + SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema = 'main' AND table_name = 'dataset_rollback_probe'`).Scan(&probeTables); err != nil { + t.Fatalf("count rollback probe table: %v", err) + } + if probeTables != 0 { + t.Fatalf("rollback probe tables = %d, want 0", probeTables) + } +} + +// TestDatasetMigrations_Ugly proves migration idempotence explicitly: the +// same path opened, migrated, closed, and reopened records version 1 +// exactly once — a reopen must be a no-op, never a duplicate insert or a +// second CREATE TABLE failure. +func TestDatasetMigrations_Ugly(t *testing.T) { + path := t.TempDir() + "/datasets.duckdb" + + first := openTestDatasetStore(t, path) + if result := applyDatasetMigrations(first, datasetMigrations, time.Now); !result.OK { + t.Fatalf("apply dataset migrations: %v", result.Value) + } + closeTestDatasetStore(t, first) + + reopened := openTestDatasetStore(t, path) + defer closeTestDatasetStore(t, reopened) + if result := applyDatasetMigrations(reopened, datasetMigrations, time.Now); !result.OK { + t.Fatalf("reapply dataset migrations on reopened database: %v", result.Value) + } + + var versions int + if err := reopened.Conn().QueryRow("SELECT COUNT(*) FROM dataset_schema_versions").Scan(&versions); err != nil { + t.Fatalf("count schema versions: %v", err) + } + if versions != 1 { + t.Fatalf("schema version rows after reopen = %d, want 1 (migration must be idempotent)", versions) + } + var version int64 + if err := reopened.Conn().QueryRow("SELECT version FROM dataset_schema_versions LIMIT 1").Scan(&version); err != nil { + t.Fatalf("read recorded version: %v", err) + } + if version != 1 { + t.Fatalf("recorded version = %d, want 1", version) + } + + // Re-opening through the full store constructor (not just the raw + // migration function) must also be a no-op — the path a real caller + // takes via newDuckDatasetStore. + reopenedStore := newTestDuckDatasetStore(t, path) + defer closeTestDuckDatasetStore(t, reopenedStore) + var versionsViaStore int + if err := reopenedStore.conn().QueryRow("SELECT COUNT(*) FROM dataset_schema_versions").Scan(&versionsViaStore); err != nil { + t.Fatalf("count schema versions via store: %v", err) + } + if versionsViaStore != 1 { + t.Fatalf("schema version rows via newDuckDatasetStore reopen = %d, want 1", versionsViaStore) + } +} diff --git a/cli/tui/datasetstore.go b/cli/tui/datasetstore.go new file mode 100644 index 000000000..f963c228f --- /dev/null +++ b/cli/tui/datasetstore.go @@ -0,0 +1,817 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package tui + +import ( + "database/sql" + "sync" + "time" + + core "dappco.re/go" + "dappco.re/go/inference/dataset" +) + +// duckDatasetStore is the DuckDB-backed [dataset.Store] implementation — +// datasets.duckdb, opened separately from lem.duckdb per the design's +// bulk/lifecycle/blast-radius decision. mu serialises every method +// (reads and writes alike): datasets.duckdb is single-writer, and several +// methods are check-then-act (existence checks before insert, MAX(seq) +// before append) that must not interleave with a concurrent call. +type duckDatasetStore struct { + database *datasetDatabase + mu sync.Mutex +} + +var _ dataset.Store = (*duckDatasetStore)(nil) + +// newDuckDatasetStore opens (creating and migrating if absent) the +// datasets.duckdb file at path and returns a ready [dataset.Store]. +// +// opened := newDuckDatasetStore(paths.Datasets) +// store := opened.Value.(*duckDatasetStore) +// defer store.Close() +func newDuckDatasetStore(path string) core.Result { + opened := openDatasetDatabase(path) + if !opened.OK { + return opened + } + database, ok := opened.Value.(*datasetDatabase) + if !ok { + return core.Fail(core.E("tui.newDuckDatasetStore", "invalid dataset database result", nil)) + } + return core.Ok(&duckDatasetStore{database: database}) +} + +// Close releases the underlying DuckDB connection. Safe to call on a nil +// receiver or an already-closed store. +func (s *duckDatasetStore) Close() core.Result { + if s == nil || s.database == nil { + return core.Ok(nil) + } + result := s.database.Close() + s.database = nil + return result +} + +func (s *duckDatasetStore) conn() *sql.DB { + if s == nil || s.database == nil || s.database.store == nil { + return nil + } + return s.database.store.Conn() +} + +func (s *duckDatasetStore) ready(operation string) core.Result { + if s.conn() == nil { + return datasetStoreFailure(operation, "dataset store is closed", nil) + } + return core.Ok(nil) +} + +func datasetStoreFailure(operation, message string, cause error) core.Result { + return core.Fail(core.E(core.Concat("tui.duckDatasetStore.", operation), message, cause)) +} + +// datasetNotFoundErr builds the exact "dataset..notfound" Code +// [dataset.MemoryStore] produces (its unexported notFound helper in +// go/dataset/store.go) — conformance depends on this string matching +// byte-for-byte, since dataset.Store callers branch on Result.Code(). +func datasetNotFoundErr(op, kind, id string) error { + return &core.Err{Operation: op, Message: core.Concat(kind, " not found: ", id), Code: core.Concat("dataset.", kind, ".notfound")} +} + +// sqlComparisonOp maps a dataset.ComparisonOp to its SQL operator. +// Everything but OpEQ ("==") is already valid SQL; OpEQ needs folding to +// a single "=". The switch is a closed whitelist — never string-built +// from filter input — so there is no injection surface even though the +// result is concatenated directly into a query string. +func sqlComparisonOp(op dataset.ComparisonOp) (string, error) { + switch op { + case dataset.OpGTE: + return ">=", nil + case dataset.OpLTE: + return "<=", nil + case dataset.OpGT: + return ">", nil + case dataset.OpLT: + return "<", nil + case dataset.OpEQ: + return "=", nil + case dataset.OpNEQ: + return "!=", nil + default: + return "", core.NewError(core.Concat("dataset: unsupported comparison operator: ", string(op))) + } +} + +// ---- scan helpers ---- + +const datasetSelect = `SELECT id, slug, title, purpose, created_at, archived, archived_at FROM dataset_datasets` + +func scanDataset(row rowScanner) (dataset.Dataset, error) { + var d dataset.Dataset + err := row.Scan(&d.ID, &d.Slug, &d.Title, &d.Purpose, &d.CreatedAt, &d.Archived, &d.ArchivedAt) + return d, err +} + +const itemSelect = `SELECT id, dataset_id, kind, content, source, source_ref, model_fingerprint, content_hash, parent_item_id, archived, archived_at, created_at FROM dataset_items` + +func scanItem(row rowScanner) (dataset.Item, error) { + var item dataset.Item + err := row.Scan(&item.ID, &item.DatasetID, &item.Kind, &item.Content, &item.Source, &item.SourceRef, + &item.ModelFingerprint, &item.ContentHash, &item.ParentItemID, &item.Archived, &item.ArchivedAt, &item.CreatedAt) + return item, err +} + +const scoreSelect = `SELECT id, item_id, kind, value, payload, scorer_name, scorer_version, judge_fingerprint, created_at FROM dataset_scores` + +func scanScore(row rowScanner) (dataset.Score, error) { + var sc dataset.Score + err := row.Scan(&sc.ID, &sc.ItemID, &sc.Kind, &sc.Value, &sc.Payload, &sc.ScorerName, &sc.ScorerVersion, &sc.JudgeFingerprint, &sc.CreatedAt) + return sc, err +} + +const exportSelect = `SELECT id, dataset_id, format, filter_description, item_count, output_path, manifest_hash, created_at FROM dataset_exports` + +func scanExport(row rowScanner) (dataset.Export, error) { + var e dataset.Export + err := row.Scan(&e.ID, &e.DatasetID, &e.Format, &e.FilterDescription, &e.ItemCount, &e.OutputPath, &e.ManifestHash, &e.CreatedAt) + return e, err +} + +// ---- existence checks (referential integrity, mirrors MemoryStore's +// hasDatasetLocked/hasItemLocked) ---- + +func (s *duckDatasetStore) datasetExistsLocked(id string) (bool, error) { + var count int + if err := s.conn().QueryRow("SELECT COUNT(*) FROM dataset_datasets WHERE id = ?", id).Scan(&count); err != nil { + return false, err + } + return count > 0, nil +} + +func (s *duckDatasetStore) itemExistsLocked(id string) (bool, error) { + var count int + if err := s.conn().QueryRow("SELECT COUNT(*) FROM dataset_items WHERE id = ?", id).Scan(&count); err != nil { + return false, err + } + return count > 0, nil +} + +// nextSeqLocked derives the next value of the insertion-order tiebreak +// column for table ("dataset_scores" or "dataset_reviews" — always a +// package-internal literal, never external input). Computed under s.mu, +// so the read-then-insert pair here is race-free: no interleaved append +// can observe or claim the same seq value. +func (s *duckDatasetStore) nextSeqLocked(table string) (int64, error) { + var next int64 + if err := s.conn().QueryRow("SELECT COALESCE(MAX(seq), 0) + 1 FROM " + table).Scan(&next); err != nil { + return 0, err + } + return next, nil +} + +// ---- Dataset ---- + +func (s *duckDatasetStore) DatasetCreate(d dataset.Dataset) core.Result { + if r := dataset.ValidateDataset(d); !r.OK { + return r + } + if r := s.ready("DatasetCreate"); !r.OK { + return r + } + s.mu.Lock() + defer s.mu.Unlock() + + var idCount int + if err := s.conn().QueryRow("SELECT COUNT(*) FROM dataset_datasets WHERE id = ?", d.ID).Scan(&idCount); err != nil { + return datasetStoreFailure("DatasetCreate", "check existing dataset id", err) + } + if idCount > 0 { + return datasetStoreFailure("DatasetCreate", "a dataset with this id already exists", nil) + } + var slugCount int + if err := s.conn().QueryRow("SELECT COUNT(*) FROM dataset_datasets WHERE slug = ?", d.Slug).Scan(&slugCount); err != nil { + return datasetStoreFailure("DatasetCreate", "check existing dataset slug", err) + } + if slugCount > 0 { + return datasetStoreFailure("DatasetCreate", "a dataset with this slug already exists", nil) + } + + archivedAt := d.ArchivedAt + if archivedAt.IsZero() { + archivedAt = unsetRecordTime() + } + if _, err := s.conn().Exec( + `INSERT INTO dataset_datasets (id, slug, title, purpose, created_at, archived, archived_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, + d.ID, d.Slug, d.Title, d.Purpose, d.CreatedAt.UTC(), d.Archived, archivedAt.UTC(), + ); err != nil { + return datasetStoreFailure("DatasetCreate", "insert dataset", err) + } + return core.Ok(d) +} + +func (s *duckDatasetStore) Dataset(id string) core.Result { + if r := s.ready("Dataset"); !r.OK { + return r + } + if core.Trim(id) == "" { + return datasetStoreFailure("Dataset", "dataset id is required", nil) + } + s.mu.Lock() + defer s.mu.Unlock() + d, err := scanDataset(s.conn().QueryRow(datasetSelect+" WHERE id = ?", id)) + if err == sql.ErrNoRows { + return core.Fail(datasetNotFoundErr("tui.duckDatasetStore.Dataset", "dataset", id)) + } + if err != nil { + return datasetStoreFailure("Dataset", "scan dataset", err) + } + return core.Ok(d) +} + +func (s *duckDatasetStore) DatasetBySlug(slug string) core.Result { + if r := s.ready("DatasetBySlug"); !r.OK { + return r + } + if core.Trim(slug) == "" { + return datasetStoreFailure("DatasetBySlug", "dataset slug is required", nil) + } + s.mu.Lock() + defer s.mu.Unlock() + d, err := scanDataset(s.conn().QueryRow(datasetSelect+" WHERE slug = ?", slug)) + if err == sql.ErrNoRows { + return core.Fail(datasetNotFoundErr("tui.duckDatasetStore.DatasetBySlug", "dataset", slug)) + } + if err != nil { + return datasetStoreFailure("DatasetBySlug", "scan dataset", err) + } + return core.Ok(d) +} + +func (s *duckDatasetStore) Datasets(includeArchived bool) core.Result { + if r := s.ready("Datasets"); !r.OK { + return r + } + s.mu.Lock() + defer s.mu.Unlock() + + query := datasetSelect + args := make([]any, 0, 1) + if !includeArchived { + query += " WHERE archived = ?" + args = append(args, false) + } + query += " ORDER BY created_at, id" + + rows, err := s.conn().Query(query, args...) + if err != nil { + return datasetStoreFailure("Datasets", "query datasets", err) + } + defer closeAgentRows(rows) + out := make([]dataset.Dataset, 0) + for rows.Next() { + d, err := scanDataset(rows) + if err != nil { + return datasetStoreFailure("Datasets", "scan dataset", err) + } + out = append(out, d) + } + if err := rows.Err(); err != nil { + return datasetStoreFailure("Datasets", "iterate datasets", err) + } + return core.Ok(out) +} + +func (s *duckDatasetStore) DatasetArchive(id string) core.Result { + if r := s.ready("DatasetArchive"); !r.OK { + return r + } + if core.Trim(id) == "" { + return datasetStoreFailure("DatasetArchive", "dataset id is required", nil) + } + s.mu.Lock() + defer s.mu.Unlock() + + result, err := s.conn().Exec("UPDATE dataset_datasets SET archived = ?, archived_at = ? WHERE id = ?", true, time.Now().UTC(), id) + if err != nil { + return datasetStoreFailure("DatasetArchive", "archive dataset", err) + } + count, err := result.RowsAffected() + if err != nil { + return datasetStoreFailure("DatasetArchive", "count archived rows", err) + } + if count == 0 { + return core.Fail(datasetNotFoundErr("tui.duckDatasetStore.DatasetArchive", "dataset", id)) + } + d, err := scanDataset(s.conn().QueryRow(datasetSelect+" WHERE id = ?", id)) + if err != nil { + return datasetStoreFailure("DatasetArchive", "scan archived dataset", err) + } + return core.Ok(d) +} + +// ---- Item ---- + +func (s *duckDatasetStore) ItemAppend(item dataset.Item) core.Result { + if r := dataset.ValidateItem(item); !r.OK { + return r + } + if r := s.ready("ItemAppend"); !r.OK { + return r + } + s.mu.Lock() + defer s.mu.Unlock() + + exists, err := s.datasetExistsLocked(item.DatasetID) + if err != nil { + return datasetStoreFailure("ItemAppend", "check dataset exists", err) + } + if !exists { + return core.Fail(datasetNotFoundErr("tui.duckDatasetStore.ItemAppend", "dataset", item.DatasetID)) + } + itemExists, err := s.itemExistsLocked(item.ID) + if err != nil { + return datasetStoreFailure("ItemAppend", "check existing item", err) + } + if itemExists { + return datasetStoreFailure("ItemAppend", "an item with this id already exists", nil) + } + + archivedAt := item.ArchivedAt + if archivedAt.IsZero() { + archivedAt = unsetRecordTime() + } + if _, err := s.conn().Exec( + `INSERT INTO dataset_items (id, dataset_id, kind, content, source, source_ref, model_fingerprint, content_hash, parent_item_id, archived, archived_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + item.ID, item.DatasetID, string(item.Kind), item.Content, string(item.Source), item.SourceRef, + item.ModelFingerprint, item.ContentHash, item.ParentItemID, item.Archived, archivedAt.UTC(), item.CreatedAt.UTC(), + ); err != nil { + return datasetStoreFailure("ItemAppend", "insert item", err) + } + return core.Ok(item) +} + +func (s *duckDatasetStore) Item(id string) core.Result { + if r := s.ready("Item"); !r.OK { + return r + } + if core.Trim(id) == "" { + return datasetStoreFailure("Item", "item id is required", nil) + } + s.mu.Lock() + defer s.mu.Unlock() + item, err := scanItem(s.conn().QueryRow(itemSelect+" WHERE id = ?", id)) + if err == sql.ErrNoRows { + return core.Fail(datasetNotFoundErr("tui.duckDatasetStore.Item", "item", id)) + } + if err != nil { + return datasetStoreFailure("Item", "scan item", err) + } + return core.Ok(item) +} + +// Items compiles filter to one SQL query. The dataset id is a mandatory +// WHERE; every other dimension is an optional AND. Status and Score +// filters join the "latest per kind/item" views datasetMigrations +// defines — window-function views the orm DuckDB medium's query builder +// cannot express (its declared Subqueries/Aliases capabilities are not +// actually wired into DuckDBMedium.Read), so this method talks to +// *sql.DB directly, matching duckAgentStore's precedent. +func (s *duckDatasetStore) Items(filter dataset.ItemFilter) core.Result { + if r := s.ready("Items"); !r.OK { + return r + } + if core.Trim(filter.DatasetID) == "" { + return datasetStoreFailure("Items", "item filter requires a dataset id", nil) + } + s.mu.Lock() + defer s.mu.Unlock() + + query := "SELECT i.id, i.dataset_id, i.kind, i.content, i.source, i.source_ref, i.model_fingerprint, i.content_hash, i.parent_item_id, i.archived, i.archived_at, i.created_at FROM dataset_items i" + args := make([]any, 0, 8) + + if filter.Status != "" { + query += " LEFT JOIN dataset_latest_reviews lr ON lr.item_id = i.id" + } + if filter.Score != nil { + query += " JOIN dataset_latest_scores ls ON ls.item_id = i.id AND ls.kind = ?" + args = append(args, string(filter.Score.Kind)) + } + + query += " WHERE i.dataset_id = ?" + args = append(args, filter.DatasetID) + + if !filter.IncludeArchived { + query += " AND i.archived = ?" + args = append(args, false) + } + if filter.Kind != "" { + query += " AND i.kind = ?" + args = append(args, string(filter.Kind)) + } + if filter.Source != "" { + query += " AND i.source = ?" + args = append(args, string(filter.Source)) + } + if filter.ContentHash != "" { + query += " AND i.content_hash = ?" + args = append(args, filter.ContentHash) + } + if filter.Status != "" { + query += " AND COALESCE(lr.status, ?) = ?" + args = append(args, string(dataset.StatusPending), string(filter.Status)) + } + if filter.Score != nil { + op, opErr := sqlComparisonOp(filter.Score.Op) + if opErr != nil { + return datasetStoreFailure("Items", "unsupported score expression operator", opErr) + } + query += " AND ls.value " + op + " ?" + args = append(args, filter.Score.Threshold) + } + query += " ORDER BY i.created_at, i.id" + + rows, err := s.conn().Query(query, args...) + if err != nil { + return datasetStoreFailure("Items", "query items", err) + } + defer closeAgentRows(rows) + out := make([]dataset.Item, 0) + for rows.Next() { + item, err := scanItem(rows) + if err != nil { + return datasetStoreFailure("Items", "scan item", err) + } + out = append(out, item) + } + if err := rows.Err(); err != nil { + return datasetStoreFailure("Items", "iterate items", err) + } + return core.Ok(out) +} + +// ---- Score ---- + +func (s *duckDatasetStore) ScoreAppend(score dataset.Score) core.Result { + if r := dataset.ValidateScore(score); !r.OK { + return r + } + if r := s.ready("ScoreAppend"); !r.OK { + return r + } + s.mu.Lock() + defer s.mu.Unlock() + + exists, err := s.itemExistsLocked(score.ItemID) + if err != nil { + return datasetStoreFailure("ScoreAppend", "check item exists", err) + } + if !exists { + return core.Fail(datasetNotFoundErr("tui.duckDatasetStore.ScoreAppend", "item", score.ItemID)) + } + + seq, err := s.nextSeqLocked("dataset_scores") + if err != nil { + return datasetStoreFailure("ScoreAppend", "derive score sequence", err) + } + if _, err := s.conn().Exec( + `INSERT INTO dataset_scores (id, item_id, kind, value, payload, scorer_name, scorer_version, judge_fingerprint, created_at, seq) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + score.ID, score.ItemID, string(score.Kind), score.Value, score.Payload, score.ScorerName, score.ScorerVersion, + score.JudgeFingerprint, score.CreatedAt.UTC(), seq, + ); err != nil { + return datasetStoreFailure("ScoreAppend", "insert score", err) + } + return core.Ok(score) +} + +func (s *duckDatasetStore) Scores(itemID string) core.Result { + if r := s.ready("Scores"); !r.OK { + return r + } + if core.Trim(itemID) == "" { + return datasetStoreFailure("Scores", "item id is required", nil) + } + s.mu.Lock() + defer s.mu.Unlock() + + exists, err := s.itemExistsLocked(itemID) + if err != nil { + return datasetStoreFailure("Scores", "check item exists", err) + } + if !exists { + return core.Fail(datasetNotFoundErr("tui.duckDatasetStore.Scores", "item", itemID)) + } + + rows, err := s.conn().Query(scoreSelect+" WHERE item_id = ? ORDER BY created_at, id", itemID) + if err != nil { + return datasetStoreFailure("Scores", "query scores", err) + } + defer closeAgentRows(rows) + out := make([]dataset.Score, 0) + for rows.Next() { + sc, err := scanScore(rows) + if err != nil { + return datasetStoreFailure("Scores", "scan score", err) + } + out = append(out, sc) + } + if err := rows.Err(); err != nil { + return datasetStoreFailure("Scores", "iterate scores", err) + } + return core.Ok(out) +} + +// ---- Review ---- + +func (s *duckDatasetStore) ReviewAppend(review dataset.Review) core.Result { + if r := dataset.ValidateReview(review); !r.OK { + return r + } + if r := s.ready("ReviewAppend"); !r.OK { + return r + } + s.mu.Lock() + defer s.mu.Unlock() + + exists, err := s.itemExistsLocked(review.ItemID) + if err != nil { + return datasetStoreFailure("ReviewAppend", "check item exists", err) + } + if !exists { + return core.Fail(datasetNotFoundErr("tui.duckDatasetStore.ReviewAppend", "item", review.ItemID)) + } + + seq, err := s.nextSeqLocked("dataset_reviews") + if err != nil { + return datasetStoreFailure("ReviewAppend", "derive review sequence", err) + } + if _, err := s.conn().Exec( + `INSERT INTO dataset_reviews (item_id, status, reviewer, note, created_at, seq) VALUES (?, ?, ?, ?, ?, ?)`, + review.ItemID, string(review.Status), review.Reviewer, review.Note, review.CreatedAt.UTC(), seq, + ); err != nil { + return datasetStoreFailure("ReviewAppend", "insert review", err) + } + return core.Ok(review) +} + +func (s *duckDatasetStore) ReviewLatest(itemID string) core.Result { + if r := s.ready("ReviewLatest"); !r.OK { + return r + } + if core.Trim(itemID) == "" { + return datasetStoreFailure("ReviewLatest", "item id is required", nil) + } + s.mu.Lock() + defer s.mu.Unlock() + + exists, err := s.itemExistsLocked(itemID) + if err != nil { + return datasetStoreFailure("ReviewLatest", "check item exists", err) + } + if !exists { + return core.Fail(datasetNotFoundErr("tui.duckDatasetStore.ReviewLatest", "item", itemID)) + } + + var review dataset.Review + err = s.conn().QueryRow( + "SELECT item_id, status, reviewer, note, created_at FROM dataset_latest_reviews WHERE item_id = ?", itemID, + ).Scan(&review.ItemID, &review.Status, &review.Reviewer, &review.Note, &review.CreatedAt) + if err == sql.ErrNoRows { + return core.Ok(dataset.Review{ItemID: itemID, Status: dataset.StatusPending}) + } + if err != nil { + return datasetStoreFailure("ReviewLatest", "scan latest review", err) + } + return core.Ok(review) +} + +// ---- Export ---- + +func (s *duckDatasetStore) ExportAppend(export dataset.Export) core.Result { + if r := dataset.ValidateExport(export); !r.OK { + return r + } + if r := s.ready("ExportAppend"); !r.OK { + return r + } + s.mu.Lock() + defer s.mu.Unlock() + + exists, err := s.datasetExistsLocked(export.DatasetID) + if err != nil { + return datasetStoreFailure("ExportAppend", "check dataset exists", err) + } + if !exists { + return core.Fail(datasetNotFoundErr("tui.duckDatasetStore.ExportAppend", "dataset", export.DatasetID)) + } + + // No id-uniqueness check here — matches dataset.MemoryStore.ExportAppend + // exactly (see the datasetMigrations doc comment): Store's interface + // only documents "fails if ID already exists" for DatasetCreate and + // ItemAppend. dataset_exports.id is not a primary key for the same + // reason. + if _, err := s.conn().Exec( + `INSERT INTO dataset_exports (id, dataset_id, format, filter_description, item_count, output_path, manifest_hash, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + export.ID, export.DatasetID, string(export.Format), export.FilterDescription, export.ItemCount, + export.OutputPath, export.ManifestHash, export.CreatedAt.UTC(), + ); err != nil { + return datasetStoreFailure("ExportAppend", "insert export", err) + } + return core.Ok(export) +} + +func (s *duckDatasetStore) Exports(datasetID string) core.Result { + if r := s.ready("Exports"); !r.OK { + return r + } + if core.Trim(datasetID) == "" { + return datasetStoreFailure("Exports", "dataset id is required", nil) + } + s.mu.Lock() + defer s.mu.Unlock() + + exists, err := s.datasetExistsLocked(datasetID) + if err != nil { + return datasetStoreFailure("Exports", "check dataset exists", err) + } + if !exists { + return core.Fail(datasetNotFoundErr("tui.duckDatasetStore.Exports", "dataset", datasetID)) + } + + rows, err := s.conn().Query(exportSelect+" WHERE dataset_id = ? ORDER BY created_at, id", datasetID) + if err != nil { + return datasetStoreFailure("Exports", "query exports", err) + } + defer closeAgentRows(rows) + out := make([]dataset.Export, 0) + for rows.Next() { + e, err := scanExport(rows) + if err != nil { + return datasetStoreFailure("Exports", "scan export", err) + } + out = append(out, e) + } + if err := rows.Err(); err != nil { + return datasetStoreFailure("Exports", "iterate exports", err) + } + return core.Ok(out) +} + +// ---- CLI-only capability extensions ---- +// +// go/dataset's Store interface (Tasks 1-7, off limits to the Data panel +// per the dataset loop plan's Task 8/9 lane split) exposes only +// ReviewLatest and no item-level archive — DatasetArchive flags a whole +// Dataset, never one Item. The Data panel's edit-as-derived action +// (archive the superseded original) and its review-history detail view +// both need a bit more than Store promises. Rather than reaching around +// dataset.Store with raw SQL from datapanel.go, or widening the +// root-module interface (out of this task's lane), both capabilities are +// declared here as small optional interfaces — discovered by type +// assertion, the same "separate interface, never extend the base one" +// idiom this repo already uses for engine capabilities. duckDatasetStore +// implements both; dataset.MemoryStore (the root-module test double) does +// not, so panel code must treat their absence as an honest "unavailable", +// never a hard requirement. + +// ItemArchiver is an optional [dataset.Store] capability exposing +// item-level archive. Mirrors DatasetArchive's shape exactly, one level +// down the hierarchy. +type ItemArchiver interface { + // ItemArchive flags the item archived (idempotent) and returns the + // updated dataset.Item. + ItemArchive(id string) core.Result +} + +var _ ItemArchiver = (*duckDatasetStore)(nil) + +func (s *duckDatasetStore) ItemArchive(id string) core.Result { + if r := s.ready("ItemArchive"); !r.OK { + return r + } + if core.Trim(id) == "" { + return datasetStoreFailure("ItemArchive", "item id is required", nil) + } + s.mu.Lock() + defer s.mu.Unlock() + + result, err := s.conn().Exec("UPDATE dataset_items SET archived = ?, archived_at = ? WHERE id = ?", true, time.Now().UTC(), id) + if err != nil { + return datasetStoreFailure("ItemArchive", "archive item", err) + } + count, err := result.RowsAffected() + if err != nil { + return datasetStoreFailure("ItemArchive", "count archived rows", err) + } + if count == 0 { + return core.Fail(datasetNotFoundErr("tui.duckDatasetStore.ItemArchive", "item", id)) + } + item, err := scanItem(s.conn().QueryRow(itemSelect+" WHERE id = ?", id)) + if err != nil { + return datasetStoreFailure("ItemArchive", "scan archived item", err) + } + return core.Ok(item) +} + +// ReviewHistoryStore is an optional [dataset.Store] capability exposing an +// item's full review history. dataset.Store's ReviewLatest only ever +// returns the current row (append-only, latest wins); the underlying +// dataset_reviews table keeps every row, per the design's "history kept". +type ReviewHistoryStore interface { + // ReviewHistory returns every Review row for itemID, oldest first. + // Fails if itemID does not exist. + ReviewHistory(itemID string) core.Result +} + +var _ ReviewHistoryStore = (*duckDatasetStore)(nil) + +func (s *duckDatasetStore) ReviewHistory(itemID string) core.Result { + if r := s.ready("ReviewHistory"); !r.OK { + return r + } + if core.Trim(itemID) == "" { + return datasetStoreFailure("ReviewHistory", "item id is required", nil) + } + s.mu.Lock() + defer s.mu.Unlock() + + exists, err := s.itemExistsLocked(itemID) + if err != nil { + return datasetStoreFailure("ReviewHistory", "check item exists", err) + } + if !exists { + return core.Fail(datasetNotFoundErr("tui.duckDatasetStore.ReviewHistory", "item", itemID)) + } + + rows, err := s.conn().Query("SELECT item_id, status, reviewer, note, created_at FROM dataset_reviews WHERE item_id = ? ORDER BY created_at, seq", itemID) + if err != nil { + return datasetStoreFailure("ReviewHistory", "query review history", err) + } + defer closeAgentRows(rows) + out := make([]dataset.Review, 0) + for rows.Next() { + var review dataset.Review + if err := rows.Scan(&review.ItemID, &review.Status, &review.Reviewer, &review.Note, &review.CreatedAt); err != nil { + return datasetStoreFailure("ReviewHistory", "scan review", err) + } + out = append(out, review) + } + if err := rows.Err(); err != nil { + return datasetStoreFailure("ReviewHistory", "iterate review history", err) + } + return core.Ok(out) +} + +// ---- CLI-facing entry point ---- + +// DatasetStore is the [dataset.Store] handle [OpenDatasetStore] returns. +// Callers close it exactly once when done (typically via defer) — the +// interface exists purely so a caller outside this package (cli/data.go, +// the serve/ssd capture taps) can hold and close the concrete +// *duckDatasetStore [OpenDatasetStore] builds without naming its +// unexported type: Go's structural typing lets an unexported +// implementation satisfy an exported interface asserted from outside the +// package, exactly as dataset.Store itself already does. +type DatasetStore interface { + dataset.Store + // Close releases the underlying DuckDB connection. Safe to call on an + // already-closed store. + Close() core.Result +} + +// OpenDatasetStore opens (creating the ~/.lem layout and migrating +// datasets.duckdb if either is absent) the one dataset store every `lem +// data` verb and the serve/ssd capture taps share — "open the DuckDB +// store via the cli/tui dataset machinery" per the dataset loop design, +// so no caller outside this package ever resolves the ~/.lem path +// itself (see appPathsAt's medium-backed path contract). There is no +// --home override: the root always resolves from $HOME via +// defaultAppPaths, matching every other ~/.lem consumer in this binary — +// tests redirect it with t.Setenv("HOME", ...), the same seam +// cli/serve_test.go already uses for the admin-token path. +// +// opened := tui.OpenDatasetStore() +// if !opened.OK { return opened } +// store := opened.Value.(tui.DatasetStore) +// defer store.Close() +func OpenDatasetStore() core.Result { + pathsResult := defaultAppPaths() + if !pathsResult.OK { + return pathsResult + } + paths, ok := pathsResult.Value.(appPaths) + if !ok { + return core.Fail(core.E("tui.OpenDatasetStore", "invalid application paths result", nil)) + } + // openAppFilesAt ensures the ~/.lem layout exists (root + the standard + // subdirectories) before the DuckDB adapter ever touches it — the same + // preparation loadDefaultWorkspace runs for lem.duckdb, applied here to + // datasets.duckdb's separate root-relative path. + if ensured := openAppFilesAt(paths.Root); !ensured.OK { + return core.Fail(core.E("tui.OpenDatasetStore", "ensure application layout", resultError(ensured))) + } + return newDuckDatasetStore(paths.Datasets) +} diff --git a/cli/tui/datasetstore_test.go b/cli/tui/datasetstore_test.go new file mode 100644 index 000000000..9bf20856e --- /dev/null +++ b/cli/tui/datasetstore_test.go @@ -0,0 +1,453 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package tui + +import ( + "os" + "testing" + "time" + + core "dappco.re/go" + "dappco.re/go/inference/dataset" + "dappco.re/go/store" +) + +// ---- test-only construction helpers ---- + +func conformancePairItem(datasetID, prompt, response string, at time.Time) dataset.Item { + content := core.MustCast[[]byte](core.JSONMarshal(dataset.PairContent{Prompt: prompt, Response: response})) + hash := core.MustCast[string](dataset.ContentHash(dataset.KindPair, content)) + return dataset.Item{ + ID: dataset.NewID(), DatasetID: datasetID, Kind: dataset.KindPair, Content: content, + Source: dataset.SourceImportJSONL, ContentHash: hash, CreatedAt: at, + } +} + +func conformanceScore(itemID string, kind dataset.ScoreKind, value float64, at time.Time) dataset.Score { + return dataset.Score{ID: dataset.NewID(), ItemID: itemID, Kind: kind, Value: value, ScorerName: "conformance-scorer", ScorerVersion: "1", CreatedAt: at} +} + +func indexOfDataset(list []dataset.Dataset, id string) int { + for i, d := range list { + if d.ID == id { + return i + } + } + return -1 +} + +// ---- store open/close test helpers ---- + +func openTestDatasetStore(t *testing.T, path string) *store.DuckDB { + t.Helper() + result := store.OpenDuckDBReadWrite(path) + if !result.OK { + t.Fatalf("open DuckDB %q: %v", path, result.Value) + } + database, ok := result.Value.(*store.DuckDB) + if !ok { + t.Fatalf("OpenDuckDBReadWrite value = %T, want *store.DuckDB", result.Value) + } + return database +} + +func closeTestDatasetStore(t *testing.T, database *store.DuckDB) { + t.Helper() + if result := database.Close(); !result.OK { + t.Errorf("close DuckDB: %v", result.Value) + } +} + +func newTestDuckDatasetStore(t *testing.T, path string) *duckDatasetStore { + t.Helper() + opened := newDuckDatasetStore(path) + if !opened.OK { + t.Fatalf("newDuckDatasetStore(%q) failed: %v", path, opened.Value) + } + s, ok := opened.Value.(*duckDatasetStore) + if !ok { + t.Fatalf("newDuckDatasetStore value = %T, want *duckDatasetStore", opened.Value) + } + return s +} + +func closeTestDuckDatasetStore(t *testing.T, s *duckDatasetStore) { + t.Helper() + if result := s.Close(); !result.OK { + t.Errorf("close dataset store: %v", result.Value) + } +} + +// ---- CONFORMANCE: the same behavioural table driven over both +// dataset.MemoryStore (the root package's reference behaviour) and +// duckDatasetStore (this package's DuckDB implementation). Every +// sub-test uses its own dataset slug / item namespace so the two runs +// never collide within one shared store instance. ---- + +func TestDatasetStoreConformance(t *testing.T) { + backends := []struct { + name string + open func(t *testing.T) dataset.Store + }{ + {name: "MemoryStore", open: func(t *testing.T) dataset.Store { + return dataset.NewMemoryStore() + }}, + {name: "DuckDB", open: func(t *testing.T) dataset.Store { + path := t.TempDir() + "/datasets.duckdb" + s := newTestDuckDatasetStore(t, path) + t.Cleanup(func() { closeTestDuckDatasetStore(t, s) }) + return s + }}, + } + + for _, backend := range backends { + t.Run(backend.name, func(t *testing.T) { + st := backend.open(t) + t.Run("DatasetLifecycle", func(t *testing.T) { testDatasetLifecycle(t, st) }) + t.Run("ItemLifecycleAndFilters", func(t *testing.T) { testItemLifecycleAndFilters(t, st) }) + t.Run("ScoreAppendAndExpressionMatching", func(t *testing.T) { testScoreAppendAndExpressionMatching(t, st) }) + t.Run("ReviewLatestWins", func(t *testing.T) { testReviewLatestWins(t, st) }) + t.Run("ExportLifecycle", func(t *testing.T) { testExportLifecycle(t, st) }) + t.Run("NotFoundCodes", func(t *testing.T) { testNotFoundCodes(t, st) }) + }) + } +} + +func testDatasetLifecycle(t *testing.T, st dataset.Store) { + t.Helper() + at := time.Date(2026, time.July, 19, 10, 0, 0, 0, time.UTC) + + dsA := dataset.Dataset{ID: dataset.NewID(), Slug: "conformance-ds-lifecycle-a", Title: "A", CreatedAt: at} + dsB := dataset.Dataset{ID: dataset.NewID(), Slug: "conformance-ds-lifecycle-b", Title: "B", CreatedAt: at.Add(time.Second)} + core.RequireTrue(t, st.DatasetCreate(dsA).OK) + core.RequireTrue(t, st.DatasetCreate(dsB).OK) + + core.AssertFalse(t, st.DatasetCreate(dataset.Dataset{ID: dsA.ID, Slug: "conformance-ds-lifecycle-c", Title: "dup id", CreatedAt: at}).OK, "duplicate id must fail") + core.AssertFalse(t, st.DatasetCreate(dataset.Dataset{ID: dataset.NewID(), Slug: dsA.Slug, Title: "dup slug", CreatedAt: at}).OK, "duplicate slug must fail") + + got := core.MustCast[dataset.Dataset](st.Dataset(dsA.ID)) + core.AssertEqual(t, dsA.Slug, got.Slug) + got = core.MustCast[dataset.Dataset](st.DatasetBySlug(dsB.Slug)) + core.AssertEqual(t, dsB.ID, got.ID) + + notFound := st.Dataset("missing-" + dataset.NewID()) + core.AssertFalse(t, notFound.OK) + core.AssertEqual(t, "dataset.dataset.notfound", notFound.Code()) + + list := core.MustCast[[]dataset.Dataset](st.Datasets(false)) + idxA, idxB := indexOfDataset(list, dsA.ID), indexOfDataset(list, dsB.ID) + core.AssertTrue(t, idxA >= 0 && idxB >= 0, "both datasets must be listed") + core.AssertTrue(t, idxA < idxB, "created_at ordering: dsA before dsB") + + archived := core.MustCast[dataset.Dataset](st.DatasetArchive(dsA.ID)) + core.AssertTrue(t, archived.Archived) + core.AssertFalse(t, archived.ArchivedAt.IsZero()) + core.RequireTrue(t, st.DatasetArchive(dsA.ID).OK, "archiving twice must be idempotent") + + listAfter := core.MustCast[[]dataset.Dataset](st.Datasets(false)) + core.AssertTrue(t, indexOfDataset(listAfter, dsA.ID) < 0, "archived dataset excluded by default") + listAll := core.MustCast[[]dataset.Dataset](st.Datasets(true)) + core.AssertTrue(t, indexOfDataset(listAll, dsA.ID) >= 0, "includeArchived surfaces the archived dataset") + + core.AssertFalse(t, st.DatasetArchive("missing-"+dataset.NewID()).OK, "archiving an unknown dataset must fail") +} + +func testItemLifecycleAndFilters(t *testing.T, st dataset.Store) { + t.Helper() + at := time.Date(2026, time.July, 19, 11, 0, 0, 0, time.UTC) + ds := dataset.Dataset{ID: dataset.NewID(), Slug: "conformance-items", Title: "Items", CreatedAt: at} + core.RequireTrue(t, st.DatasetCreate(ds).OK) + + item1 := conformancePairItem(ds.ID, "hi", "hello-1", at.Add(time.Second)) + item2 := conformancePairItem(ds.ID, "hi", "hello-2", at.Add(2*time.Second)) + core.RequireTrue(t, st.ItemAppend(item1).OK) + core.RequireTrue(t, st.ItemAppend(item2).OK) + + core.AssertFalse(t, st.ItemAppend(conformancePairItem("missing-"+dataset.NewID(), "x", "y", at)).OK, "unknown dataset id must fail") + core.AssertFalse(t, st.ItemAppend(item1).OK, "duplicate item id must fail") + + got := core.MustCast[dataset.Item](st.Item(item1.ID)) + core.AssertEqual(t, item1.ContentHash, got.ContentHash) + + notFound := st.Item("missing-" + dataset.NewID()) + core.AssertFalse(t, notFound.OK) + core.AssertEqual(t, "dataset.item.notfound", notFound.Code()) + + core.AssertFalse(t, st.Items(dataset.ItemFilter{}).OK, "a filter with no dataset id must fail") + + list := core.MustCast[[]dataset.Item](st.Items(dataset.ItemFilter{DatasetID: ds.ID})) + core.AssertLen(t, list, 2) + core.AssertEqual(t, item1.ID, list[0].ID, "created_at ordering: earlier item first") + + core.AssertLen(t, core.MustCast[[]dataset.Item](st.Items(dataset.ItemFilter{DatasetID: ds.ID, Kind: dataset.KindMessages})), 0, "no messages-kind items exist") + core.AssertLen(t, core.MustCast[[]dataset.Item](st.Items(dataset.ItemFilter{DatasetID: ds.ID, ContentHash: item1.ContentHash})), 1) + core.AssertLen(t, core.MustCast[[]dataset.Item](st.Items(dataset.ItemFilter{DatasetID: ds.ID, Source: dataset.SourceCaptureServe})), 0, "no capture:serve items exist") + core.AssertLen(t, core.MustCast[[]dataset.Item](st.Items(dataset.ItemFilter{DatasetID: ds.ID, Source: item1.Source})), 2) + + core.AssertLen(t, core.MustCast[[]dataset.Item](st.Items(dataset.ItemFilter{DatasetID: ds.ID, Status: dataset.StatusPending})), 2, "unreviewed items are implicitly pending") + core.AssertLen(t, core.MustCast[[]dataset.Item](st.Items(dataset.ItemFilter{DatasetID: ds.ID, Status: dataset.StatusApproved})), 0, "no reviews yet — nothing approved") + core.RequireTrue(t, st.ReviewAppend(dataset.Review{ItemID: item1.ID, Status: dataset.StatusApproved, Reviewer: "snider", CreatedAt: at.Add(10 * time.Second)}).OK) + core.AssertLen(t, core.MustCast[[]dataset.Item](st.Items(dataset.ItemFilter{DatasetID: ds.ID, Status: dataset.StatusApproved})), 1, "the approved item now matches") + core.AssertLen(t, core.MustCast[[]dataset.Item](st.Items(dataset.ItemFilter{DatasetID: ds.ID, Status: dataset.StatusPending})), 1, "the other item is still pending") + + archivedItem := conformancePairItem(ds.ID, "hi", "hello-archived", at.Add(20*time.Second)) + archivedItem.Archived = true + archivedItem.ArchivedAt = at.Add(21 * time.Second) + core.RequireTrue(t, st.ItemAppend(archivedItem).OK) + list = core.MustCast[[]dataset.Item](st.Items(dataset.ItemFilter{DatasetID: ds.ID})) + for _, it := range list { + core.AssertNotEqual(t, archivedItem.ID, it.ID, "archived item excluded by default") + } + listAll := core.MustCast[[]dataset.Item](st.Items(dataset.ItemFilter{DatasetID: ds.ID, IncludeArchived: true})) + core.AssertLen(t, listAll, 3, "IncludeArchived surfaces the archived item too") +} + +func testScoreAppendAndExpressionMatching(t *testing.T, st dataset.Store) { + t.Helper() + at := time.Date(2026, time.July, 19, 12, 0, 0, 0, time.UTC) + ds := dataset.Dataset{ID: dataset.NewID(), Slug: "conformance-scores", Title: "Scores", CreatedAt: at} + core.RequireTrue(t, st.DatasetCreate(ds).OK) + item := conformancePairItem(ds.ID, "hi", "hello", at.Add(time.Second)) + core.RequireTrue(t, st.ItemAppend(item).OK) + + core.AssertFalse(t, st.ScoreAppend(conformanceScore("missing-"+dataset.NewID(), dataset.ScoreKindLEK, 80, at)).OK, "unknown item id must fail") + invalid := conformanceScore(item.ID, dataset.ScoreKindLEK, 80, at) + invalid.ScorerName = "" + core.AssertFalse(t, st.ScoreAppend(invalid).OK, "an invalid score must fail validation before touching storage") + + core.RequireTrue(t, st.ScoreAppend(conformanceScore(item.ID, dataset.ScoreKindLEK, 40, at.Add(2*time.Second))).OK) + core.RequireTrue(t, st.ScoreAppend(conformanceScore(item.ID, dataset.ScoreKindLEK, 90, at.Add(3*time.Second))).OK) + scores := core.MustCast[[]dataset.Score](st.Scores(item.ID)) + core.AssertLen(t, scores, 2) + core.AssertEqual(t, 40.0, scores[0].Value, "oldest first") + + core.AssertFalse(t, st.Scores("missing-"+dataset.NewID()).OK, "scores for an unknown item must fail") + + // ScoreExpression latest-wins, exercised through Items(Score: expr) — + // the only Store-level surface for ScoreExpression.Matches: the + // LATEST lek score (90) satisfies >=80 even though an earlier score + // (40) does not. + matching := core.MustCast[[]dataset.Item](st.Items(dataset.ItemFilter{ + DatasetID: ds.ID, Score: &dataset.ScoreExpression{Kind: dataset.ScoreKindLEK, Op: dataset.OpGTE, Threshold: 80}, + })) + core.AssertLen(t, matching, 1, "the latest lek score (90) satisfies >=80") + notMatching := core.MustCast[[]dataset.Item](st.Items(dataset.ItemFilter{ + DatasetID: ds.ID, Score: &dataset.ScoreExpression{Kind: dataset.ScoreKindLEK, Op: dataset.OpGTE, Threshold: 95}, + })) + core.AssertLen(t, notMatching, 0, "95 exceeds even the latest score") + noSycophancy := core.MustCast[[]dataset.Item](st.Items(dataset.ItemFilter{ + DatasetID: ds.ID, Score: &dataset.ScoreExpression{Kind: dataset.ScoreKindSycophancy, Op: dataset.OpGTE, Threshold: 0}, + })) + core.AssertLen(t, noSycophancy, 0, "an absent score kind never matches, even threshold >=0") + + // Tie-break: two scores at the SAME created_at — the later-appended + // (second ScoreAppend call) value must win, matching + // dataset.ScoreExpression.Matches' insertion-order tie-break rule. + // A naive "ORDER BY created_at DESC LIMIT 1" SQL implementation with + // no tiebreak column would get this non-deterministic. + item2 := conformancePairItem(ds.ID, "hi", "hello-2", at.Add(4*time.Second)) + core.RequireTrue(t, st.ItemAppend(item2).OK) + tie := at.Add(5 * time.Second) + core.RequireTrue(t, st.ScoreAppend(conformanceScore(item2.ID, dataset.ScoreKindLEK, 10, tie)).OK) + core.RequireTrue(t, st.ScoreAppend(conformanceScore(item2.ID, dataset.ScoreKindLEK, 90, tie)).OK) + tieMatch := core.MustCast[[]dataset.Item](st.Items(dataset.ItemFilter{ + DatasetID: ds.ID, Score: &dataset.ScoreExpression{Kind: dataset.ScoreKindLEK, Op: dataset.OpEQ, Threshold: 90}, + })) + found := false + for _, it := range tieMatch { + if it.ID == item2.ID { + found = true + } + } + core.AssertTrue(t, found, "on a created_at tie the later-inserted score (90) must win, matching MemoryStore's append-order rule") +} + +func testReviewLatestWins(t *testing.T, st dataset.Store) { + t.Helper() + at := time.Date(2026, time.July, 19, 13, 0, 0, 0, time.UTC) + ds := dataset.Dataset{ID: dataset.NewID(), Slug: "conformance-reviews", Title: "Reviews", CreatedAt: at} + core.RequireTrue(t, st.DatasetCreate(ds).OK) + item := conformancePairItem(ds.ID, "hi", "hello", at.Add(time.Second)) + core.RequireTrue(t, st.ItemAppend(item).OK) + other := conformancePairItem(ds.ID, "hi", "hello-other", at.Add(2*time.Second)) + core.RequireTrue(t, st.ItemAppend(other).OK) + + pending := core.MustCast[dataset.Review](st.ReviewLatest(item.ID)) + core.AssertEqual(t, dataset.StatusPending, pending.Status, "pending is the implicit starting state") + + core.AssertFalse(t, st.ReviewAppend(dataset.Review{ItemID: "missing-" + dataset.NewID(), Status: dataset.StatusApproved, Reviewer: "snider", CreatedAt: at}).OK, "unknown item id must fail") + core.AssertFalse(t, st.ReviewLatest("missing-"+dataset.NewID()).OK, "reviewing an unknown item must fail") + + core.RequireTrue(t, st.ReviewAppend(dataset.Review{ItemID: item.ID, Status: dataset.StatusQuarantined, Reviewer: "auto:welfare", CreatedAt: at.Add(3 * time.Second)}).OK) + core.RequireTrue(t, st.ReviewAppend(dataset.Review{ItemID: item.ID, Status: dataset.StatusApproved, Reviewer: "snider", CreatedAt: at.Add(4 * time.Second)}).OK) + latest := core.MustCast[dataset.Review](st.ReviewLatest(item.ID)) + core.AssertEqual(t, dataset.StatusApproved, latest.Status, "the later-timestamped review wins") + + core.RequireTrue(t, st.ReviewAppend(dataset.Review{ItemID: other.ID, Status: dataset.StatusRejected, Reviewer: "snider", CreatedAt: at.Add(8 * time.Second)}).OK) + + // Tie-break: two reviews at the SAME created_at — the later-appended + // (second ReviewAppend call) status must win, matching + // MemoryStore.latestReviewLocked's insertion-order rule. Review + // carries no ID in the domain model, so this is the one place a + // naive SQL implementation (ORDER BY created_at DESC LIMIT 1, no + // tiebreak) would be genuinely non-deterministic rather than merely + // wrong. + tie := at.Add(9 * time.Second) + core.RequireTrue(t, st.ReviewAppend(dataset.Review{ItemID: item.ID, Status: dataset.StatusRejected, Reviewer: "auto:threshold", CreatedAt: tie}).OK) + core.RequireTrue(t, st.ReviewAppend(dataset.Review{ItemID: item.ID, Status: dataset.StatusApproved, Reviewer: "snider", CreatedAt: tie}).OK) + tieLatest := core.MustCast[dataset.Review](st.ReviewLatest(item.ID)) + core.AssertEqual(t, dataset.StatusApproved, tieLatest.Status, "on a created_at tie the later-inserted review wins") + + otherLatest := core.MustCast[dataset.Review](st.ReviewLatest(other.ID)) + core.AssertEqual(t, dataset.StatusRejected, otherLatest.Status, "the other item's own review is unaffected by item's tie-break append") +} + +func testExportLifecycle(t *testing.T, st dataset.Store) { + t.Helper() + at := time.Date(2026, time.July, 19, 14, 0, 0, 0, time.UTC) + ds := dataset.Dataset{ID: dataset.NewID(), Slug: "conformance-exports", Title: "Exports", CreatedAt: at} + core.RequireTrue(t, st.DatasetCreate(ds).OK) + + exp1 := dataset.Export{ID: dataset.NewID(), DatasetID: ds.ID, Format: dataset.FormatPairsJSONL, OutputPath: "/tmp/a.jsonl", ManifestHash: "hash-a", CreatedAt: at.Add(2 * time.Second)} + exp2 := dataset.Export{ID: dataset.NewID(), DatasetID: ds.ID, Format: dataset.FormatSFTJSONL, OutputPath: "/tmp/b.jsonl", ManifestHash: "hash-b", CreatedAt: at.Add(time.Second)} + core.RequireTrue(t, st.ExportAppend(exp1).OK) + core.RequireTrue(t, st.ExportAppend(exp2).OK) + + core.AssertFalse(t, st.ExportAppend(dataset.Export{ID: dataset.NewID(), DatasetID: "missing-" + dataset.NewID(), Format: dataset.FormatPairsJSONL, OutputPath: "/tmp/c.jsonl", ManifestHash: "hash-c", CreatedAt: at}).OK, "unknown dataset id must fail") + + // Unlike DatasetCreate/ItemAppend, ExportAppend's interface doc only + // promises "fails if DatasetID does not exist" — no id-uniqueness + // clause — and MemoryStore's implementation has no id-collision + // check either. Re-appending the same Export id must succeed on both + // backends (a real conformance finding, not an assumption). + redundant := core.MustCast[dataset.Export](st.ExportAppend(exp1)) + core.AssertEqual(t, exp1.ID, redundant.ID) + + list := core.MustCast[[]dataset.Export](st.Exports(ds.ID)) + core.AssertLen(t, list, 3, "ExportAppend does not enforce id uniqueness, matching MemoryStore") + core.AssertEqual(t, exp2.ID, list[0].ID, "oldest first") + + core.AssertFalse(t, st.Exports("missing-"+dataset.NewID()).OK, "exports for an unknown dataset must fail") +} + +func testNotFoundCodes(t *testing.T, st dataset.Store) { + t.Helper() + at := time.Date(2026, time.July, 19, 15, 0, 0, 0, time.UTC) + cases := []struct { + name string + code string + r core.Result + }{ + {"Dataset", "dataset.dataset.notfound", st.Dataset("missing-" + dataset.NewID())}, + {"DatasetBySlug", "dataset.dataset.notfound", st.DatasetBySlug("missing-slug-" + dataset.NewID())}, + {"DatasetArchive", "dataset.dataset.notfound", st.DatasetArchive("missing-" + dataset.NewID())}, + {"ItemAppend", "dataset.dataset.notfound", st.ItemAppend(conformancePairItem("missing-"+dataset.NewID(), "x", "y", at))}, + {"Item", "dataset.item.notfound", st.Item("missing-" + dataset.NewID())}, + {"Scores", "dataset.item.notfound", st.Scores("missing-" + dataset.NewID())}, + {"ScoreAppend", "dataset.item.notfound", st.ScoreAppend(conformanceScore("missing-"+dataset.NewID(), dataset.ScoreKindLEK, 1, at))}, + {"ReviewLatest", "dataset.item.notfound", st.ReviewLatest("missing-" + dataset.NewID())}, + {"ReviewAppend", "dataset.item.notfound", st.ReviewAppend(dataset.Review{ItemID: "missing-" + dataset.NewID(), Status: dataset.StatusApproved, Reviewer: "snider", CreatedAt: at})}, + {"Exports", "dataset.dataset.notfound", st.Exports("missing-" + dataset.NewID())}, + {"ExportAppend", "dataset.dataset.notfound", st.ExportAppend(dataset.Export{ID: dataset.NewID(), DatasetID: "missing-" + dataset.NewID(), Format: dataset.FormatPairsJSONL, OutputPath: "/tmp/x.jsonl", ManifestHash: "h", CreatedAt: at})}, + } + for _, c := range cases { + core.AssertFalse(t, c.r.OK, c.name+" must fail") + core.AssertEqual(t, c.code, c.r.Code(), c.name+" error code") + } +} + +// ---- temp-root isolation ---- + +// TestDuckDatasetStore_TempRootIsolation proves two DuckDB dataset stores +// opened at different roots never share state — a fresh t.TempDir() per +// store is a real isolation boundary — and that datasets.duckdb lands at +// the path paths.go resolves without ever touching lem.duckdb (the +// design's "separate file" decision, checked structurally, not just by +// naming convention). +func TestDuckDatasetStore_TempRootIsolation(t *testing.T) { + rootA := t.TempDir() + rootB := t.TempDir() + + pathsA := core.MustCast[appPaths](appPathsAt(rootA)) + pathsB := core.MustCast[appPaths](appPathsAt(rootB)) + core.AssertNotEqual(t, pathsA.Datasets, pathsB.Datasets, "each root resolves its own datasets.duckdb path") + + storeA := newTestDuckDatasetStore(t, pathsA.Datasets) + defer closeTestDuckDatasetStore(t, storeA) + storeB := newTestDuckDatasetStore(t, pathsB.Datasets) + defer closeTestDuckDatasetStore(t, storeB) + + if _, err := os.Stat(pathsA.Datasets); err != nil { + t.Fatalf("datasets.duckdb was not created at the resolved path: %v", err) + } + if _, err := os.Stat(pathsB.Datasets); err != nil { + t.Fatalf("datasets.duckdb was not created at the resolved path: %v", err) + } + + at := time.Date(2026, time.July, 19, 16, 0, 0, 0, time.UTC) + core.RequireTrue(t, storeA.DatasetCreate(dataset.Dataset{ID: dataset.NewID(), Slug: "isolation-only-in-a", Title: "A", CreatedAt: at}).OK) + + core.AssertFalse(t, storeB.DatasetBySlug("isolation-only-in-a").OK, "store B must not see store A's dataset") + core.AssertLen(t, core.MustCast[[]dataset.Dataset](storeA.Datasets(false)), 1) + core.AssertLen(t, core.MustCast[[]dataset.Dataset](storeB.Datasets(false)), 0, "store B's dataset list must stay empty") + + if _, err := os.Stat(pathsA.Database); !os.IsNotExist(err) { + t.Fatalf("opening the dataset store must not create lem.duckdb: stat err = %v", err) + } +} + +// ---- OpenDatasetStore: the CLI-facing entry point (cli/data.go, the +// serve/ssd capture taps) ---- + +// TestOpenDatasetStore_Good proves the exported entry point resolves the +// $HOME/.lem root (no --home flag surface — HOME is the only seam, +// exactly as cli/serve_test.go redirects the admin-token path), creates +// datasets.duckdb there, and that a second open against the SAME HOME +// reopens the same file rather than a fresh one (migrations are +// idempotent on reopen; a dataset created in the first open is still +// there in the second). +func TestOpenDatasetStore_Good(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + first := OpenDatasetStore() + core.RequireTrue(t, first.OK, "OpenDatasetStore first open") + store1, ok := first.Value.(DatasetStore) + if !ok { + t.Fatalf("OpenDatasetStore value = %T, want DatasetStore", first.Value) + } + + at := time.Date(2026, time.July, 19, 17, 0, 0, 0, time.UTC) + core.RequireTrue(t, store1.DatasetCreate(dataset.Dataset{ID: dataset.NewID(), Slug: "reopen-proof", Title: "Reopen proof", CreatedAt: at}).OK) + core.RequireTrue(t, store1.Close().OK) + + wantPath := core.Path(home, ".lem", "datasets.duckdb") + if _, err := os.Stat(wantPath); err != nil { + t.Fatalf("datasets.duckdb missing at the resolved default path %s: %v", wantPath, err) + } + + second := OpenDatasetStore() + core.RequireTrue(t, second.OK, "OpenDatasetStore second open") + store2, ok := second.Value.(DatasetStore) + if !ok { + t.Fatalf("OpenDatasetStore value = %T, want DatasetStore", second.Value) + } + defer func() { core.RequireTrue(t, store2.Close().OK) }() + + core.RequireTrue(t, store2.DatasetBySlug("reopen-proof").OK, "the dataset created through the first handle must survive a reopen") +} + +// TestOpenDatasetStore_Bad proves a root that cannot be prepared (HOME +// points at a regular file, so $HOME/.lem cannot be created) fails +// closed rather than opening a bogus store — mirrors +// TestAppFiles_Ugly's fixture trick one layer up. +func TestOpenDatasetStore_Bad(t *testing.T) { + blocker := core.Path(t.TempDir(), "home-is-a-file") + if err := os.WriteFile(blocker, []byte("not a directory"), 0o600); err != nil { + t.Fatalf("write blocker fixture: %v", err) + } + t.Setenv("HOME", blocker) + + result := OpenDatasetStore() + core.AssertFalse(t, result.OK, "OpenDatasetStore over a blocked root must fail") +} diff --git a/cli/tui/export.go b/cli/tui/export.go new file mode 100644 index 000000000..b38530780 --- /dev/null +++ b/cli/tui/export.go @@ -0,0 +1,363 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package tui + +import ( + "time" + + core "dappco.re/go" + coreio "dappco.re/go/io" +) + +type exportFormat string + +const ( + exportMarkdown exportFormat = "markdown" + exportJSON exportFormat = "json" +) + +type sessionExporter interface { + Export(medium coreio.Medium, directory, sessionID string, format exportFormat) core.Result +} + +type exportReceipt struct { + SessionID string `json:"session_id"` + Title string `json:"title"` + Path string `json:"path"` + Format exportFormat `json:"format"` + Bytes int `json:"bytes"` + ExportedAt time.Time `json:"exported_at"` +} + +type sessionExport struct { + Session sessionRecord `json:"session"` + Turns []turnRecord `json:"turns"` + Events []eventRecord `json:"events"` + Artifacts []artifactRecord `json:"artifacts"` + Attachments []attachmentRecord `json:"attachments"` + ExportedAt time.Time `json:"exported_at"` +} + +type workspaceSessionExporter struct { + repository workspaceRepository + showThinking bool + now func() time.Time + ids func() string +} + +func newWorkspaceSessionExporter( + repository workspaceRepository, + showThinking bool, + now func() time.Time, + ids func() string, +) sessionExporter { + if now == nil { + now = time.Now + } + if ids == nil { + ids = newRecordID + } + return &workspaceSessionExporter{ + repository: repository, showThinking: showThinking, now: now, ids: ids, + } +} + +func (exporter *workspaceSessionExporter) Export( + medium coreio.Medium, + directory string, + sessionID string, + format exportFormat, +) core.Result { + if exporter == nil || exporter.repository == nil { + return core.Fail(core.E("tui.sessionExporter.Export", "workspace repository is unavailable", nil)) + } + if medium == nil { + return core.Fail(core.E("tui.sessionExporter.Export", "export medium is required", nil)) + } + sessionID = core.Trim(sessionID) + if sessionID == "" { + return core.Fail(core.E("tui.sessionExporter.Export", "session ID is required", nil)) + } + directory = trimMediumPath(directory) + if directory == ".." || core.HasPrefix(directory, "../") || core.Contains(directory, "/../") { + return core.Fail(core.E("tui.sessionExporter.Export", core.Concat("unsafe export directory: ", directory), nil)) + } + if format != exportMarkdown && format != exportJSON { + return core.Fail(core.E("tui.sessionExporter.Export", core.Concat("unsupported export format: ", string(format)), nil)) + } + loaded := exporter.load(sessionID) + if !loaded.OK { + return loaded + } + payload := loaded.Value.(sessionExport) + payload.ExportedAt = exporter.now().UTC() + contentResult := exporter.render(payload, format) + if !contentResult.OK { + return contentResult + } + content := contentResult.String() + if directory != "" { + if err := medium.EnsureDir(directory); err != nil { + return core.Fail(core.E( + "tui.sessionExporter.Export", + core.Concat("create export directory ", directory), + err, + )) + } + } + path := collisionSafeExportPath(medium, directory, payload.Session, payload.ExportedAt, format) + temporaryID := exportPathToken(exporter.ids()) + if temporaryID == "" { + temporaryID = "pending" + } + temporaryPath := joinMediumPath(directory, core.Concat(".", core.PathBase(path), ".tmp-", temporaryID)) + if err := medium.WriteMode(temporaryPath, content, 0600); err != nil { + if medium.Exists(temporaryPath) { + if cleanupErr := medium.Delete(temporaryPath); cleanupErr != nil { + core.Warn("tui.export.temporary_cleanup", "path", temporaryPath, "error", cleanupErr) + } + } + return core.Fail(core.E( + "tui.sessionExporter.Export", + core.Concat("write export ", temporaryPath), + err, + )) + } + if err := medium.Rename(temporaryPath, path); err != nil { + if cleanupErr := medium.Delete(temporaryPath); cleanupErr != nil { + core.Warn("tui.export.temporary_cleanup", "path", temporaryPath, "error", cleanupErr) + } + return core.Fail(core.E( + "tui.sessionExporter.Export", + core.Concat("commit export ", path), + err, + )) + } + return core.Ok(exportReceipt{ + SessionID: payload.Session.ID, + Title: payload.Session.Title, + Path: path, + Format: format, + Bytes: len(content), + ExportedAt: payload.ExportedAt, + }) +} + +func (exporter *workspaceSessionExporter) load(sessionID string) core.Result { + sessionResult := exporter.repository.Session(sessionID) + if !sessionResult.OK { + return sessionResult + } + session, ok := sessionResult.Value.(sessionRecord) + if !ok { + return core.Fail(core.E("tui.sessionExporter.load", "invalid session result", nil)) + } + turnResult := exporter.repository.Turns(sessionID) + if !turnResult.OK { + return turnResult + } + turns, ok := turnResult.Value.([]turnRecord) + if !ok { + return core.Fail(core.E("tui.sessionExporter.load", "invalid turn result", nil)) + } + turns = append([]turnRecord(nil), turns...) + if !exporter.showThinking { + for index := range turns { + turns[index].Thought = "" + } + } + eventResult := exporter.repository.Events(sessionID) + if !eventResult.OK { + return eventResult + } + events, ok := eventResult.Value.([]eventRecord) + if !ok { + return core.Fail(core.E("tui.sessionExporter.load", "invalid event result", nil)) + } + artifactResult := exporter.repository.Artifacts(sessionID) + if !artifactResult.OK { + return artifactResult + } + artifacts, ok := artifactResult.Value.([]artifactRecord) + if !ok { + return core.Fail(core.E("tui.sessionExporter.load", "invalid artifact result", nil)) + } + attachmentResult := exporter.repository.Attachments(sessionID) + if !attachmentResult.OK { + return attachmentResult + } + attachments, ok := attachmentResult.Value.([]attachmentRecord) + if !ok { + return core.Fail(core.E("tui.sessionExporter.load", "invalid attachment result", nil)) + } + return core.Ok(sessionExport{ + Session: session, + Turns: turns, + Events: append([]eventRecord(nil), events...), + Artifacts: append([]artifactRecord(nil), artifacts...), + Attachments: append([]attachmentRecord(nil), attachments...), + }) +} + +func (exporter *workspaceSessionExporter) render(payload sessionExport, format exportFormat) core.Result { + if format == exportJSON { + marshaled := core.JSONMarshalIndent(payload, "", " ") + if !marshaled.OK { + return core.Fail(core.E("tui.sessionExporter.render", "marshal JSON export", resultError(marshaled))) + } + return core.Ok(core.AsString(marshaled.Bytes()) + "\n") + } + return core.Ok(renderSessionMarkdown(payload)) +} + +func renderSessionMarkdown(payload sessionExport) string { + builder := core.NewBuilder() + builder.WriteString("# ") + builder.WriteString(payload.Session.Title) + builder.WriteString("\n\n") + builder.WriteString("- Session: `") + builder.WriteString(payload.Session.ID) + builder.WriteString("`\n- Status: ") + builder.WriteString(payload.Session.Status) + builder.WriteString("\n- Model: ") + if payload.Session.PreferredModel == "" { + builder.WriteString("not selected") + } else { + builder.WriteString(payload.Session.PreferredModel) + } + builder.WriteString("\n- Exported: ") + builder.WriteString(payload.ExportedAt.Format(time.RFC3339)) + builder.WriteString("\n\n## Conversation\n") + for _, turn := range payload.Turns { + builder.WriteString("\n### ") + builder.WriteString(exportRoleTitle(turn.Role)) + builder.WriteString("\n\n") + if turn.Thought != "" { + builder.WriteString("_Thought:_ ") + builder.WriteString(turn.Thought) + builder.WriteString("\n\n") + } + if turn.Visible != "" { + builder.WriteString(turn.Visible) + builder.WriteString("\n") + } + hasCall := turn.ToolCallJSON != "" && turn.ToolCallJSON != "{}" + hasResult := turn.ToolResultJSON != "" && turn.ToolResultJSON != "{}" + if turn.ToolName != "" || hasCall || hasResult { + builder.WriteString("\n**Tool receipt**\n\n") + if turn.ToolName != "" { + builder.WriteString("- Tool: `") + builder.WriteString(turn.ToolName) + builder.WriteString("`\n") + } + if hasCall { + builder.WriteString("- Call: `") + builder.WriteString(turn.ToolCallJSON) + builder.WriteString("`\n") + } + if hasResult { + builder.WriteString("- Result: `") + builder.WriteString(turn.ToolResultJSON) + builder.WriteString("`\n") + } + } + } + if len(payload.Attachments) > 0 { + builder.WriteString("\n## Knowledge snapshots\n") + for _, attachment := range payload.Attachments { + builder.WriteString("\n### ") + builder.WriteString(attachment.Title) + builder.WriteString("\n\nSource: `") + builder.WriteString(attachment.SourcePath) + builder.WriteString("`\n\n") + builder.WriteString(attachment.Snapshot) + builder.WriteString("\n") + } + } + if len(payload.Events) > 0 { + builder.WriteString("\n## Events\n") + for _, event := range payload.Events { + builder.WriteString("\n- **") + builder.WriteString(event.Title) + builder.WriteString("** (`") + builder.WriteString(event.Kind) + builder.WriteString("`) — ") + builder.WriteString(event.Detail) + } + builder.WriteString("\n") + } + if len(payload.Artifacts) > 0 { + builder.WriteString("\n## Artifacts\n") + for _, artifact := range payload.Artifacts { + builder.WriteString("\n- ") + builder.WriteString(artifact.Title) + builder.WriteString(": ") + builder.WriteString(artifact.Path) + } + builder.WriteString("\n") + } + return builder.String() +} + +func collisionSafeExportPath( + medium coreio.Medium, + directory string, + session sessionRecord, + exportedAt time.Time, + format exportFormat, +) string { + extension := ".md" + if format == exportJSON { + extension = ".json" + } + base := core.Concat( + exportedAt.UTC().Format("20060102T150405Z"), "-", + exportPathToken(session.Title), "-", + shortExportID(session.ID), + ) + if base == "" { + base = "session" + } + path := joinMediumPath(directory, base+extension) + for suffix := 2; medium.Exists(path); suffix++ { + path = joinMediumPath(directory, core.Sprintf("%s-%d%s", base, suffix, extension)) + } + return path +} + +func exportPathToken(value string) string { + value = core.Lower(core.Trim(value)) + builder := core.NewBuilder() + dash := false + for _, character := range value { + if character >= 'a' && character <= 'z' || character >= '0' && character <= '9' { + builder.WriteRune(character) + dash = false + continue + } + if builder.Len() > 0 && !dash { + builder.WriteByte('-') + dash = true + } + } + return core.TrimSuffix(builder.String(), "-") +} + +func shortExportID(sessionID string) string { + token := exportPathToken(sessionID) + if len(token) > 8 { + return token[:8] + } + return token +} + +func exportRoleTitle(role string) string { + switch core.Lower(role) { + case "user": + return "You" + case "tool": + return "Tool result" + default: + return "Assistant" + } +} diff --git a/cli/tui/export_test.go b/cli/tui/export_test.go new file mode 100644 index 000000000..33d2adfd5 --- /dev/null +++ b/cli/tui/export_test.go @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package tui + +import ( + "errors" + "io/fs" + "strings" + "testing" + "time" + + core "dappco.re/go" + coreio "dappco.re/go/io" +) + +func TestExportSessionMarkdown_Good(t *testing.T) { + repository, session := exportFixture(t, "11111111-1111-4111-8111-111111111111", "A thoughtful session") + defer closeTestDuckRepository(t, repository) + medium := coreio.NewMemoryMedium() + now := time.Date(2026, time.July, 17, 21, 30, 0, 0, time.UTC) + exporter := newWorkspaceSessionExporter(repository, true, func() time.Time { return now }, newRecordID) + result := exporter.Export(medium, "exports", session.ID, exportMarkdown) + if !result.OK { + t.Fatalf("Export Markdown: %v", result.Value) + } + receipt := result.Value.(exportReceipt) + content, err := medium.Read(receipt.Path) + if err != nil { + t.Fatalf("read export %s: %v", receipt.Path, err) + } + for _, want := range []string{ + "A thoughtful session", "Visible answer", "private thought", "word_count", + "2 words", "Knowledge snapshot", "Runner started", "https://example.test/pr/1", + } { + if !strings.Contains(content, want) { + t.Fatalf("Markdown export missing %q:\n%s", want, content) + } + } + withoutThoughts := newWorkspaceSessionExporter(repository, false, func() time.Time { return now.Add(time.Second) }, newRecordID) + hidden := withoutThoughts.Export(medium, "exports", session.ID, exportMarkdown) + if !hidden.OK { + t.Fatalf("Export without thoughts: %v", hidden.Value) + } + hiddenContent, _ := medium.Read(hidden.Value.(exportReceipt).Path) + if strings.Contains(hiddenContent, "private thought") { + t.Fatalf("thought preference was ignored:\n%s", hiddenContent) + } + + palette := newCommandPalette(newUIStyles(midnightTheme())) + palette.SetExporter(exporter, medium, "exports") + a := newApp("", 0, 64) + a.palette = palette + a.repository = repository + a.sessionID = session.ID + if invoked := palette.Invoke(commandExportMarkdown, &a); !invoked.OK { + t.Fatalf("palette export: %v", invoked.Value) + } + if invoked := palette.Invoke(commandExportJSON, &a); !invoked.OK { + t.Fatalf("palette JSON export: %v", invoked.Value) + } + artifacts := repository.Artifacts(session.ID) + if !artifacts.OK || len(artifacts.Value.([]artifactRecord)) != 3 { + t.Fatalf("export artifact persistence = %#v", artifacts.Value) + } +} + +func TestExportSessionJSON_Good(t *testing.T) { + repository, session := exportFixture(t, "22222222-2222-4222-8222-222222222222", "Structured session") + defer closeTestDuckRepository(t, repository) + medium := coreio.NewMemoryMedium() + exporter := newWorkspaceSessionExporter(repository, true, func() time.Time { + return time.Date(2026, time.July, 17, 22, 0, 0, 0, time.UTC) + }, newRecordID) + result := exporter.Export(medium, "exports", session.ID, exportJSON) + if !result.OK { + t.Fatalf("Export JSON: %v", result.Value) + } + content, err := medium.Read(result.Value.(exportReceipt).Path) + if err != nil { + t.Fatalf("read JSON export: %v", err) + } + var decoded sessionExport + if unmarshaled := core.JSONUnmarshalString(content, &decoded); !unmarshaled.OK { + t.Fatalf("unmarshal JSON export: %v", unmarshaled.Value) + } + if decoded.Session.ID != session.ID || len(decoded.Turns) != 2 || len(decoded.Events) != 1 || len(decoded.Artifacts) != 1 || len(decoded.Attachments) != 1 { + t.Fatalf("decoded export = %#v", decoded) + } +} + +func TestExportSessionSelectedMedium_Good(t *testing.T) { + repository, session := exportFixture(t, "33333333-3333-4333-8333-333333333333", "Selected destination") + defer closeTestDuckRepository(t, repository) + defaultMedium := coreio.NewMemoryMedium() + selectedMedium := coreio.NewMemoryMedium() + exporter := newWorkspaceSessionExporter(repository, true, time.Now, newRecordID) + result := exporter.Export(selectedMedium, "chosen", session.ID, exportMarkdown) + if !result.OK { + t.Fatalf("selected-medium export: %v", result.Value) + } + if !selectedMedium.Exists(result.Value.(exportReceipt).Path) { + t.Fatal("selected destination did not receive export") + } + entries, err := defaultMedium.List("") + if err != nil || len(entries) != 0 { + t.Fatalf("default destination changed: entries=%#v err=%v", entries, err) + } +} + +func TestExportSession_Bad(t *testing.T) { + repository, session := exportFixture(t, "44444444-4444-4444-8444-444444444444", "Read only destination") + defer closeTestDuckRepository(t, repository) + medium := &failingExportMedium{Medium: coreio.NewMemoryMedium(), reason: errors.New("read-only medium")} + exporter := newWorkspaceSessionExporter(repository, true, time.Now, newRecordID) + palette := newCommandPalette(newUIStyles(midnightTheme())) + palette.SetExporter(exporter, medium, "exports") + a := newApp("", 0, 64) + a.palette = palette + a.repository = repository + a.sessionID = session.ID + before := repository.Artifacts(session.ID).Value.([]artifactRecord) + result := palette.Invoke(commandExportMarkdown, &a) + if result.OK || !strings.Contains(result.Error(), "exports/") || !strings.Contains(result.Error(), "read-only medium") { + t.Fatalf("failed export result = %#v", result) + } + after := repository.Artifacts(session.ID).Value.([]artifactRecord) + if len(after) != len(before) { + t.Fatalf("failed export mutated artifacts: before=%#v after=%#v", before, after) + } +} + +func TestExportSession_Ugly(t *testing.T) { + repository := openTestDuckRepository(t) + defer closeTestDuckRepository(t, repository) + now := time.Date(2026, time.July, 17, 23, 0, 0, 0, time.UTC) + first := testSessionRecord("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", "Same title", now) + second := testSessionRecord("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", "Same title", now) + for _, session := range []sessionRecord{first, second} { + if result := repository.SaveSession(session); !result.OK { + t.Fatalf("save session: %v", result.Value) + } + } + medium := coreio.NewMemoryMedium() + exporter := newWorkspaceSessionExporter(repository, true, func() time.Time { return now }, newRecordID) + firstResult := exporter.Export(medium, "exports", first.ID, exportMarkdown) + secondResult := exporter.Export(medium, "exports", second.ID, exportMarkdown) + if !firstResult.OK || !secondResult.OK { + t.Fatalf("collision exports: first=%#v second=%#v", firstResult.Value, secondResult.Value) + } + firstPath := firstResult.Value.(exportReceipt).Path + secondPath := secondResult.Value.(exportReceipt).Path + if firstPath == secondPath || !medium.Exists(firstPath) || !medium.Exists(secondPath) { + t.Fatalf("collision paths = %q / %q", firstPath, secondPath) + } +} + +func exportFixture(t *testing.T, sessionID, title string) (workspaceRepository, sessionRecord) { + t.Helper() + repository := openTestDuckRepository(t) + now := time.Date(2026, time.July, 17, 21, 0, 0, 0, time.UTC) + session := testSessionRecord(sessionID, title, now) + if result := repository.SaveSession(session); !result.OK { + t.Fatalf("save export session: %v", result.Value) + } + turns := []turnRecord{ + testTurnRecord("turn-user-"+sessionID[:8], session.ID, 1, "user", "Question", now), + { + ID: "turn-assistant-" + sessionID[:8], SessionID: session.ID, Sequence: 2, Role: "assistant", + Visible: "Visible answer", Thought: "private thought", ToolName: "word_count", + ToolCallJSON: `{"name":"word_count","text":"two words"}`, ToolResultJSON: `{"result":"2 words"}`, + Model: "fixture-model", CreatedAt: now.Add(time.Minute), UpdatedAt: now.Add(time.Minute), + }, + } + for _, turn := range turns { + if result := repository.SaveTurn(turn); !result.OK { + t.Fatalf("save export turn: %v", result.Value) + } + } + event := eventRecord{ + ID: "event-" + sessionID[:8], SessionID: session.ID, Kind: "runner.started", Status: "completed", + Title: "Runner started", Detail: "local", PayloadJSON: `{}`, CreatedAt: now.Add(2 * time.Minute), + } + if result := repository.SaveEvent(event); !result.OK { + t.Fatalf("save export event: %v", result.Value) + } + artifact := artifactRecord{ + ID: "artifact-" + sessionID[:8], SessionID: session.ID, Kind: "pull_request", + Path: "https://example.test/pr/1", Title: "Pull request", MetadataJSON: `{}`, + CreatedAt: now.Add(3 * time.Minute), ArchivedAt: unsetRecordTime(), + } + if result := repository.SaveArtifact(artifact); !result.OK { + t.Fatalf("save export artifact: %v", result.Value) + } + attachment := attachmentRecord{ + ID: "attachment-" + sessionID[:8], SessionID: session.ID, SourcePath: "local:packs/knowledge.md", + Title: "Knowledge", ContentHash: core.SHA256HexString("Knowledge snapshot"), Snapshot: "Knowledge snapshot", + AddedAt: now.Add(4 * time.Minute), LastCheckedAt: now.Add(4 * time.Minute), ArchivedAt: unsetRecordTime(), + } + if result := repository.SaveAttachment(attachment); !result.OK { + t.Fatalf("save export attachment: %v", result.Value) + } + return repository, session +} + +type failingExportMedium struct { + coreio.Medium + reason error +} + +func (medium *failingExportMedium) WriteMode(string, string, fs.FileMode) error { + return medium.reason +} diff --git a/cli/tui/inspector.go b/cli/tui/inspector.go new file mode 100644 index 000000000..077665675 --- /dev/null +++ b/cli/tui/inspector.go @@ -0,0 +1,381 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package tui + +import core "dappco.re/go" + +type inspectorControl uint8 + +const ( + inspectorControlContext inspectorControl = iota + inspectorControlMaxTokens + inspectorControlThinking + inspectorControlTheme + inspectorControlMode + inspectorControlTools + inspectorControlCount +) + +var inspectorThemes = []string{"midnight", "aurora", "daylight"} + +type inspectorState struct { + cursor int + dirty bool + theme string + runtime runtimeInspection + knowledge knowledgeInspection +} + +func newInspector() inspectorState { + return inspectorState{theme: "midnight"} +} + +func (inspector inspectorState) Dirty() bool { return inspector.dirty } +func (inspector inspectorState) Theme() string { + if inspector.theme == "" { + return "midnight" + } + return inspector.theme +} + +func (inspector *inspectorState) Select(control inspectorControl) bool { + if inspector == nil || control >= inspectorControlCount { + return false + } + inspector.cursor = int(control) + return true +} + +func (inspector *inspectorState) Move(delta int) { + if inspector == nil { + return + } + inspector.cursor = ((inspector.cursor+delta)%int(inspectorControlCount) + int(inspectorControlCount)) % int(inspectorControlCount) +} + +func (inspector *inspectorState) ApplyRuntime(result core.Result) { + if inspector != nil { + inspector.runtime = runtimeInspectionFrom(result) + } +} + +func (inspector *inspectorState) ApplyKnowledge(result core.Result) { + if inspector != nil { + inspector.knowledge = knowledgeInspectionFrom(result) + } +} + +func (inspector *inspectorState) Adjust(target *app, delta int) core.Result { + if inspector == nil || target == nil { + return core.Fail(core.E("tui.inspector.Adjust", "inspector target is unavailable", nil)) + } + switch inspectorControl(inspector.cursor) { + case inspectorControlContext: + target.cfg.ctxIdx = wrapIndex(target.cfg.ctxIdx, delta, len(ctxSteps)) + case inspectorControlMaxTokens: + target.cfg.maxTokIdx = wrapIndex(target.cfg.maxTokIdx, delta, len(maxTokSteps)) + case inspectorControlThinking: + target.cfg.thinkIdx = wrapIndex(target.cfg.thinkIdx, delta, len(thinkNames)) + case inspectorControlTheme: + index := themeIndex(inspector.Theme()) + inspector.theme = inspectorThemes[wrapIndex(index, delta, len(inspectorThemes))] + case inspectorControlMode: + target.modes = target.modes.move(delta) + case inspectorControlTools: + switch { + case delta < 0: + target.tools.setEnabled(false) + case delta > 0: + target.tools.setEnabled(true) + default: + target.tools.toggle() + } + default: + return core.Fail(core.E("tui.inspector.Adjust", "unknown inspector control", nil)) + } + inspector.dirty = true + return core.Ok(nil) +} + +func (inspector *inspectorState) Save(target *app) core.Result { + if inspector == nil || target == nil || target.preferences == nil { + return core.Fail(core.E("tui.inspector.Save", "preference store is unavailable", nil)) + } + thinking := []string{"model", "on", "off"}[target.cfg.thinkIdx] + values := []struct { + key string + value any + }{ + {preferenceContextLength, target.cfg.contextLen()}, + {preferenceMaxTokens, target.cfg.maxTokens()}, + {preferenceThinking, thinking}, + {preferenceTheme, inspector.Theme()}, + } + for _, value := range values { + if result := target.preferences.Set(value.key, value.value); !result.OK { + return result + } + } + if result := target.preferences.Commit(); !result.OK { + return result + } + target.rebuildTheme(themeForName(inspector.Theme())) + inspector.dirty = false + return core.Ok(nil) +} + +func (inspector inspectorState) View(target app, width, height int) string { + if width <= 0 || height <= 0 { + return "" + } + var builder core.Builder + builder.WriteString(target.styles.title.Render("INSPECTOR") + "\n\n") + switch target.activePanel { + case panelWork: + inspector.renderWork(&builder, target) + case panelModels: + builder.WriteString(target.styles.accent.Render("MODEL DETAIL") + "\n") + if selected, ok := target.picker.SelectedItem().(modelItem); ok { + builder.WriteString(target.styles.title.Render(selected.name) + "\n") + builder.WriteString(target.styles.status.Render(selected.modelType) + "\n") + builder.WriteString(target.styles.thought.Render(selected.path) + "\n\n") + } else { + builder.WriteString(target.styles.status.Render("○ select a discovered model") + "\n\n") + } + builder.WriteString(target.styles.accent.Render("LOADED") + " ") + if target.modelName == "" { + builder.WriteString(target.styles.status.Render("○ none")) + } else { + builder.WriteString(target.styles.success.Render("● " + target.modelName)) + } + case panelService: + builder.WriteString(target.styles.accent.Render("ADDRESS") + "\n") + builder.WriteString(target.styles.title.Render(target.svc.addr()) + "\n\n") + builder.WriteString(target.styles.accent.Render("REQUESTS") + " ") + builder.WriteString(target.styles.title.Render(core.Sprintf("%d", target.svc.requests.Load())) + "\n\n") + builder.WriteString(target.styles.accent.Render("STATE") + " ") + if target.svc.running { + builder.WriteString(target.styles.success.Render("● serving")) + } else { + builder.WriteString(target.styles.status.Render("○ stopped")) + } + default: + inspector.renderChat(&builder, target) + } + return fitPane(builder.String(), width, height, target.styles.inspector) +} + +func (inspector inspectorState) renderWork(builder *core.Builder, target app) { + builder.WriteString(target.styles.accent.Render("WORK DETAIL") + "\n") + if target.work == nil { + builder.WriteString(target.styles.status.Render("○ no work item selected") + "\n\n") + } else if selected, ok := target.work.Selected(); ok { + builder.WriteString(target.styles.title.Render(selected.Title) + "\n") + builder.WriteString(target.styles.status.Render(workGlyph(selected.Status)+" "+core.Upper(selected.Status)) + "\n") + if selected.Repo != "" { + builder.WriteString(target.styles.thought.Render(selected.Repo+" "+selected.Branch) + "\n") + } + if selected.Question != "" { + builder.WriteString(target.styles.attention.Render("? "+selected.Question) + "\n") + } + builder.WriteString("\n") + } else { + builder.WriteString(target.styles.status.Render("○ no work item selected") + "\n\n") + } + + inspector.renderRuntime(builder, target) + + capabilities := []agentCapability{} + if target.work != nil { + capabilities = target.work.Capabilities() + } else if target.agent != nil { + capabilities = target.agent.Capabilities() + } + byFeature := make(map[agentFeature]agentCapability, len(capabilities)) + reason := defaultAgentUnavailableReason + for _, capability := range capabilities { + byFeature[capability.Feature] = capability + if !capability.Available && capability.Reason != "" { + reason = capability.Reason + } + } + builder.WriteString(target.styles.accent.Render("AGENT CAPABILITY") + "\n") + available := 0 + for _, capability := range capabilities { + if capability.Available { + available++ + } + } + if available == 0 { + builder.WriteString(target.styles.attention.Render("○ not installed") + "\n") + builder.WriteString(target.styles.thought.Render(reason) + "\n\n") + } else { + builder.WriteString(target.styles.success.Render(core.Sprintf("● %d actions available", available)) + "\n\n") + } + + selectedFeature := agentFeature("") + if target.work != nil { + selectedFeature = target.work.SelectedAction().Feature + } + for _, group := range agentFeatureGroups { + builder.WriteString(target.styles.accent.Render(group.Title) + "\n") + for _, feature := range group.Features { + capability, exists := byFeature[feature] + if !exists { + capability = agentCapability{Feature: feature, Reason: reason} + } + cursor := " " + if feature == selectedFeature { + cursor = "› " + } + glyph := "○" + style := target.styles.status + if capability.Available { + glyph = "●" + style = target.styles.success + } + builder.WriteString(cursor + style.Render(glyph+" "+agentFeatureTitle(feature)) + "\n") + } + builder.WriteString("\n") + } +} + +func (inspector inspectorState) renderRuntime(builder *core.Builder, target app) { + builder.WriteString(target.styles.accent.Render("RUNTIME") + "\n") + if target.work != nil { + if selected, ok := target.work.Selected(); ok && selected.Runtime != "" { + builder.WriteString(target.styles.title.Render("assigned "+selected.Runtime) + "\n") + } + } + switch { + case !inspector.runtime.ready: + builder.WriteString(target.styles.status.Render("○ detection pending") + "\n\n") + case inspector.runtime.reason != "": + builder.WriteString(target.styles.attention.Render("○ unavailable") + "\n") + builder.WriteString(target.styles.thought.Render(inspector.runtime.reason) + "\n\n") + case len(inspector.runtime.capabilities) == 0: + builder.WriteString(target.styles.status.Render("○ none available") + "\n\n") + default: + for index, capability := range inspector.runtime.capabilities { + marker := "○" + if index == 0 { + marker = "●" + } + name := capability.Name + if capability.Version != "" { + name = core.Concat(name, " ", capability.Version) + } + builder.WriteString(target.styles.success.Render(marker+" "+name) + "\n") + features := runtimeFeatureLabels(capability) + if len(features) > 0 { + builder.WriteString(target.styles.thought.Render(" "+core.Join(" · ", features...)) + "\n") + } + if capability.Path != "" { + builder.WriteString(target.styles.thought.Render(" "+capability.Path) + "\n") + } + } + builder.WriteString("\n") + } +} + +func runtimeFeatureLabels(capability runtimeCapability) []string { + features := make([]string, 0, 6) + if capability.GPU { + features = append(features, "GPU") + } + if capability.NetworkIsolation { + features = append(features, "network isolation") + } + if capability.VolumeMounts { + features = append(features, "volumes") + } + if capability.Encryption { + features = append(features, "encryption") + } + if capability.HardwareIsolation { + features = append(features, "hardware isolation") + } + if capability.SubSecondStart { + features = append(features, "sub-second start") + } + return features +} + +func (inspector inspectorState) renderChat(builder *core.Builder, target app) { + model := "○ none" + if target.modelName != "" { + model = "● " + target.modelName + } + generation := "○ idle" + if target.generating { + generation = "◉ generating" + } + builder.WriteString(target.styles.accent.Render("SESSION") + " ● active\n") + builder.WriteString(target.styles.accent.Render("MODEL") + " " + model + "\n") + builder.WriteString(target.styles.accent.Render("GENERATION") + " " + generation + "\n\n") + builder.WriteString(target.styles.accent.Render("SETTINGS") + "\n") + inspector.renderControl(builder, target, inspectorControlContext, "context", core.Sprintf("%d", target.cfg.contextLen())) + inspector.renderControl(builder, target, inspectorControlMaxTokens, "max tokens", core.Sprintf("%d", target.cfg.maxTokens())) + inspector.renderControl(builder, target, inspectorControlThinking, "thinking", thinkNames[target.cfg.thinkIdx]) + inspector.renderControl(builder, target, inspectorControlTheme, "theme", inspector.Theme()) + builder.WriteString("\n" + target.styles.accent.Render("MODE") + "\n") + inspector.renderControl(builder, target, inspectorControlMode, "sampling", target.modes.current().name) + builder.WriteString("\n" + target.styles.accent.Render("TOOLS") + "\n") + tools := "○ disabled" + if target.tools.enabled { + tools = "● enabled" + } + inspector.renderControl(builder, target, inspectorControlTools, "function calls", tools) + builder.WriteString("\n" + target.styles.accent.Render("KNOWLEDGE") + "\n") + switch { + case !inspector.knowledge.ready: + builder.WriteString(target.styles.status.Render(" ○ discovery pending") + "\n") + case len(inspector.knowledge.documents) == 0: + builder.WriteString(target.styles.status.Render(" ○ no local documents") + "\n") + default: + builder.WriteString(target.styles.success.Render(core.Sprintf(" ● %d local documents", len(inspector.knowledge.documents))) + "\n") + for _, document := range inspector.knowledge.documents { + builder.WriteString(target.styles.status.Render(" "+document.Title+" "+document.Path) + "\n") + } + } + if len(target.attachments) > 0 { + builder.WriteString(target.styles.title.Render(core.Sprintf(" %d attached snapshots", len(target.attachments))) + "\n") + } + for _, warning := range inspector.knowledge.warnings { + label := warning.Path + if label == "" { + label = warning.Mount + } + builder.WriteString(target.styles.attention.Render(" ! "+label) + "\n") + builder.WriteString(target.styles.thought.Render(" "+warning.Reason) + "\n") + } + if inspector.dirty { + builder.WriteString("\n" + target.styles.attention.Render("● unsaved · ctrl+s")) + } +} + +func (inspector inspectorState) renderControl(builder *core.Builder, target app, control inspectorControl, label, value string) { + cursor := " " + style := target.styles.status + if inspector.cursor == int(control) { + cursor = "› " + style = target.styles.accent + } + builder.WriteString(cursor + style.Render(label) + " " + target.styles.title.Render("‹ "+value+" ›") + "\n") +} + +func wrapIndex(index, delta, length int) int { + if length <= 0 { + return 0 + } + return ((index+delta)%length + length) % length +} + +func themeIndex(name string) int { + for index, candidate := range inspectorThemes { + if candidate == name { + return index + } + } + return 0 +} diff --git a/cli/tui/inspector_test.go b/cli/tui/inspector_test.go new file mode 100644 index 000000000..46f5078a8 --- /dev/null +++ b/cli/tui/inspector_test.go @@ -0,0 +1,153 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package tui + +import ( + "strings" + "testing" + + "dappco.re/go/inference/decode/parser" + coreio "dappco.re/go/io" + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +func TestInspector_Good(t *testing.T) { + a := newApp("", 0, 64) + a.modelName = "gemma-4" + a.generating = true + a.svc.requests.Store(7) + a.picker.SetItems([]list.Item{modelItem{path: "/models/qwen", name: "qwen", modelType: "qwen3"}}) + + tests := []struct { + panel panelID + want []string + }{ + {panelChat, []string{"SESSION", "MODEL", "GENERATION", "SETTINGS", "MODE", "TOOLS", "gemma-4"}}, + {panelWork, []string{"WORK DETAIL", "RUNTIME", "AGENT CAPABILITY", "not installed"}}, + {panelModels, []string{"MODEL DETAIL", "qwen", "/models/qwen"}}, + {panelService, []string{"ADDRESS", "REQUESTS", a.svc.addr(), "7"}}, + } + for _, test := range tests { + a.activePanel = test.panel + view := a.inspector.View(a, 36, 20) + for _, want := range test.want { + if !strings.Contains(view, want) { + t.Fatalf("panel %d inspector missing %q:\n%s", test.panel, want, view) + } + } + } +} + +func TestInspector_Bad(t *testing.T) { + a := newApp("", 0, 64) + m, _ := a.Update(tea.WindowSizeMsg{Width: 100, Height: 24}) + a = m.(app) + a.activePanel = panelWork + a.inspectorOpen = true + view := a.View() + if !strings.Contains(view, "Work") || !strings.Contains(view, "AGENT CAPABILITY") { + t.Fatalf("overlay inspector did not retain main Work panel:\n%s", view) + } +} + +func TestInspector_Ugly(t *testing.T) { + a := newApp("", 0, 64) + m, _ := a.Update(tea.WindowSizeMsg{Width: 72, Height: 22}) + a = m.(app) + a.activePanel = panelChat + a.inspectorOpen = true + view := a.View() + for _, section := range []string{"SESSION", "SETTINGS", "MODE", "TOOLS"} { + if !strings.Contains(view, section) { + t.Fatalf("narrow inspector missing %q:\n%s", section, view) + } + } + for line, text := range strings.Split(view, "\n") { + if width := lipgloss.Width(text); width > 72 { + t.Fatalf("narrow line %d width = %d", line, width) + } + } + _ = a.inspector.View(a, 0, 0) // zero dimensions are a valid compact boundary +} + +func TestInspectorPreferences_Good(t *testing.T) { + medium := coreio.NewMockMedium() + opened := openPreferences(medium, appConfigPath) + if !opened.OK { + t.Fatalf("open preferences: %v", opened.Value) + } + preferences := opened.Value.(preferenceStore) + a := newApp("", 0, 64) + a.attachPreferences(preferences) + if !a.inspector.Select(inspectorControlMaxTokens) { + t.Fatal("max-tokens control unavailable") + } + if result := a.inspector.Adjust(&a, 1); !result.OK { + t.Fatalf("adjust max tokens: %v", result.Value) + } + if !a.inspector.Select(inspectorControlTheme) { + t.Fatal("theme control unavailable") + } + if result := a.inspector.Adjust(&a, 1); !result.OK { + t.Fatalf("adjust theme: %v", result.Value) + } + if !a.inspector.Dirty() || a.cfg.maxTokens() != 8192 || a.inspector.Theme() != "aurora" { + t.Fatalf("edited inspector: dirty=%v max=%d theme=%q", a.inspector.Dirty(), a.cfg.maxTokens(), a.inspector.Theme()) + } + + m, _ := a.Update(tea.KeyMsg{Type: tea.KeyCtrlS}) + a = m.(app) + if a.inspector.Dirty() { + t.Fatal("Ctrl+S left inspector dirty") + } + if a.styles.theme.name != "aurora" || a.markdown.theme != "aurora" { + t.Fatalf("theme was not rebuilt in place: styles=%q markdown=%q", a.styles.theme.name, a.markdown.theme) + } + + reopened := openPreferences(medium, appConfigPath) + if !reopened.OK { + t.Fatalf("reopen preferences: %v", reopened.Value) + } + values := reopened.Value.(preferenceStore).Values() + if values.MaxTokens != 8192 || values.Theme != "aurora" { + t.Fatalf("persisted inspector values = %#v", values) + } +} + +func TestInspectorTools_Bad(t *testing.T) { + manager := openTestSessionManager(t, sequenceIDs("session-tools")) + created := manager.Create() + if !created.OK { + t.Fatalf("create tools session: %v", created.Value) + } + a := newApp("", 0, 64) + a.sessions = manager + a.repository = manager.repository + a.activateManagedSession(created.Value.(*chatSession)) + if !a.inspector.Select(inspectorControlTools) { + t.Fatal("tools control unavailable") + } + if result := a.inspector.Adjust(&a, 1); !result.OK || !a.tools.enabled { + t.Fatalf("enable tools = %#v enabled=%v", result.Value, a.tools.enabled) + } + + malformed := parser.ToolCallOpenMarker + "broken payload" + parser.ToolCallCloseMarker + a.turns = append(a.turns, turn{id: "assistant-malformed", role: "assistant", text: malformed}) + if command := a.runToolLoop(); command != nil { + t.Fatal("malformed tool call unexpectedly auto-continued") + } + last := a.turns[len(a.turns)-1] + if last.role != "tool" || !strings.Contains(last.text, "malformed tool call") { + t.Fatalf("explicit malformed tool result = %#v", last) + } + events := manager.repository.Events(manager.Active().Record.ID) + if !events.OK { + t.Fatalf("load tool events: %v", events.Value) + } + stored := events.Value.([]eventRecord) + if len(stored) != 1 || stored[0].Kind != "tool.parse" || stored[0].Status != "failed" { + t.Fatalf("stored malformed tool event = %#v", stored) + } +} diff --git a/cli/tui/jobs.go b/cli/tui/jobs.go new file mode 100644 index 000000000..b4ec6bb11 --- /dev/null +++ b/cli/tui/jobs.go @@ -0,0 +1,359 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package tui + +import ( + "context" + "iter" + "sync" + + core "dappco.re/go" + "dappco.re/go/inference" + "dappco.re/go/inference/serving/scheduler" +) + +// modelLane owns the one scheduler wrapped around a loaded model. Every local +// session and every HTTP request uses scheduled, so starting or stopping the +// service never changes the model's concurrency policy. +type modelLane struct { + base inference.TextModel + scheduled *scheduler.Model + model *laneTextModel + name string + + closeOnce sync.Once + closeResult core.Result +} + +// laneTextModel keeps the scheduler's shared serial lane while capturing the +// base model's terminal Result before the next queued request may run. The +// private request-error seam lets the TUI attribute errors to one session +// without requiring a newer public inference option in the nested CLI module. +type laneTextModel struct { + base inference.TextModel + scheduled *scheduler.Model + gate chan struct{} + close func() core.Result + errMu sync.Mutex + lastErr core.Result +} + +type scopedErrorChatModel interface { + chatWithErrorSink(context.Context, []inference.Message, func(error), ...inference.GenerateOption) iter.Seq[inference.Token] +} + +// newModelLane creates the application's single serial generation lane. +func newModelLane(model inference.TextModel, name string) core.Result { + if model == nil { + return core.Fail(core.E("tui.newModelLane", "model is nil", nil)) + } + scheduled, err := scheduler.New(model, scheduler.Config{ + Mode: scheduler.ModeSerial, + MaxConcurrent: 1, + MaxQueue: 64, + StreamBuffer: 16, + RequestIDPrefix: "tui", + }) + if err != nil { + return core.Fail(core.E("tui.newModelLane", "create serial scheduler", err)) + } + lane := &modelLane{base: model, scheduled: scheduled, name: name} + lane.model = &laneTextModel{ + base: model, + scheduled: scheduled, + gate: make(chan struct{}, 1), + lastErr: core.Ok(nil), + } + lane.model.close = lane.Close + return core.Ok(lane) +} + +// Model returns the scheduled view shared by all generation consumers. +func (lane *modelLane) Model() inference.TextModel { + if lane == nil { + return nil + } + return lane.model +} + +func (model *laneTextModel) Generate(ctx context.Context, prompt string, opts ...inference.GenerateOption) iter.Seq[inference.Token] { + return model.stream(ctx, func() iter.Seq[inference.Token] { + return model.scheduled.Generate(ctx, prompt, opts...) + }, nil) +} + +func (model *laneTextModel) Chat(ctx context.Context, messages []inference.Message, opts ...inference.GenerateOption) iter.Seq[inference.Token] { + return model.chatWithErrorSink(ctx, messages, nil, opts...) +} + +func (model *laneTextModel) chatWithErrorSink(ctx context.Context, messages []inference.Message, sink func(error), opts ...inference.GenerateOption) iter.Seq[inference.Token] { + return model.stream(ctx, func() iter.Seq[inference.Token] { + return model.scheduled.Chat(ctx, messages, opts...) + }, sink) +} + +func (model *laneTextModel) stream(ctx context.Context, source func() iter.Seq[inference.Token], sink func(error)) iter.Seq[inference.Token] { + return func(yield func(inference.Token) bool) { + if model == nil || model.base == nil || model.scheduled == nil { + result := core.Fail(core.E("tui.laneTextModel.stream", "model lane is unavailable", nil)) + model.setResult(result, sink) + return + } + if ctx == nil { + ctx = context.Background() + } + select { + case model.gate <- struct{}{}: + defer func() { <-model.gate }() + case <-ctx.Done(): + model.setResult(core.Fail(ctx.Err()), sink) + return + } + for token := range source() { + if !yield(token) { + break + } + } + result := model.base.Err() + if result.OK && ctx.Err() != nil { + result = core.Fail(ctx.Err()) + } + model.setResult(result, sink) + } +} + +func (model *laneTextModel) setResult(result core.Result, sink func(error)) { + if model != nil { + model.errMu.Lock() + model.lastErr = result + model.errMu.Unlock() + } + if sink == nil { + return + } + if result.OK { + sink(nil) + return + } + err := resultError(result) + if err == nil { + err = core.E("tui.laneTextModel.setResult", result.Error(), nil) + } + sink(err) +} + +func (model *laneTextModel) Classify(ctx context.Context, prompts []string, opts ...inference.GenerateOption) core.Result { + if result := model.acquire(ctx); !result.OK { + return result + } + defer model.release() + return model.scheduled.Classify(ctx, prompts, opts...) +} + +func (model *laneTextModel) BatchGenerate(ctx context.Context, prompts []string, opts ...inference.GenerateOption) core.Result { + if result := model.acquire(ctx); !result.OK { + return result + } + defer model.release() + return model.scheduled.BatchGenerate(ctx, prompts, opts...) +} + +func (model *laneTextModel) acquire(ctx context.Context) core.Result { + if model == nil || model.scheduled == nil { + return core.Fail(core.E("tui.laneTextModel.acquire", "model lane is unavailable", nil)) + } + if ctx == nil { + ctx = context.Background() + } + select { + case model.gate <- struct{}{}: + return core.Ok(nil) + case <-ctx.Done(): + return core.Fail(ctx.Err()) + } +} + +func (model *laneTextModel) release() { + if model != nil { + <-model.gate + } +} + +func (model *laneTextModel) ModelType() string { + if model == nil || model.base == nil { + return "" + } + return model.base.ModelType() +} + +func (model *laneTextModel) Info() inference.ModelInfo { + if model == nil || model.base == nil { + return inference.ModelInfo{} + } + return model.base.Info() +} + +func (model *laneTextModel) Metrics() inference.GenerateMetrics { + if model == nil || model.scheduled == nil { + return inference.GenerateMetrics{} + } + return model.scheduled.Metrics() +} + +func (model *laneTextModel) Err() core.Result { + if model == nil { + return core.Fail(core.E("tui.laneTextModel.Err", "model lane is unavailable", nil)) + } + model.errMu.Lock() + defer model.errMu.Unlock() + return model.lastErr +} + +func (model *laneTextModel) Close() core.Result { + if model == nil || model.close == nil { + return core.Ok(nil) + } + return model.close() +} + +// Close drains the scheduler and releases the base model exactly once. +func (lane *modelLane) Close() core.Result { + if lane == nil { + return core.Ok(nil) + } + lane.closeOnce.Do(func() { + if lane.scheduled == nil { + lane.closeResult = core.Ok(nil) + return + } + lane.closeResult = lane.scheduled.Close() + }) + return lane.closeResult +} + +// generation identifies one in-flight session turn. Tags travel with every +// stream event so a late event can never be folded into another session. +type generation struct { + SessionID string + JobID string + cancel context.CancelFunc + events chan streamEvent +} + +// jobManager allows one in-flight generation per session while permitting +// independent sessions to queue on the shared model lane. +type jobManager struct { + parent context.Context + + mu sync.Mutex + bySession map[string]*generation +} + +func newJobManager(parent context.Context) *jobManager { + if parent == nil { + parent = context.Background() + } + return &jobManager{parent: parent, bySession: make(map[string]*generation)} +} + +// Start registers and launches a tagged generation. The successful Result +// carries *generation for Bubble Tea's wait command. +func (jobs *jobManager) Start(sessionID, jobID string, model inference.TextModel, history []inference.Message, opts []inference.GenerateOption) core.Result { + if jobs == nil { + return core.Fail(core.E("tui.jobManager.Start", "job manager is nil", nil)) + } + if core.Trim(sessionID) == "" { + return core.Fail(core.E("tui.jobManager.Start", "session ID is empty", nil)) + } + if core.Trim(jobID) == "" { + return core.Fail(core.E("tui.jobManager.Start", "job ID is empty", nil)) + } + if model == nil { + return core.Fail(core.E("tui.jobManager.Start", "model is nil", nil)) + } + + jobs.mu.Lock() + if jobs.bySession == nil { + jobs.bySession = make(map[string]*generation) + } + if _, exists := jobs.bySession[sessionID]; exists { + jobs.mu.Unlock() + return core.Fail(core.E("tui.jobManager.Start", "session already has a running generation", nil)) + } + parent := jobs.parent + if parent == nil { + parent = context.Background() + } + ctx, cancel := context.WithCancel(parent) + generation := &generation{ + SessionID: sessionID, + JobID: jobID, + cancel: cancel, + events: make(chan streamEvent, 64), + } + jobs.bySession[sessionID] = generation + jobs.mu.Unlock() + + go streamGeneration(ctx, generation, model, history, opts, func() { + jobs.finish(sessionID, jobID) + }) + return core.Ok(generation) +} + +// Cancel stops only the named session's current generation. +func (jobs *jobManager) Cancel(sessionID string) core.Result { + if jobs == nil { + return core.Fail(core.E("tui.jobManager.Cancel", "job manager is nil", nil)) + } + jobs.mu.Lock() + generation, exists := jobs.bySession[sessionID] + jobs.mu.Unlock() + if !exists { + return core.Fail(core.E("tui.jobManager.Cancel", "session has no running generation", nil)) + } + generation.cancel() + return core.Ok(nil) +} + +// CancelAll stops every session generation without closing the model lane. +func (jobs *jobManager) CancelAll() core.Result { + if jobs == nil { + return core.Fail(core.E("tui.jobManager.CancelAll", "job manager is nil", nil)) + } + jobs.mu.Lock() + generations := make([]*generation, 0, len(jobs.bySession)) + for _, generation := range jobs.bySession { + generations = append(generations, generation) + } + jobs.mu.Unlock() + for _, generation := range generations { + generation.cancel() + } + return core.Ok(len(generations)) +} + +func (jobs *jobManager) Active(sessionID string) *generation { + if jobs == nil { + return nil + } + jobs.mu.Lock() + defer jobs.mu.Unlock() + return jobs.bySession[sessionID] +} + +func (jobs *jobManager) ActiveCount() int { + if jobs == nil { + return 0 + } + jobs.mu.Lock() + defer jobs.mu.Unlock() + return len(jobs.bySession) +} + +func (jobs *jobManager) finish(sessionID, jobID string) { + jobs.mu.Lock() + defer jobs.mu.Unlock() + if current := jobs.bySession[sessionID]; current != nil && current.JobID == jobID { + delete(jobs.bySession, sessionID) + } +} diff --git a/cli/tui/jobs_test.go b/cli/tui/jobs_test.go new file mode 100644 index 000000000..b58b006de --- /dev/null +++ b/cli/tui/jobs_test.go @@ -0,0 +1,480 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package tui + +import ( + "context" + "iter" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + core "dappco.re/go" + "dappco.re/go/inference" +) + +// fakeTextModel is a deterministic generation probe. Each prompt has a token +// script and may have a gate; a gated call blocks until the gate closes or its +// context is cancelled. The counters are safe to inspect while calls run. +type fakeTextModel struct { + mu sync.RWMutex + scripts map[string][]string + gates map[string]chan struct{} + afterFirstGates map[string]chan struct{} + started chan string + firstYielded chan string + + active atomic.Int64 + maxActive atomic.Int64 + closes atomic.Int64 +} + +type requestErrorTextModel struct { + *fakeTextModel + errMu sync.Mutex + lastErr core.Result +} + +func newRequestErrorTextModel() *requestErrorTextModel { + return &requestErrorTextModel{ + fakeTextModel: newFakeTextModel(nil), + lastErr: core.Ok(nil), + } +} + +func (model *requestErrorTextModel) Chat(_ context.Context, messages []inference.Message, _ ...inference.GenerateOption) iter.Seq[inference.Token] { + prompt := "" + if len(messages) > 0 { + prompt = messages[len(messages)-1].Content + } + return func(yield func(inference.Token) bool) { + model.errMu.Lock() + if prompt == "fails" { + model.lastErr = core.Fail(core.E("test.request", "first request failed", nil)) + } else { + model.lastErr = core.Ok(nil) + } + model.errMu.Unlock() + if prompt != "fails" { + yield(inference.Token{Text: "second succeeds"}) + } + } +} + +func (model *requestErrorTextModel) Err() core.Result { + model.errMu.Lock() + defer model.errMu.Unlock() + return model.lastErr +} + +func newFakeTextModel(scripts map[string][]string) *fakeTextModel { + return &fakeTextModel{ + scripts: scripts, + gates: make(map[string]chan struct{}), + afterFirstGates: make(map[string]chan struct{}), + started: make(chan string, 16), + firstYielded: make(chan string, 16), + } +} + +func (m *fakeTextModel) block(prompt string) chan struct{} { + m.mu.Lock() + defer m.mu.Unlock() + gate := make(chan struct{}) + m.gates[prompt] = gate + return gate +} + +func (m *fakeTextModel) blockAfterFirst(prompt string) chan struct{} { + m.mu.Lock() + defer m.mu.Unlock() + gate := make(chan struct{}) + m.afterFirstGates[prompt] = gate + return gate +} + +func (m *fakeTextModel) Generate(ctx context.Context, prompt string, _ ...inference.GenerateOption) iter.Seq[inference.Token] { + return m.sequence(ctx, prompt) +} + +func (m *fakeTextModel) Chat(ctx context.Context, messages []inference.Message, _ ...inference.GenerateOption) iter.Seq[inference.Token] { + prompt := "" + if len(messages) > 0 { + prompt = messages[len(messages)-1].Content + } + return m.sequence(ctx, prompt) +} + +func (m *fakeTextModel) sequence(ctx context.Context, prompt string) iter.Seq[inference.Token] { + return func(yield func(inference.Token) bool) { + active := m.active.Add(1) + defer m.active.Add(-1) + for { + seen := m.maxActive.Load() + if active <= seen || m.maxActive.CompareAndSwap(seen, active) { + break + } + } + m.started <- prompt + + m.mu.RLock() + gate := m.gates[prompt] + afterFirstGate := m.afterFirstGates[prompt] + tokens := append([]string(nil), m.scripts[prompt]...) + m.mu.RUnlock() + if gate != nil { + select { + case <-ctx.Done(): + return + case <-gate: + } + } + for index, text := range tokens { + select { + case <-ctx.Done(): + return + default: + } + if !yield(inference.Token{Text: text}) { + return + } + if index == 0 { + m.firstYielded <- prompt + if afterFirstGate != nil { + select { + case <-ctx.Done(): + return + case <-afterFirstGate: + } + } + } + } + } +} + +func (m *fakeTextModel) Classify(context.Context, []string, ...inference.GenerateOption) core.Result { + return core.Ok([]inference.ClassifyResult(nil)) +} + +func (m *fakeTextModel) BatchGenerate(context.Context, []string, ...inference.GenerateOption) core.Result { + return core.Ok([]inference.BatchResult(nil)) +} + +func (m *fakeTextModel) ModelType() string { return "fake" } +func (m *fakeTextModel) Info() inference.ModelInfo { return inference.ModelInfo{Architecture: "qwen3"} } +func (m *fakeTextModel) Metrics() inference.GenerateMetrics { return inference.GenerateMetrics{} } +func (m *fakeTextModel) Err() core.Result { return core.Ok(nil) } +func (m *fakeTextModel) Close() core.Result { m.closes.Add(1); return core.Ok(nil) } + +func TestModelLane_Good(t *testing.T) { + base := newFakeTextModel(map[string][]string{"first": {"one"}, "second": {"two"}}) + firstGate := base.block("first") + secondGate := base.block("second") + r := newModelLane(base, "test") + if !r.OK { + t.Fatalf("newModelLane error = %s", r.Error()) + } + lane := r.Value.(*modelLane) + defer lane.Close() + + done := make(chan string, 2) + go consumeFakeChat(lane.Model(), "first", done) + go consumeFakeChat(lane.Model(), "second", done) + + first := waitFakeStarted(t, base.started) + assertNoFakeStarted(t, base.started) + if got := base.maxActive.Load(); got != 1 { + t.Fatalf("max base concurrency = %d, want 1", got) + } + if first == "first" { + close(firstGate) + } else { + close(secondGate) + } + second := waitFakeStarted(t, base.started) + if first == second { + t.Fatalf("started prompts = %q then %q, want both requests", first, second) + } + if second == "first" { + close(firstGate) + } else { + close(secondGate) + } + waitFakeDone(t, done) + waitFakeDone(t, done) + if got := base.maxActive.Load(); got != 1 { + t.Fatalf("max base concurrency = %d after both requests, want 1", got) + } +} + +func TestModelLane_Bad(t *testing.T) { + r := newModelLane(nil, "missing") + if r.OK { + t.Fatal("newModelLane(nil) succeeded") + } +} + +func TestModelLane_Ugly(t *testing.T) { + base := newFakeTextModel(map[string][]string{"running": {"one"}, "queued": {"two"}}) + base.block("running") + base.block("queued") + r := newModelLane(base, "test") + if !r.OK { + t.Fatalf("newModelLane error = %s", r.Error()) + } + lane := r.Value.(*modelLane) + + done := make(chan string, 2) + go consumeFakeChat(lane.Model(), "running", done) + if got := waitFakeStarted(t, base.started); got != "running" { + t.Fatalf("started = %q, want running", got) + } + go consumeFakeChat(lane.Model(), "queued", done) + assertNoFakeStarted(t, base.started) + + closed := make(chan core.Result, 1) + go func() { closed <- lane.Close() }() + waitFakeDone(t, done) + waitFakeDone(t, done) + select { + case result := <-closed: + if !result.OK { + t.Fatalf("Close error = %s", result.Error()) + } + case <-time.After(2 * time.Second): + t.Fatal("Close did not drain running and queued requests") + } + if r := lane.Close(); !r.OK { + t.Fatalf("second Close error = %s", r.Error()) + } + if got := base.closes.Load(); got != 1 { + t.Fatalf("base Close calls = %d, want 1", got) + } +} + +func TestJobManager_Good(t *testing.T) { + model := newFakeTextModel(map[string][]string{ + "alpha": {"A1", "A2"}, + "beta": {"B1", "B2"}, + }) + jobs := newJobManager(context.Background()) + alpha := jobs.Start("session-alpha", "job-alpha", model, + []inference.Message{{Role: "user", Content: "alpha"}}, nil) + beta := jobs.Start("session-beta", "job-beta", model, + []inference.Message{{Role: "user", Content: "beta"}}, nil) + if !alpha.OK || !beta.OK { + t.Fatalf("Start results = %s / %s", alpha.Error(), beta.Error()) + } + + assertInterleavedGenerations(t, []taggedGenerationExpectation{ + {generation: alpha.Value.(*generation), sessionID: "session-alpha", jobID: "job-alpha", text: "A1A2"}, + {generation: beta.Value.(*generation), sessionID: "session-beta", jobID: "job-beta", text: "B1B2"}, + }) +} + +func TestJobManager_Bad(t *testing.T) { + model := newFakeTextModel(map[string][]string{"blocked": {"done"}}) + model.block("blocked") + jobs := newJobManager(context.Background()) + first := jobs.Start("same-session", "first", model, + []inference.Message{{Role: "user", Content: "blocked"}}, nil) + if !first.OK { + t.Fatalf("first Start error = %s", first.Error()) + } + waitFakeStarted(t, model.started) + duplicate := jobs.Start("same-session", "second", model, + []inference.Message{{Role: "user", Content: "blocked"}}, nil) + if duplicate.OK { + t.Fatal("duplicate Start for one session succeeded") + } + if r := jobs.Cancel("same-session"); !r.OK { + t.Fatalf("Cancel error = %s", r.Error()) + } + assertTaggedGeneration(t, first.Value.(*generation), "same-session", "first", "") +} + +func TestJobManager_Ugly(t *testing.T) { + base := newFakeTextModel(map[string][]string{"cancel-me": {"no"}, "continue": {"yes"}}) + base.block("cancel-me") + continueGate := base.block("continue") + laneResult := newModelLane(base, "test") + if !laneResult.OK { + t.Fatalf("newModelLane error = %s", laneResult.Error()) + } + lane := laneResult.Value.(*modelLane) + defer lane.Close() + + jobs := newJobManager(context.Background()) + cancelled := jobs.Start("session-cancel", "job-cancel", lane.Model(), + []inference.Message{{Role: "user", Content: "cancel-me"}}, nil) + if !cancelled.OK { + t.Fatalf("cancelled Start error = %s", cancelled.Error()) + } + if got := waitFakeStarted(t, base.started); got != "cancel-me" { + t.Fatalf("started = %q, want cancel-me", got) + } + continued := jobs.Start("session-continue", "job-continue", lane.Model(), + []inference.Message{{Role: "user", Content: "continue"}}, nil) + if !continued.OK { + t.Fatalf("continued Start error = %s", continued.Error()) + } + if r := jobs.Cancel("session-cancel"); !r.OK { + t.Fatalf("Cancel error = %s", r.Error()) + } + if got := waitFakeStarted(t, base.started); got != "continue" { + t.Fatalf("started after cancel = %q, want continue", got) + } + close(continueGate) + + assertTaggedGeneration(t, cancelled.Value.(*generation), "session-cancel", "job-cancel", "") + assertTaggedGeneration(t, continued.Value.(*generation), "session-continue", "job-continue", "yes") +} + +func TestJobManagerRequestErrorsAreIsolated_Ugly(t *testing.T) { + base := newRequestErrorTextModel() + laneResult := newModelLane(base, "request-errors") + if !laneResult.OK { + t.Fatalf("newModelLane: %s", laneResult.Error()) + } + lane := laneResult.Value.(*modelLane) + defer lane.Close() + jobs := newJobManager(context.Background()) + + first := jobs.Start("session-first", "job-first", lane.Model(), []inference.Message{{Role: "user", Content: "fails"}}, nil) + if !first.OK { + t.Fatalf("start first: %s", first.Error()) + } + firstText, firstErr := collectGeneration(t, first.Value.(*generation)) + if firstText != "" || firstErr == nil || !strings.Contains(firstErr.Error(), "first request failed") { + t.Fatalf("first result = %q / %v", firstText, firstErr) + } + + second := jobs.Start("session-second", "job-second", lane.Model(), []inference.Message{{Role: "user", Content: "succeeds"}}, nil) + if !second.OK { + t.Fatalf("start second: %s", second.Error()) + } + secondText, secondErr := collectGeneration(t, second.Value.(*generation)) + if secondText != "second succeeds" || secondErr != nil { + t.Fatalf("second result = %q / %v", secondText, secondErr) + } +} + +func collectGeneration(t *testing.T, generation *generation) (string, error) { + t.Helper() + var text strings.Builder + var terminalErr error + for event := range generation.events { + text.WriteString(event.visible) + if event.err != nil { + terminalErr = event.err + } + } + return text.String(), terminalErr +} + +func consumeFakeChat(model inference.TextModel, prompt string, done chan<- string) { + var text strings.Builder + for token := range model.Chat(context.Background(), []inference.Message{{Role: "user", Content: prompt}}) { + text.WriteString(token.Text) + } + done <- text.String() +} + +func waitFakeStarted(t *testing.T, started <-chan string) string { + t.Helper() + select { + case prompt := <-started: + return prompt + case <-time.After(2 * time.Second): + t.Fatal("model did not start") + return "" + } +} + +func assertNoFakeStarted(t *testing.T, started <-chan string) { + t.Helper() + select { + case prompt := <-started: + t.Fatalf("unexpected concurrent start %q", prompt) + case <-time.After(50 * time.Millisecond): + } +} + +func waitFakeDone(t *testing.T, done <-chan string) string { + t.Helper() + select { + case text := <-done: + return text + case <-time.After(2 * time.Second): + t.Fatal("generation did not finish") + return "" + } +} + +func assertTaggedGeneration(t *testing.T, generation *generation, sessionID, jobID, wantText string) { + t.Helper() + var text strings.Builder + timeout := time.NewTimer(2 * time.Second) + defer timeout.Stop() + for { + select { + case event, ok := <-generation.events: + if !ok { + if got := text.String(); got != wantText { + t.Fatalf("stream text = %q, want %q", got, wantText) + } + return + } + if event.SessionID != sessionID || event.JobID != jobID { + t.Fatalf("event IDs = %q/%q, want %q/%q", event.SessionID, event.JobID, sessionID, jobID) + } + text.WriteString(event.visible) + case <-timeout.C: + t.Fatal("timed out draining tagged generation") + } + } +} + +type taggedGenerationExpectation struct { + generation *generation + sessionID string + jobID string + text string +} + +func assertInterleavedGenerations(t *testing.T, expected []taggedGenerationExpectation) { + t.Helper() + texts := make([]strings.Builder, len(expected)) + closed := make([]bool, len(expected)) + remaining := len(expected) + timeout := time.NewTimer(2 * time.Second) + defer timeout.Stop() + for remaining > 0 { + for i, stream := range expected { + if closed[i] { + continue + } + select { + case event, ok := <-stream.generation.events: + if !ok { + closed[i] = true + remaining-- + continue + } + if event.SessionID != stream.sessionID || event.JobID != stream.jobID { + t.Fatalf("event IDs = %q/%q, want %q/%q", event.SessionID, event.JobID, stream.sessionID, stream.jobID) + } + texts[i].WriteString(event.visible) + case <-timeout.C: + t.Fatal("timed out interleaving tagged generations") + } + } + } + for i, stream := range expected { + if got := texts[i].String(); got != stream.text { + t.Fatalf("stream %q text = %q, want %q", stream.sessionID, got, stream.text) + } + } +} diff --git a/cli/tui/keymap.go b/cli/tui/keymap.go new file mode 100644 index 000000000..93291ad0a --- /dev/null +++ b/cli/tui/keymap.go @@ -0,0 +1,42 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package tui + +import "github.com/charmbracelet/bubbles/key" + +type keyMap struct { + NewSession key.Binding + SwitchSession key.Binding + PreviousSession key.Binding + NextSession key.Binding + CommandPalette key.Binding + ToggleInspector key.Binding + Search key.Binding + Save key.Binding + Help key.Binding +} + +func newKeyMap() keyMap { + return keyMap{ + NewSession: key.NewBinding(key.WithKeys("ctrl+n"), key.WithHelp("ctrl+n", "new session")), + SwitchSession: key.NewBinding(key.WithKeys("ctrl+p"), key.WithHelp("ctrl+p", "switch session")), + PreviousSession: key.NewBinding(key.WithKeys("alt+left"), key.WithHelp("alt+←", "previous session")), + NextSession: key.NewBinding(key.WithKeys("alt+right"), key.WithHelp("alt+→", "next session")), + CommandPalette: key.NewBinding(key.WithKeys("ctrl+k"), key.WithHelp("ctrl+k", "commands")), + ToggleInspector: key.NewBinding(key.WithKeys("ctrl+o"), key.WithHelp("ctrl+o", "inspector")), + Search: key.NewBinding(key.WithKeys("ctrl+f"), key.WithHelp("ctrl+f", "search")), + Save: key.NewBinding(key.WithKeys("ctrl+s"), key.WithHelp("ctrl+s", "save")), + Help: key.NewBinding(key.WithKeys("f1"), key.WithHelp("f1", "help")), + } +} + +func (keys keyMap) ShortHelp() []key.Binding { + return []key.Binding{keys.CommandPalette, keys.SwitchSession, keys.ToggleInspector, keys.Help} +} + +func (keys keyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {keys.NewSession, keys.SwitchSession, keys.PreviousSession, keys.NextSession}, + {keys.CommandPalette, keys.ToggleInspector, keys.Search, keys.Save, keys.Help}, + } +} diff --git a/cli/tui/keymap_test.go b/cli/tui/keymap_test.go new file mode 100644 index 000000000..b076c86ab --- /dev/null +++ b/cli/tui/keymap_test.go @@ -0,0 +1,36 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package tui + +import ( + "testing" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" +) + +func TestKeyMap_Good(t *testing.T) { + keys := newKeyMap() + tests := []struct { + name string + message tea.KeyMsg + binding key.Binding + }{ + {"new session", tea.KeyMsg{Type: tea.KeyCtrlN}, keys.NewSession}, + {"session switcher", tea.KeyMsg{Type: tea.KeyCtrlP}, keys.SwitchSession}, + {"previous session", tea.KeyMsg{Type: tea.KeyLeft, Alt: true}, keys.PreviousSession}, + {"next session", tea.KeyMsg{Type: tea.KeyRight, Alt: true}, keys.NextSession}, + {"command palette", tea.KeyMsg{Type: tea.KeyCtrlK}, keys.CommandPalette}, + {"inspector", tea.KeyMsg{Type: tea.KeyCtrlO}, keys.ToggleInspector}, + {"search", tea.KeyMsg{Type: tea.KeyCtrlF}, keys.Search}, + {"save", tea.KeyMsg{Type: tea.KeyCtrlS}, keys.Save}, + {"help", tea.KeyMsg{Type: tea.KeyF1}, keys.Help}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if !key.Matches(test.message, test.binding) { + t.Fatalf("%q did not match %q", test.message.String(), test.binding.Keys()) + } + }) + } +} diff --git a/cli/tui/knowledge.go b/cli/tui/knowledge.go new file mode 100644 index 000000000..4d5fb5ec6 --- /dev/null +++ b/cli/tui/knowledge.go @@ -0,0 +1,420 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package tui + +import ( + "sort" + "time" + + core "dappco.re/go" + coreio "dappco.re/go/io" +) + +const knowledgeSystemMessageMaxBytes = 65536 + +type knowledgeDocument struct { + Mount string + Path string + Title string + Content string + ContentHash string + ModifiedAt time.Time +} + +type knowledgeMount struct { + Name string + Root string + Medium coreio.Medium +} + +type knowledgeWarning struct { + Mount string + Path string + Reason string +} + +type knowledgeDiscovery struct { + Documents []knowledgeDocument + Warnings []knowledgeWarning +} + +type knowledgeScanner interface { + Discover(mounts []knowledgeMount, maxBytes int64) core.Result +} + +type mediumKnowledgeScanner struct{} + +func newKnowledgeScanner() knowledgeScanner { return mediumKnowledgeScanner{} } + +func (mediumKnowledgeScanner) Discover(mounts []knowledgeMount, maxBytes int64) core.Result { + if maxBytes <= 0 { + return core.Fail(core.E("tui.knowledgeScanner.Discover", "knowledge file byte limit must be positive", nil)) + } + discovery := knowledgeDiscovery{ + Documents: []knowledgeDocument{}, + Warnings: []knowledgeWarning{}, + } + visitedDirectories := make(map[string]bool) + visitedDocuments := make(map[string]bool) + for _, mount := range mounts { + mount.Name = core.Trim(mount.Name) + mount.Root = trimMediumPath(mount.Root) + if mount.Name == "" { + discovery.Warnings = append(discovery.Warnings, knowledgeWarning{Path: mount.Root, Reason: "mount name is required"}) + continue + } + if mount.Medium == nil { + discovery.Warnings = append(discovery.Warnings, knowledgeWarning{Mount: mount.Name, Path: mount.Root, Reason: "mount medium is unavailable"}) + continue + } + scanKnowledgeDirectory(mount, mount.Root, maxBytes, visitedDirectories, visitedDocuments, &discovery) + } + sort.SliceStable(discovery.Documents, func(left, right int) bool { + if discovery.Documents[left].Mount != discovery.Documents[right].Mount { + return discovery.Documents[left].Mount < discovery.Documents[right].Mount + } + leftTitle := core.Lower(discovery.Documents[left].Title) + rightTitle := core.Lower(discovery.Documents[right].Title) + if leftTitle != rightTitle { + return leftTitle < rightTitle + } + return discovery.Documents[left].Path < discovery.Documents[right].Path + }) + sort.SliceStable(discovery.Warnings, func(left, right int) bool { + if discovery.Warnings[left].Mount != discovery.Warnings[right].Mount { + return discovery.Warnings[left].Mount < discovery.Warnings[right].Mount + } + return discovery.Warnings[left].Path < discovery.Warnings[right].Path + }) + return core.Ok(discovery) +} + +func scanKnowledgeDirectory( + mount knowledgeMount, + directory string, + maxBytes int64, + visitedDirectories map[string]bool, + visitedDocuments map[string]bool, + discovery *knowledgeDiscovery, +) { + directoryKey := core.Concat(mount.Name, "\x00", directory) + if visitedDirectories[directoryKey] { + return + } + visitedDirectories[directoryKey] = true + entries, err := mount.Medium.List(directory) + if err != nil { + discovery.Warnings = append(discovery.Warnings, knowledgeWarning{ + Mount: mount.Name, Path: directory, Reason: core.Concat("list failed: ", err.Error()), + }) + return + } + for _, entry := range entries { + if entry == nil || entry.Type()&core.ModeSymlink != 0 { + continue + } + path := joinMediumPath(directory, entry.Name()) + info, err := mount.Medium.Stat(path) + if err != nil { + discovery.Warnings = append(discovery.Warnings, knowledgeWarning{ + Mount: mount.Name, Path: path, Reason: core.Concat("stat failed: ", err.Error()), + }) + continue + } + if info.Mode()&core.ModeSymlink != 0 { + continue + } + if info.IsDir() { + scanKnowledgeDirectory(mount, path, maxBytes, visitedDirectories, visitedDocuments, discovery) + continue + } + extension := core.Lower(core.PathExt(path)) + if extension != ".md" && extension != ".markdown" { + continue + } + documentKey := core.Concat(mount.Name, "\x00", path) + if visitedDocuments[documentKey] { + continue + } + visitedDocuments[documentKey] = true + if info.Size() > maxBytes { + discovery.Warnings = append(discovery.Warnings, knowledgeWarning{ + Mount: mount.Name, + Path: path, + Reason: core.Sprintf("file exceeds the %d bytes limit", maxBytes), + }) + continue + } + content, err := mount.Medium.Read(path) + if err != nil { + discovery.Warnings = append(discovery.Warnings, knowledgeWarning{ + Mount: mount.Name, Path: path, Reason: core.Concat("read failed: ", err.Error()), + }) + continue + } + if int64(len(content)) > maxBytes { + discovery.Warnings = append(discovery.Warnings, knowledgeWarning{ + Mount: mount.Name, + Path: path, + Reason: core.Sprintf("file exceeds the %d bytes limit", maxBytes), + }) + continue + } + discovery.Documents = append(discovery.Documents, knowledgeDocument{ + Mount: mount.Name, + Path: path, + Title: knowledgeTitle(path, content), + Content: content, + ContentHash: core.SHA256HexString(content), + ModifiedAt: info.ModTime().UTC(), + }) + } +} + +func knowledgeTitle(path, content string) string { + for _, line := range core.Split(content, "\n") { + line = core.Trim(line) + if !core.HasPrefix(line, "#") { + continue + } + for core.HasPrefix(line, "#") { + line = core.TrimPrefix(line, "#") + } + if title := core.Trim(line); title != "" { + return title + } + } + base := core.PathBase(path) + return core.TrimSuffix(base, core.PathExt(base)) +} + +func trimMediumPath(path string) string { + path = core.Trim(path) + for core.HasPrefix(path, "/") { + path = core.TrimPrefix(path, "/") + } + for core.HasSuffix(path, "/") { + path = core.TrimSuffix(path, "/") + } + if path == "." { + return "" + } + return path +} + +func joinMediumPath(directory, name string) string { + directory = trimMediumPath(directory) + name = trimMediumPath(name) + if directory == "" { + return name + } + if name == "" { + return directory + } + return core.Concat(directory, "/", name) +} + +type knowledgeLibrary struct { + repository workspaceRepository + maxBytes int64 + ids func() string + now func() time.Time +} + +func newKnowledgeLibrary( + repository workspaceRepository, + maxBytes int64, + ids func() string, + now func() time.Time, +) core.Result { + if repository == nil { + return core.Fail(core.E("tui.newKnowledgeLibrary", "workspace repository is required", nil)) + } + if maxBytes <= 0 { + return core.Fail(core.E("tui.newKnowledgeLibrary", "knowledge byte limit must be positive", nil)) + } + if ids == nil { + ids = newRecordID + } + if now == nil { + now = time.Now + } + return core.Ok(&knowledgeLibrary{repository: repository, maxBytes: maxBytes, ids: ids, now: now}) +} + +func (library *knowledgeLibrary) Attach(sessionID string, document knowledgeDocument) core.Result { + if library == nil || library.repository == nil { + return core.Fail(core.E("tui.knowledgeLibrary.Attach", "knowledge library is unavailable", nil)) + } + sessionID = core.Trim(sessionID) + if sessionID == "" { + return core.Fail(core.E("tui.knowledgeLibrary.Attach", "session ID is required", nil)) + } + if core.Trim(document.Path) == "" { + return core.Fail(core.E("tui.knowledgeLibrary.Attach", "document path is required", nil)) + } + activeResult := library.Attachments(sessionID) + if !activeResult.OK { + return activeResult + } + active := activeResult.Value.([]attachmentRecord) + sourcePath := knowledgeSourcePath(document) + totalBytes := int64(len(document.Content)) + for _, attachment := range active { + if attachment.SourcePath == sourcePath { + return core.Ok(attachment) + } + totalBytes += int64(len(attachment.Snapshot)) + } + if int64(len(document.Content)) > library.maxBytes || totalBytes > library.maxBytes { + return core.Fail(core.E( + "tui.knowledgeLibrary.Attach", + core.Sprintf("knowledge snapshots exceed the %d bytes session limit", library.maxBytes), + nil, + )) + } + id := core.Trim(library.ids()) + if id == "" { + return core.Fail(core.E("tui.knowledgeLibrary.Attach", "attachment ID generator returned an empty value", nil)) + } + now := library.now().UTC() + record := attachmentRecord{ + ID: id, + SessionID: sessionID, + SourcePath: sourcePath, + Title: document.Title, + ContentHash: core.SHA256HexString(document.Content), + Snapshot: document.Content, + AddedAt: now, + LastCheckedAt: now, + ArchivedAt: unsetRecordTime(), + } + if result := library.repository.SaveAttachment(record); !result.OK { + return result + } + return core.Ok(record) +} + +func (library *knowledgeLibrary) Detach(sessionID, attachmentID string) core.Result { + if library == nil { + return core.Fail(core.E("tui.knowledgeLibrary.Detach", "knowledge library is unavailable", nil)) + } + result := library.Attachments(sessionID) + if !result.OK { + return result + } + for _, attachment := range result.Value.([]attachmentRecord) { + if attachment.ID != attachmentID { + continue + } + attachment.Archived = true + attachment.ArchivedAt = library.now().UTC() + if saved := library.repository.SaveAttachment(attachment); !saved.OK { + return saved + } + return core.Ok(attachment) + } + return core.Fail(core.E("tui.knowledgeLibrary.Detach", core.Concat("unknown attachment: ", attachmentID), nil)) +} + +func (library *knowledgeLibrary) Attachments(sessionID string) core.Result { + if library == nil || library.repository == nil { + return core.Fail(core.E("tui.knowledgeLibrary.Attachments", "knowledge library is unavailable", nil)) + } + return library.repository.Attachments(sessionID) +} + +func (library *knowledgeLibrary) RefreshStaleness(sessionID string, documents []knowledgeDocument) core.Result { + if library == nil { + return core.Fail(core.E("tui.knowledgeLibrary.RefreshStaleness", "knowledge library is unavailable", nil)) + } + result := library.Attachments(sessionID) + if !result.OK { + return result + } + hashes := make(map[string]string, len(documents)) + for _, document := range documents { + hashes[knowledgeSourcePath(document)] = core.SHA256HexString(document.Content) + } + updated := append([]attachmentRecord(nil), result.Value.([]attachmentRecord)...) + for index := range updated { + currentHash, exists := hashes[updated[index].SourcePath] + updated[index].Stale = !exists || currentHash != updated[index].ContentHash + updated[index].LastCheckedAt = library.now().UTC() + if saved := library.repository.SaveAttachment(updated[index]); !saved.OK { + return saved + } + } + return core.Ok(updated) +} + +func knowledgeSystemMessage(attachments []attachmentRecord) string { + return knowledgeSystemMessageBounded(attachments, knowledgeSystemMessageMaxBytes) +} + +func knowledgeSystemMessageBounded(attachments []attachmentRecord, maxBytes int) string { + if len(attachments) == 0 || maxBytes <= 0 { + return "" + } + ordered := append([]attachmentRecord(nil), attachments...) + sort.SliceStable(ordered, func(left, right int) bool { + if ordered[left].AddedAt.Equal(ordered[right].AddedAt) { + return ordered[left].SourcePath < ordered[right].SourcePath + } + return ordered[left].AddedAt.Before(ordered[right].AddedAt) + }) + builder := core.NewBuilder() + header := "Local knowledge snapshots (read-only context):" + if len(header) > maxBytes { + return "" + } + builder.WriteString(header) + for _, attachment := range ordered { + if attachment.Archived { + continue + } + stale := "" + if attachment.Stale { + stale = " [source changed; preserved snapshot]" + } + section := core.Concat( + "\n\n## ", attachment.Title, stale, + "\nSource: ", attachment.SourcePath, + "\n\n", attachment.Snapshot, + ) + if builder.Len()+len(section) > maxBytes { + continue + } + builder.WriteString(section) + } + return builder.String() +} + +func knowledgeSourcePath(document knowledgeDocument) string { + if document.Mount == "" { + return document.Path + } + return core.Concat(document.Mount, ":", document.Path) +} + +type knowledgeInspection struct { + ready bool + documents []knowledgeDocument + warnings []knowledgeWarning +} + +func knowledgeInspectionFrom(result core.Result) knowledgeInspection { + inspection := knowledgeInspection{ready: true} + if !result.OK { + inspection.warnings = []knowledgeWarning{{Reason: result.Error()}} + return inspection + } + discovery, ok := result.Value.(knowledgeDiscovery) + if !ok { + inspection.warnings = []knowledgeWarning{{Reason: "invalid knowledge discovery result"}} + return inspection + } + inspection.documents = append([]knowledgeDocument(nil), discovery.Documents...) + inspection.warnings = append([]knowledgeWarning(nil), discovery.Warnings...) + return inspection +} diff --git a/cli/tui/knowledge_test.go b/cli/tui/knowledge_test.go new file mode 100644 index 000000000..a8d0b8bab --- /dev/null +++ b/cli/tui/knowledge_test.go @@ -0,0 +1,235 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package tui + +import ( + "errors" + "io/fs" + "strings" + "testing" + "time" + + core "dappco.re/go" + coreio "dappco.re/go/io" +) + +func TestKnowledgeDiscover_Good(t *testing.T) { + local := coreio.NewMemoryMedium() + additional := coreio.NewMemoryMedium() + writeKnowledgeFixture(t, local, "packs/zeta.md", "# Zeta\n\nLast document.") + writeKnowledgeFixture(t, local, "packs/nested/guide.markdown", "No heading here.\n") + writeKnowledgeFixture(t, additional, "alpha.md", "# Alpha\n\nFirst document.") + + result := newKnowledgeScanner().Discover([]knowledgeMount{ + {Name: "local", Root: "packs", Medium: local}, + {Name: "additional", Root: "", Medium: additional}, + }, 1024) + if !result.OK { + t.Fatalf("Discover: %v", result.Value) + } + discovery := result.Value.(knowledgeDiscovery) + if len(discovery.Warnings) != 0 || len(discovery.Documents) != 3 { + t.Fatalf("discovery = %#v", discovery) + } + want := []struct{ mount, title, path string }{ + {"additional", "Alpha", "alpha.md"}, + {"local", "guide", "packs/nested/guide.markdown"}, + {"local", "Zeta", "packs/zeta.md"}, + } + for index, expected := range want { + document := discovery.Documents[index] + if document.Mount != expected.mount || document.Title != expected.title || document.Path != expected.path { + t.Fatalf("document %d = %#v, want %#v", index, document, expected) + } + if document.ContentHash != core.SHA256HexString(document.Content) { + t.Fatalf("document %d hash = %q", index, document.ContentHash) + } + } +} + +func TestKnowledgeDiscover_Bad(t *testing.T) { + medium := coreio.NewMemoryMedium() + writeKnowledgeFixture(t, medium, "packs/small.md", "# Small\nvalid") + writeKnowledgeFixture(t, medium, "packs/oversized.md", "# Huge\n"+strings.Repeat("x", 256)) + result := newKnowledgeScanner().Discover([]knowledgeMount{{Name: "local", Root: "packs", Medium: medium}}, 32) + if !result.OK { + t.Fatalf("Discover should retain valid documents: %v", result.Value) + } + discovery := result.Value.(knowledgeDiscovery) + if len(discovery.Documents) != 1 || discovery.Documents[0].Title != "Small" || len(discovery.Warnings) != 1 { + t.Fatalf("partial discovery = %#v", discovery) + } + warning := discovery.Warnings[0] + if warning.Path != "packs/oversized.md" || !strings.Contains(warning.Reason, "32 bytes") { + t.Fatalf("oversized warning = %#v", warning) + } + a := newApp("", 0, 64) + a.activePanel = panelChat + a.inspector.ApplyKnowledge(result) + view := a.inspector.View(a, 72, 40) + if !strings.Contains(view, "KNOWLEDGE") || !strings.Contains(view, "oversized.md") || !strings.Contains(view, "32 bytes") { + t.Fatalf("visible knowledge warning:\n%s", view) + } +} + +func TestKnowledgeDiscover_Ugly(t *testing.T) { + base := coreio.NewMemoryMedium() + writeKnowledgeFixture(t, base, "packs/real.md", "# Real\n") + writeKnowledgeFixture(t, base, "packs/unreadable.md", "# Secret\n") + writeKnowledgeFixture(t, base, "packs/ignore.txt", "not markdown") + medium := &fixtureKnowledgeMedium{ + Medium: base, + unreadable: map[string]bool{"packs/unreadable.md": true}, + reads: map[string]int{}, + lists: map[string]int{}, + } + mount := knowledgeMount{Name: "local", Root: "packs", Medium: medium} + result := newKnowledgeScanner().Discover([]knowledgeMount{mount, mount}, 1024) + if !result.OK { + t.Fatalf("Discover ugly: %v", result.Value) + } + discovery := result.Value.(knowledgeDiscovery) + if len(discovery.Documents) != 1 || discovery.Documents[0].Title != "Real" || len(discovery.Warnings) != 1 { + t.Fatalf("ugly discovery = %#v", discovery) + } + if medium.reads["packs/real.md"] != 1 || medium.lists["packs/loop"] != 0 { + t.Fatalf("duplicate/symlink traversal: reads=%#v lists=%#v", medium.reads, medium.lists) + } +} + +func TestKnowledgeAttachment_Good(t *testing.T) { + databasePath := t.TempDir() + "/lem.duckdb" + repositoryResult := openDuckRepository(databasePath) + if !repositoryResult.OK { + t.Fatalf("open repository: %v", repositoryResult.Value) + } + repository := repositoryResult.Value.(workspaceRepository) + createdAt := time.Date(2026, time.July, 17, 19, 0, 0, 0, time.UTC) + document := knowledgeDocument{ + Mount: "local", Path: "packs/guide.md", Title: "Guide", + Content: "# Guide\n\nUse the durable snapshot.", ContentHash: core.SHA256HexString("# Guide\n\nUse the durable snapshot."), + } + opened := newKnowledgeLibrary(repository, 4096, sequenceIDs("attachment-1"), func() time.Time { return createdAt }) + if !opened.OK { + t.Fatalf("newKnowledgeLibrary: %v", opened.Value) + } + library := opened.Value.(*knowledgeLibrary) + attached := library.Attach("session-knowledge", document) + if !attached.OK { + t.Fatalf("Attach: %v", attached.Value) + } + attachments := library.Attachments("session-knowledge") + if !attachments.OK { + t.Fatalf("Attachments: %v", attachments.Value) + } + records := attachments.Value.([]attachmentRecord) + message := knowledgeSystemMessage(records) + if len(message) > knowledgeSystemMessageMaxBytes || !strings.Contains(message, "Guide") || !strings.Contains(message, document.Content) { + t.Fatalf("knowledge system message = %q", message) + } + a := newApp("", 0, 64) + a.knowledge = library + a.attachments = records + a.tools.setEnabled(true) + history := a.history() + if len(history) != 1 || history[0].Role != "system" { + t.Fatalf("knowledge/tool history = %#v", history) + } + knowledgeAt := strings.Index(history[0].Content, "Local knowledge snapshots") + toolsAt := strings.Index(history[0].Content, a.tools.declarations()) + if knowledgeAt < 0 || toolsAt <= knowledgeAt { + t.Fatalf("system message ordering = %q", history[0].Content) + } + if result := repository.Close(); !result.OK { + t.Fatalf("close repository: %v", result.Value) + } + + reopened := openDuckRepository(databasePath) + if !reopened.OK { + t.Fatalf("reopen repository: %v", reopened.Value) + } + repository = reopened.Value.(workspaceRepository) + defer closeTestDuckRepository(t, repository) + restarted := newKnowledgeLibrary(repository, 4096, sequenceIDs("unused"), func() time.Time { return createdAt.Add(time.Hour) }) + if !restarted.OK { + t.Fatalf("restart library: %v", restarted.Value) + } + restored := restarted.Value.(*knowledgeLibrary).Attachments("session-knowledge") + if !restored.OK { + t.Fatalf("restored attachments: %v", restored.Value) + } + stored := restored.Value.([]attachmentRecord) + if len(stored) != 1 || stored[0].Snapshot != document.Content || knowledgeSystemMessage(stored) != message { + t.Fatalf("restored snapshot = %#v", stored) + } + if result := restarted.Value.(*knowledgeLibrary).Detach("session-knowledge", stored[0].ID); !result.OK { + t.Fatalf("Detach: %v", result.Value) + } + if active := restarted.Value.(*knowledgeLibrary).Attachments("session-knowledge"); !active.OK || len(active.Value.([]attachmentRecord)) != 0 { + t.Fatalf("active attachments after detach = %#v", active.Value) + } +} + +func TestKnowledgeAttachmentStale_Good(t *testing.T) { + repository := openTestDuckRepository(t) + defer closeTestDuckRepository(t, repository) + medium := coreio.NewMemoryMedium() + writeKnowledgeFixture(t, medium, "packs/context.md", "# Context\noriginal") + scanner := newKnowledgeScanner() + discovered := scanner.Discover([]knowledgeMount{{Name: "local", Root: "packs", Medium: medium}}, 1024) + document := discovered.Value.(knowledgeDiscovery).Documents[0] + now := time.Date(2026, time.July, 17, 20, 0, 0, 0, time.UTC) + libraryResult := newKnowledgeLibrary(repository, 4096, sequenceIDs("attachment-stale"), func() time.Time { return now }) + if !libraryResult.OK { + t.Fatalf("newKnowledgeLibrary: %v", libraryResult.Value) + } + library := libraryResult.Value.(*knowledgeLibrary) + if result := library.Attach("session-stale", document); !result.OK { + t.Fatalf("Attach: %v", result.Value) + } + original := document.Content + writeKnowledgeFixture(t, medium, "packs/context.md", "# Context\nchanged") + changed := scanner.Discover([]knowledgeMount{{Name: "local", Root: "packs", Medium: medium}}, 1024) + if result := library.RefreshStaleness("session-stale", changed.Value.(knowledgeDiscovery).Documents); !result.OK { + t.Fatalf("RefreshStaleness: %v", result.Value) + } + attachments := library.Attachments("session-stale").Value.([]attachmentRecord) + if len(attachments) != 1 || !attachments[0].Stale || attachments[0].Snapshot != original { + t.Fatalf("stale attachment = %#v", attachments) + } +} + +func writeKnowledgeFixture(t *testing.T, medium coreio.Medium, path, content string) { + t.Helper() + if err := medium.Write(path, content); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +type fixtureKnowledgeMedium struct { + coreio.Medium + unreadable map[string]bool + reads map[string]int + lists map[string]int +} + +func (medium *fixtureKnowledgeMedium) Read(path string) (string, error) { + medium.reads[path]++ + if medium.unreadable[path] { + return "", errors.New("fixture unreadable") + } + return medium.Medium.Read(path) +} + +func (medium *fixtureKnowledgeMedium) List(path string) ([]fs.DirEntry, error) { + medium.lists[path]++ + entries, err := medium.Medium.List(path) + if err != nil { + return nil, err + } + if path == "packs" { + info := coreio.NewFileInfo("loop", 0, core.ModeSymlink|0777, time.Time{}, false) + entries = append(entries, coreio.NewDirEntry("loop", false, core.ModeSymlink|0777, info)) + } + return entries, nil +} diff --git a/cli/tui/layout.go b/cli/tui/layout.go new file mode 100644 index 000000000..512650593 --- /dev/null +++ b/cli/tui/layout.go @@ -0,0 +1,163 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package tui + +import ( + "github.com/charmbracelet/lipgloss" + + core "dappco.re/go" +) + +type layoutKind uint8 + +const ( + layoutNarrow layoutKind = iota + layoutOverlay + layoutWide +) + +const wideInspectorWidth = 32 + +// frameInsetRows and frameInsetCols map screen cells to frame-inner cells: +// renderFrame's outer rounded border occupies row 0 and column 0, so inner +// content — the panel bar first — begins one cell in on both axes. Mouse +// hit-testing subtracts these before resolving against a component box map. +const ( + frameInsetRows = 1 + frameInsetCols = 1 +) + +func chooseLayout(width int) layoutKind { + switch { + case width >= 120: + return layoutWide + case width >= 80: + return layoutOverlay + default: + return layoutNarrow + } +} + +type frameSpec struct { + Width int + Height int + Active panelID + SessionStrip string + Main string + Inspector string + Footer string + InspectorOpen bool +} + +type frameMetrics struct { + kind layoutKind + innerWidth int + innerHeight int + regionHeight int + mainWidth int + mainHeight int + inspectorWidth int + inspectorHeight int +} + +func measureFrame(width, height int, inspectorOpen bool) frameMetrics { + metrics := frameMetrics{ + kind: chooseLayout(width), + innerWidth: max(0, width-2), + innerHeight: max(0, height-2), + regionHeight: max(1, height-5), // border + header + sessions + footer + } + metrics.mainWidth = metrics.innerWidth + metrics.mainHeight = metrics.regionHeight + switch metrics.kind { + case layoutWide: + metrics.inspectorWidth = wideInspectorWidth + metrics.inspectorHeight = metrics.regionHeight + metrics.mainWidth = max(1, metrics.innerWidth-wideInspectorWidth-1) + case layoutOverlay: + if inspectorOpen { + metrics.inspectorWidth = metrics.innerWidth + // Contextual inspectors carry several short sections. Give them room + // to remain useful while preserving a compact main-panel preview. + metrics.inspectorHeight = min(12, max(3, metrics.regionHeight-5)) + metrics.mainHeight = max(1, metrics.regionHeight-metrics.inspectorHeight-1) + } + case layoutNarrow: + if inspectorOpen { + metrics.inspectorWidth = metrics.innerWidth + metrics.inspectorHeight = metrics.regionHeight + } + } + return metrics +} + +// renderFrame composes the permanent workspace shell. All truncation is done +// by Lip Gloss on rendered cell widths; no ANSI string is sliced manually. +func renderFrame(spec frameSpec, styles uiStyles) string { + if spec.Width <= 0 || spec.Height <= 0 { + return "" + } + if spec.Width < 3 || spec.Height < 4 { + return minimalFrame(spec.Width, spec.Height) + } + metrics := measureFrame(spec.Width, spec.Height, spec.InspectorOpen) + header := renderPanelBar(spec.Active, metrics.innerWidth, metrics.kind, styles) + sessions := fitLine(styles.title.Render("SESSIONS")+" "+styles.session.Render(spec.SessionStrip), metrics.innerWidth, styles.session) + region := renderWorkspaceRegion(spec, metrics, styles) + footer := fitLine(spec.Footer, metrics.innerWidth, styles.footer) + inside := lipgloss.JoinVertical(lipgloss.Left, header, sessions, region, footer) + return styles.outerFrame. + Width(metrics.innerWidth). + Height(metrics.innerHeight). + MaxWidth(spec.Width). + MaxHeight(spec.Height). + Render(inside) +} + +func renderWorkspaceRegion(spec frameSpec, metrics frameMetrics, styles uiStyles) string { + switch metrics.kind { + case layoutWide: + main := fitPane(spec.Main, metrics.mainWidth, metrics.mainHeight, styles.panel) + separator := fitPane(core.Repeat("│\n", max(0, metrics.regionHeight-1))+"│", 1, metrics.regionHeight, styles.separator) + inspector := fitPane(spec.Inspector, metrics.inspectorWidth, metrics.inspectorHeight, styles.inspector) + return lipgloss.JoinHorizontal(lipgloss.Top, main, separator, inspector) + case layoutOverlay: + if spec.InspectorOpen { + inspector := fitPane(spec.Inspector, metrics.inspectorWidth, metrics.inspectorHeight, styles.inspector) + separator := fitLine(core.Repeat("─", metrics.innerWidth), metrics.innerWidth, styles.separator) + main := fitPane(spec.Main, metrics.mainWidth, metrics.mainHeight, styles.panel) + return lipgloss.JoinVertical(lipgloss.Left, inspector, separator, main) + } + case layoutNarrow: + if spec.InspectorOpen { + return fitPane(spec.Inspector, metrics.innerWidth, metrics.regionHeight, styles.inspector) + } + } + return fitPane(spec.Main, metrics.mainWidth, metrics.mainHeight, styles.panel) +} + +func fitLine(content string, width int, style lipgloss.Style) string { + if width <= 0 { + return "" + } + return style.Width(width).MaxWidth(width).MaxHeight(1).Render(content) +} + +func fitPane(content string, width, height int, style lipgloss.Style) string { + if width <= 0 || height <= 0 { + return "" + } + return style.Width(width).Height(height).MaxWidth(width).MaxHeight(height).Render(content) +} + +func minimalFrame(width, height int) string { + if width <= 0 || height <= 0 { + return "" + } + line := core.Repeat("─", width) + lines := make([]string, height) + for i := range lines { + lines[i] = line + } + return core.Join("\n", lines...) +} diff --git a/cli/tui/layout_test.go b/cli/tui/layout_test.go new file mode 100644 index 000000000..c791c727e --- /dev/null +++ b/cli/tui/layout_test.go @@ -0,0 +1,137 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package tui + +import ( + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" +) + +func TestChooseLayout_Good(t *testing.T) { + for _, width := range []int{120, 160} { + if got := chooseLayout(width); got != layoutWide { + t.Fatalf("chooseLayout(%d) = %d, want wide", width, got) + } + } +} + +func TestChooseLayout_Bad(t *testing.T) { + for _, width := range []int{80, 119} { + if got := chooseLayout(width); got != layoutOverlay { + t.Fatalf("chooseLayout(%d) = %d, want overlay", width, got) + } + } +} + +func TestChooseLayout_Ugly(t *testing.T) { + for _, width := range []int{0, 1, 79} { + if got := chooseLayout(width); got != layoutNarrow { + t.Fatalf("chooseLayout(%d) = %d, want narrow", width, got) + } + } +} + +func TestPanelID_Good(t *testing.T) { + want := []panelID{panelChat, panelWork, panelModels, panelService, panelData} + panel := panelChat + for i, expected := range want { + if panel != expected { + t.Fatalf("forward panel %d = %d, want %d", i, panel, expected) + } + panel = panel.next() + } + if panel != panelChat { + t.Fatalf("forward wrap = %d, want chat", panel) + } + panel = panelChat + for i := len(want) - 1; i >= 0; i-- { + panel = panel.prev() + if panel != want[i] { + t.Fatalf("reverse panel %d = %d, want %d", i, panel, want[i]) + } + } +} + +func TestWorkspaceFrame_Good(t *testing.T) { + styles := newUIStyles(midnightTheme()) + view := renderFrame(frameSpec{ + Width: 140, + Height: 26, + Active: panelChat, + SessionStrip: "● New session ○ Refactor scheduler", + Main: "MAIN REGION\nchat transcript", + Inspector: "INSPECTOR\nmodel ready", + Footer: "FOOTER ctrl+k commands", + }, styles) + assertFrameBasics(t, view, 140) + if !strings.Contains(view, "INSPECTOR") { + t.Fatal("wide frame did not render its permanent inspector") + } +} + +func TestWorkspaceFrame_Bad(t *testing.T) { + styles := newUIStyles(midnightTheme()) + closed := renderFrame(frameSpec{ + Width: 100, Height: 24, Active: panelModels, + SessionStrip: "● New session", Main: "MAIN REGION", Inspector: "INSPECTOR", Footer: "FOOTER", + }, styles) + assertFrameBasics(t, closed, 100) + if strings.Contains(closed, "INSPECTOR") { + t.Fatal("closed overlay frame rendered the inspector") + } + open := renderFrame(frameSpec{ + Width: 100, Height: 24, Active: panelModels, InspectorOpen: true, + SessionStrip: "● New session", Main: "MAIN REGION", Inspector: "INSPECTOR", Footer: "FOOTER", + }, styles) + assertFrameBasics(t, open, 100) + if !strings.Contains(open, "INSPECTOR") || !strings.Contains(open, "MAIN REGION") { + t.Fatal("open overlay must retain both inspector and main panel") + } +} + +func TestWorkspaceFrame_Ugly(t *testing.T) { + styles := newUIStyles(midnightTheme()) + closed := renderFrame(frameSpec{ + Width: 72, Height: 22, Active: panelWork, + SessionStrip: "● New session", Main: "MAIN REGION", Inspector: "INSPECTOR", Footer: "FOOTER", + }, styles) + assertFrameBasics(t, closed, 72) + if strings.Contains(closed, "INSPECTOR") { + t.Fatal("closed narrow frame rendered the inspector") + } + open := renderFrame(frameSpec{ + Width: 72, Height: 22, Active: panelWork, InspectorOpen: true, + SessionStrip: "● New session", Main: "MAIN REGION", Inspector: "INSPECTOR", Footer: "FOOTER", + }, styles) + if !strings.Contains(open, "INSPECTOR") { + t.Fatal("open narrow frame did not render inspector as the content view") + } + if strings.Contains(open, "MAIN REGION") { + t.Fatal("open narrow inspector should own the single content column") + } + assertFrameWidth(t, open, 72) +} + +func assertFrameBasics(t *testing.T, view string, width int) { + t.Helper() + for _, text := range []string{"LEM", "Chat", "Work", "Models", "Data", "SESSIONS", "MAIN REGION", "FOOTER"} { + if !strings.Contains(view, text) { + t.Fatalf("frame missing %q\n%s", text, view) + } + } + if !strings.HasPrefix(view, "╭") || !strings.Contains(view, "╰") { + t.Fatalf("frame missing stable rounded outer border\n%s", view) + } + assertFrameWidth(t, view, width) +} + +func assertFrameWidth(t *testing.T, view string, width int) { + t.Helper() + for line, text := range strings.Split(view, "\n") { + if got := lipgloss.Width(text); got > width { + t.Fatalf("line %d width = %d, exceeds %d: %q", line, got, width, text) + } + } +} diff --git a/cli/tui/markdown.go b/cli/tui/markdown.go new file mode 100644 index 000000000..c4c1288d3 --- /dev/null +++ b/cli/tui/markdown.go @@ -0,0 +1,128 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package tui + +import ( + "crypto/sha256" + "sync" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/glamour" +) + +type markdownCacheKey struct { + turnID string + hash [sha256.Size]byte + width int + theme string +} + +type markdownStats struct { + Hits uint64 + Misses uint64 +} + +// markdownRenderer caches completed turn output and the width-specific +// Glamour renderer behind one lock; Glamour reuses internal buffers and is not +// safe to invoke concurrently. +type markdownRenderer struct { + mu sync.Mutex + theme string + cache map[markdownCacheKey]string + renderers map[int]*glamour.TermRenderer + stats markdownStats +} + +func newMarkdownRenderer(theme string) *markdownRenderer { + return &markdownRenderer{ + theme: theme, + cache: make(map[markdownCacheKey]string), + renderers: make(map[int]*glamour.TermRenderer), + } +} + +// Render returns the cached rich representation of one completed turn. An +// unusable width or renderer failure degrades to the original readable text. +func (renderer *markdownRenderer) Render(turnID, content string, width int) string { + if renderer == nil || width <= 0 { + return content + } + key := markdownCacheKey{ + turnID: turnID, + hash: sha256.Sum256([]byte(content)), + width: width, + theme: renderer.theme, + } + renderer.mu.Lock() + defer renderer.mu.Unlock() + if rendered, exists := renderer.cache[key]; exists { + renderer.stats.Hits++ + return rendered + } + renderer.stats.Misses++ + term := renderer.renderers[width] + if term == nil { + built, err := glamour.NewTermRenderer( + glamour.WithStandardStyle("dark"), + glamour.WithWordWrap(width), + glamour.WithTableWrap(true), + glamour.WithPreservedNewLines(), + glamour.WithEmoji(), + ) + if err != nil { + renderer.cache[key] = content + return content + } + term = built + renderer.renderers[width] = term + } + rendered, err := term.Render(content) + if err != nil || rendered == "" { + rendered = content + } + renderer.cache[key] = rendered + return rendered +} + +func (renderer *markdownRenderer) Stats() markdownStats { + if renderer == nil { + return markdownStats{} + } + renderer.mu.Lock() + defer renderer.mu.Unlock() + return renderer.stats +} + +const streamRefreshInterval = 40 * time.Millisecond + +type streamRefreshMsg struct { + SessionID string + JobID string +} + +// waitEventOrRefresh keeps exactly one Bubble Tea command in flight. It +// returns the next stream event, or a bounded refresh signal when tokens are +// arriving faster than the terminal should re-render. +func waitEventOrRefresh(generation *generation, deadline time.Time) tea.Cmd { + return func() tea.Msg { + if generation == nil { + return streamMsg{done: true} + } + delay := time.Until(deadline) + if delay <= 0 { + return streamRefreshMsg{SessionID: generation.SessionID, JobID: generation.JobID} + } + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case event, ok := <-generation.events: + if !ok { + return streamMsg{SessionID: generation.SessionID, JobID: generation.JobID, done: true} + } + return streamMsg(event) + case <-timer.C: + return streamRefreshMsg{SessionID: generation.SessionID, JobID: generation.JobID} + } + } +} diff --git a/cli/tui/markdown_bench_test.go b/cli/tui/markdown_bench_test.go new file mode 100644 index 000000000..215e226fd --- /dev/null +++ b/cli/tui/markdown_bench_test.go @@ -0,0 +1,30 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package tui + +import "testing" + +var sinkMarkdownTranscript string + +func BenchmarkMarkdownTranscript(b *testing.B) { + renderer := newMarkdownRenderer("midnight") + turns := make([]string, 20) + for i := range turns { + turns[i] = "## Assistant result\n\n- item one\n- item two\n\n```go\nfunc answer() int { return 42 }\n```" + } + for i, content := range turns { + sinkMarkdownTranscript = renderer.Render(benchmarkTurnID(i), content, 78) + } + b.ReportAllocs() + b.ResetTimer() + for range b.N { + for i, content := range turns { + sinkMarkdownTranscript = renderer.Render(benchmarkTurnID(i), content, 78) + } + } +} + +func benchmarkTurnID(index int) string { + const digits = "0123456789" + return "benchmark-turn-" + string(digits[index/10]) + string(digits[index%10]) +} diff --git a/cli/tui/markdown_test.go b/cli/tui/markdown_test.go new file mode 100644 index 000000000..dae7689ae --- /dev/null +++ b/cli/tui/markdown_test.go @@ -0,0 +1,55 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package tui + +import ( + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" +) + +func TestMarkdownRenderer_Good(t *testing.T) { + renderer := newMarkdownRenderer("midnight") + markdown := "# Heading\n\n" + + "A deliberately long sentence that should wrap differently when the transcript becomes narrow.\n\n" + + "- first item\n- **bold item** with `inline()` code\n\n" + + "```go\nfunc main() { println(\"hello\") }\n```\n\n" + + "[OpenAI](https://openai.com) :sparkles:" + + wide := renderer.Render("turn-wide", markdown, 72) + narrow := renderer.Render("turn-narrow", markdown, 32) + for name, rendered := range map[string]string{"wide": wide, "narrow": narrow} { + plain := ansi.Strip(rendered) + for _, text := range []string{"Heading", "first item", "bold item", "inline()", "func main()", "OpenAI", "✨"} { + if !strings.Contains(plain, text) { + t.Fatalf("%s render missing %q:\n%s", name, text, plain) + } + } + } + if strings.Count(ansi.Strip(narrow), "\n") <= strings.Count(ansi.Strip(wide), "\n") { + t.Fatal("narrow Markdown did not wrap to more lines than wide Markdown") + } +} + +func TestMarkdownRenderer_Bad(t *testing.T) { + renderer := newMarkdownRenderer("midnight") + const markdown = "**keep this text**" + if got := renderer.Render("turn-invalid", markdown, 0); got != markdown { + t.Fatalf("invalid-width fallback = %q, want original plain text", got) + } +} + +func TestMarkdownRenderer_Ugly(t *testing.T) { + renderer := newMarkdownRenderer("midnight") + first := renderer.Render("turn-cache", "## Cached\n\ncontent", 48) + before := renderer.Stats() + second := renderer.Render("turn-cache", "## Cached\n\ncontent", 48) + after := renderer.Stats() + if first != second { + t.Fatal("cached render changed output") + } + if after.Hits != before.Hits+1 || after.Misses != before.Misses { + t.Fatalf("cache stats before=%+v after=%+v, want one hit", before, after) + } +} diff --git a/cli/tui/migrations.go b/cli/tui/migrations.go new file mode 100644 index 000000000..6807b5c87 --- /dev/null +++ b/cli/tui/migrations.go @@ -0,0 +1,268 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package tui + +import ( + "database/sql" + "time" + + core "dappco.re/go" + "dappco.re/go/orm" + "dappco.re/go/store" +) + +type workspaceMigration struct { + Version int64 + Statements []string +} + +var workspaceMigrations = []workspaceMigration{ + { + Version: 1, + Statements: []string{ + "CREATE TABLE IF NOT EXISTS lem_schema_versions (version BIGINT PRIMARY KEY, applied_at TIMESTAMP NOT NULL)", + "CREATE TABLE IF NOT EXISTS lem_sessions (id TEXT PRIMARY KEY, title TEXT NOT NULL, status TEXT NOT NULL, preferred_model TEXT NOT NULL, mode TEXT NOT NULL, generation_json TEXT NOT NULL, tools_json TEXT NOT NULL, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, last_opened_at TIMESTAMP NOT NULL, archived BOOLEAN NOT NULL, archived_at TIMESTAMP NOT NULL)", + "CREATE TABLE IF NOT EXISTS lem_turns (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, sequence BIGINT NOT NULL, role TEXT NOT NULL, visible TEXT NOT NULL, thought TEXT NOT NULL, tool_name TEXT NOT NULL, tool_call_json TEXT NOT NULL, tool_result_json TEXT NOT NULL, model TEXT NOT NULL, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, UNIQUE(session_id, sequence))", + "CREATE TABLE IF NOT EXISTS lem_events (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, work_item_id TEXT NOT NULL, job_id TEXT NOT NULL, kind TEXT NOT NULL, status TEXT NOT NULL, title TEXT NOT NULL, detail TEXT NOT NULL, payload_json TEXT NOT NULL, created_at TIMESTAMP NOT NULL)", + "CREATE TABLE IF NOT EXISTS lem_generation_jobs (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, prompt_turn_id TEXT NOT NULL, answer_turn_id TEXT NOT NULL, status TEXT NOT NULL, model TEXT NOT NULL, error TEXT NOT NULL, metrics_json TEXT NOT NULL, created_at TIMESTAMP NOT NULL, started_at TIMESTAMP NOT NULL, finished_at TIMESTAMP NOT NULL)", + "CREATE TABLE IF NOT EXISTS lem_work_items (id TEXT PRIMARY KEY, external_id TEXT NOT NULL UNIQUE, source TEXT NOT NULL, title TEXT NOT NULL, status TEXT NOT NULL, agent TEXT NOT NULL, repo TEXT NOT NULL, org TEXT NOT NULL, task TEXT NOT NULL, branch TEXT NOT NULL, runtime TEXT NOT NULL, question TEXT NOT NULL, pr_url TEXT NOT NULL, session_id TEXT NOT NULL, started_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, archived BOOLEAN NOT NULL, archived_at TIMESTAMP NOT NULL)", + "CREATE TABLE IF NOT EXISTS lem_artifacts (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, work_item_id TEXT NOT NULL, kind TEXT NOT NULL, path TEXT NOT NULL, title TEXT NOT NULL, metadata_json TEXT NOT NULL, created_at TIMESTAMP NOT NULL, archived BOOLEAN NOT NULL, archived_at TIMESTAMP NOT NULL)", + "CREATE TABLE IF NOT EXISTS lem_attachments (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, source_path TEXT NOT NULL, title TEXT NOT NULL, content_hash TEXT NOT NULL, snapshot TEXT NOT NULL, added_at TIMESTAMP NOT NULL, last_checked_at TIMESTAMP NOT NULL, stale BOOLEAN NOT NULL, archived BOOLEAN NOT NULL, archived_at TIMESTAMP NOT NULL)", + "CREATE INDEX IF NOT EXISTS lem_sessions_recent_idx ON lem_sessions(archived, last_opened_at)", + "CREATE INDEX IF NOT EXISTS lem_turns_session_idx ON lem_turns(session_id, sequence)", + "CREATE INDEX IF NOT EXISTS lem_events_session_idx ON lem_events(session_id, created_at)", + "CREATE INDEX IF NOT EXISTS lem_jobs_session_idx ON lem_generation_jobs(session_id, status, created_at)", + "CREATE INDEX IF NOT EXISTS lem_work_status_idx ON lem_work_items(archived, status, updated_at)", + "CREATE INDEX IF NOT EXISTS lem_artifacts_session_idx ON lem_artifacts(session_id, created_at)", + "CREATE INDEX IF NOT EXISTS lem_attachments_session_idx ON lem_attachments(session_id, archived, added_at)", + }, + }, + { + Version: 2, + Statements: []string{ + "CREATE TABLE agent_projects (id TEXT PRIMARY KEY, source_path TEXT NOT NULL UNIQUE, repository_root TEXT NOT NULL, source_branch TEXT NOT NULL, source_revision TEXT NOT NULL, repository_name TEXT NOT NULL UNIQUE, clone_path TEXT NOT NULL, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL)", + "CREATE TABLE agent_runs (id TEXT PRIMARY KEY, work_id TEXT NOT NULL, project_id TEXT NOT NULL, parent_run_id TEXT NOT NULL, provider TEXT NOT NULL, model TEXT NOT NULL, source_revision TEXT NOT NULL, durable_revision TEXT NOT NULL DEFAULT '', execution_revision TEXT NOT NULL, accepted_revision TEXT NOT NULL, branch TEXT NOT NULL, worktree TEXT NOT NULL, command_receipt TEXT NOT NULL, run_number INTEGER NOT NULL, attempt INTEGER NOT NULL, process_id BIGINT NOT NULL, status TEXT NOT NULL, exit_code INTEGER NOT NULL, failure_reason TEXT NOT NULL, queued_at TIMESTAMP NOT NULL, started_at TIMESTAMP NOT NULL, finished_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL)", + "CREATE TABLE agent_events (id TEXT PRIMARY KEY, run_id TEXT NOT NULL, work_id TEXT NOT NULL, kind TEXT NOT NULL, title TEXT NOT NULL, detail TEXT NOT NULL, detail_json TEXT NOT NULL, created_at TIMESTAMP NOT NULL)", + "CREATE TABLE agent_log_chunks (run_id TEXT NOT NULL, sequence BIGINT NOT NULL, stream TEXT NOT NULL, text TEXT NOT NULL, created_at TIMESTAMP NOT NULL, PRIMARY KEY (run_id, sequence))", + "CREATE TABLE agent_questions (id TEXT PRIMARY KEY, run_id TEXT NOT NULL UNIQUE, text TEXT NOT NULL, created_at TIMESTAMP NOT NULL)", + "CREATE TABLE agent_answers (id TEXT PRIMARY KEY, question_id TEXT NOT NULL UNIQUE, resume_run_id TEXT NOT NULL, text TEXT NOT NULL, created_at TIMESTAMP NOT NULL)", + "CREATE TABLE agent_acceptances (id TEXT PRIMARY KEY, work_id TEXT NOT NULL, run_id TEXT NOT NULL, source_base TEXT NOT NULL, agent_base TEXT NOT NULL, agent_tip TEXT NOT NULL, integration_branch TEXT NOT NULL, integration_worktree TEXT NOT NULL, result_revision TEXT NOT NULL, status TEXT NOT NULL, validation_json TEXT NOT NULL, failure_reason TEXT NOT NULL, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL)", + "CREATE TABLE agent_queue_state (id TEXT PRIMARY KEY CHECK (id = 'default'), status TEXT NOT NULL CHECK (status IN ('frozen', 'accepting', 'draining')), reason TEXT NOT NULL, updated_at TIMESTAMP NOT NULL)", + "CREATE TABLE agent_provider_state (provider TEXT PRIMARY KEY, backoff_reason TEXT NOT NULL, last_run_id TEXT NOT NULL, backoff_until TIMESTAMP NOT NULL, last_started_at TIMESTAMP NOT NULL, window_started_at TIMESTAMP NOT NULL, window_admissions INTEGER NOT NULL, updated_at TIMESTAMP NOT NULL)", + "CREATE UNIQUE INDEX agent_runs_work_number_attempt_idx ON agent_runs(work_id, run_number, attempt)", + "CREATE INDEX agent_runs_work_idx ON agent_runs(work_id, queued_at)", + "CREATE INDEX agent_runs_status_idx ON agent_runs(status, provider, model, queued_at)", + "CREATE INDEX agent_events_run_idx ON agent_events(run_id, created_at, id)", + "CREATE INDEX agent_acceptances_work_idx ON agent_acceptances(work_id, updated_at)", + }, + }, +} + +type workspaceDatabase struct { + runtime *core.Core + store *store.DuckDB + medium *orm.DuckDBMedium +} + +func openWorkspaceDatabase(path string) core.Result { + path = core.Trim(path) + if path == "" { + return core.Fail(core.E("tui.openWorkspaceDatabase", "database path is required", nil)) + } + + storeResult := store.OpenDuckDBReadWrite(path) + if !storeResult.OK { + return core.Fail(core.E("tui.openWorkspaceDatabase", core.Concat("open DuckDB: ", path), resultError(storeResult))) + } + databaseStore, ok := storeResult.Value.(*store.DuckDB) + if !ok { + return core.Fail(core.E("tui.openWorkspaceDatabase", "invalid go-store DuckDB result", nil)) + } + if result := applyWorkspaceMigrations(databaseStore, workspaceMigrations, time.Now); !result.OK { + closeStoreAfterFailure(databaseStore) + return core.Fail(core.E("tui.openWorkspaceDatabase", core.Concat("migrate DuckDB: ", path), resultError(result))) + } + + mediumResult := orm.NewDuckDB(path) + if !mediumResult.OK { + closeStoreAfterFailure(databaseStore) + return core.Fail(core.E("tui.openWorkspaceDatabase", core.Concat("open ORM DuckDB: ", path), resultError(mediumResult))) + } + medium, ok := mediumResult.Value.(*orm.DuckDBMedium) + if !ok { + closeStoreAfterFailure(databaseStore) + return core.Fail(core.E("tui.openWorkspaceDatabase", "invalid ORM DuckDB result", nil)) + } + + runtime := core.New() + if result := orm.Mount(runtime, "default", medium); !result.OK { + closeDatabaseAfterFailure(databaseStore, medium, runtime) + return core.Fail(core.E("tui.openWorkspaceDatabase", "mount ORM DuckDB", resultError(result))) + } + for _, schema := range workspaceRecordSchemas() { + medium.RegisterTable(schema.Name, schema) + if result := orm.RegisterSchema(runtime, schema); !result.OK { + closeDatabaseAfterFailure(databaseStore, medium, runtime) + return core.Fail(core.E("tui.openWorkspaceDatabase", core.Concat("register ORM schema: ", schema.Name), resultError(result))) + } + } + + return core.Ok(&workspaceDatabase{ + runtime: runtime, + store: databaseStore, + medium: medium, + }) +} + +func applyWorkspaceMigrations(database *store.DuckDB, migrations []workspaceMigration, now func() time.Time) core.Result { + if database == nil || database.Conn() == nil { + return core.Fail(core.E("tui.applyWorkspaceMigrations", "DuckDB connection is required", nil)) + } + if now == nil { + now = time.Now + } + + for _, migration := range migrations { + if migration.Version < 1 { + return core.Fail(core.E("tui.applyWorkspaceMigrations", "migration version must be positive", nil)) + } + applied, err := workspaceMigrationApplied(database, migration.Version) + if err != nil { + return core.Fail(core.E( + "tui.applyWorkspaceMigrations", + core.Sprintf("read migration version %d", migration.Version), + err, + )) + } + if applied { + continue + } + + transaction, err := database.Conn().Begin() + if err != nil { + return core.Fail(core.E( + "tui.applyWorkspaceMigrations", + core.Sprintf("begin migration version %d", migration.Version), + err, + )) + } + for _, statement := range migration.Statements { + if core.Trim(statement) == "" { + rollbackWorkspaceMigration(transaction) + return core.Fail(core.E( + "tui.applyWorkspaceMigrations", + core.Sprintf("migration version %d contains an empty statement", migration.Version), + nil, + )) + } + if _, err := transaction.Exec(statement); err != nil { + rollbackWorkspaceMigration(transaction) + return core.Fail(core.E( + "tui.applyWorkspaceMigrations", + core.Sprintf("execute migration version %d", migration.Version), + err, + )) + } + } + if _, err := transaction.Exec( + "INSERT INTO lem_schema_versions (version, applied_at) VALUES (?, ?)", + migration.Version, + now().UTC(), + ); err != nil { + rollbackWorkspaceMigration(transaction) + return core.Fail(core.E( + "tui.applyWorkspaceMigrations", + core.Sprintf("record migration version %d", migration.Version), + err, + )) + } + if err := transaction.Commit(); err != nil { + rollbackWorkspaceMigration(transaction) + return core.Fail(core.E( + "tui.applyWorkspaceMigrations", + core.Sprintf("commit migration version %d", migration.Version), + err, + )) + } + } + + return core.Ok(nil) +} + +func workspaceMigrationApplied(database *store.DuckDB, version int64) (bool, error) { + var tableCount int + if err := database.Conn().QueryRow(` + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = 'main' AND table_name = 'lem_schema_versions' + `).Scan(&tableCount); err != nil { + return false, err + } + if tableCount == 0 { + return false, nil + } + + var versionCount int + if err := database.Conn().QueryRow( + "SELECT COUNT(*) FROM lem_schema_versions WHERE version = ?", + version, + ).Scan(&versionCount); err != nil { + return false, err + } + return versionCount > 0, nil +} + +func rollbackWorkspaceMigration(transaction *sql.Tx) { + if transaction == nil { + return + } + if err := transaction.Rollback(); err != nil && err != sql.ErrTxDone { + core.Warn("tui.migration.rollback", "error", err) + } +} + +func (database *workspaceDatabase) Close() core.Result { + if database == nil { + return core.Ok(nil) + } + if database.runtime != nil { + orm.Remove(database.runtime) + database.runtime = nil + } + + result := core.Ok(nil) + if database.medium != nil { + if closeResult := database.medium.Close(); !closeResult.OK { + result = closeResult + } + database.medium = nil + } + if database.store != nil { + if closeResult := database.store.Close(); !closeResult.OK && result.OK { + result = closeResult + } + database.store = nil + } + return result +} + +func closeStoreAfterFailure(database *store.DuckDB) { + if database == nil { + return + } + if result := database.Close(); !result.OK { + core.Warn("tui.database.close_after_failure", "error", result.Value) + } +} + +func closeDatabaseAfterFailure(databaseStore *store.DuckDB, medium *orm.DuckDBMedium, runtime *core.Core) { + if runtime != nil { + orm.Remove(runtime) + } + if medium != nil { + if result := medium.Close(); !result.OK { + core.Warn("tui.orm.close_after_failure", "error", result.Value) + } + } + closeStoreAfterFailure(databaseStore) +} diff --git a/cli/tui/migrations_test.go b/cli/tui/migrations_test.go new file mode 100644 index 000000000..48e15b44e --- /dev/null +++ b/cli/tui/migrations_test.go @@ -0,0 +1,276 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package tui + +import ( + "testing" + "time" + + "dappco.re/go/orm" + "dappco.re/go/store" +) + +func TestMigrations_Good(t *testing.T) { + path := t.TempDir() + "/lem.duckdb" + opened := openWorkspaceDatabase(path) + if !opened.OK { + t.Fatalf("openWorkspaceDatabase(%q) failed: %v", path, opened.Value) + } + database, ok := opened.Value.(*workspaceDatabase) + if !ok { + t.Fatalf("openWorkspaceDatabase value = %T, want *workspaceDatabase", opened.Value) + } + defer func() { + if result := database.Close(); !result.OK { + t.Errorf("close workspace database: %v", result.Value) + } + }() + + second := applyWorkspaceMigrations(database.store, workspaceMigrations, func() time.Time { + return time.Date(2040, time.January, 2, 3, 4, 5, 0, time.UTC) + }) + if !second.OK { + t.Fatalf("second migration pass failed: %v", second.Value) + } + + var versions int + if err := database.store.Conn().QueryRow("SELECT COUNT(*) FROM lem_schema_versions").Scan(&versions); err != nil { + t.Fatalf("count schema versions: %v", err) + } + if versions != 2 { + t.Fatalf("schema version rows = %d, want 2", versions) + } + + var tables int + if err := database.store.Conn().QueryRow(` + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = 'main' + AND table_name IN ( + 'lem_schema_versions', 'lem_sessions', 'lem_turns', 'lem_events', + 'lem_generation_jobs', 'lem_work_items', 'lem_artifacts', 'lem_attachments' + )`).Scan(&tables); err != nil { + t.Fatalf("count workspace tables: %v", err) + } + if tables != 8 { + t.Fatalf("workspace tables = %d, want 8", tables) + } + + for _, table := range []string{ + "lem_schema_versions", "lem_sessions", "lem_turns", "lem_events", + "lem_generation_jobs", "lem_work_items", "lem_artifacts", "lem_attachments", + } { + if result := orm.OfTable(database.runtime, table).Count(); !result.OK { + t.Errorf("ORM schema %q was not registered: %v", table, result.Value) + } + } +} + +func TestMigrations_Bad(t *testing.T) { + database := openTestMigrationStore(t, t.TempDir()+"/lem.duckdb") + defer closeTestMigrationStore(t, database) + + first := applyWorkspaceMigrations(database, workspaceMigrations, time.Now) + if !first.OK { + t.Fatalf("apply base migrations: %v", first.Value) + } + bad := []workspaceMigration{{ + Version: 3, + Statements: []string{ + "CREATE TABLE lem_rollback_probe (id BIGINT)", + "THIS IS NOT VALID SQL", + }, + }} + result := applyWorkspaceMigrations(database, bad, time.Now) + if result.OK { + t.Fatalf("applyWorkspaceMigrations(invalid) = %#v, want failure", result.Value) + } + + var versions int + if err := database.Conn().QueryRow("SELECT COUNT(*) FROM lem_schema_versions WHERE version = 3").Scan(&versions); err != nil { + t.Fatalf("count failed schema version: %v", err) + } + if versions != 0 { + t.Fatalf("failed schema version rows = %d, want 0", versions) + } + var probeTables int + if err := database.Conn().QueryRow(` + SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema = 'main' AND table_name = 'lem_rollback_probe'`).Scan(&probeTables); err != nil { + t.Fatalf("count rollback probe table: %v", err) + } + if probeTables != 0 { + t.Fatalf("rollback probe tables = %d, want 0", probeTables) + } +} + +func TestMigrations_Ugly(t *testing.T) { + database := openTestMigrationStore(t, t.TempDir()+"/lem.duckdb") + defer closeTestMigrationStore(t, database) + + appliedAt := time.Date(2020, time.February, 3, 4, 5, 6, 0, time.UTC) + if result := database.Exec(`CREATE TABLE lem_schema_versions ( + version BIGINT PRIMARY KEY, + applied_at TIMESTAMP NOT NULL + )`); !result.OK { + t.Fatalf("create schema version fixture: %v", result.Value) + } + if result := database.Exec( + "INSERT INTO lem_schema_versions (version, applied_at) VALUES (?, ?)", + int64(1), appliedAt, + ); !result.OK { + t.Fatalf("insert schema version fixture: %v", result.Value) + } + + result := applyWorkspaceMigrations(database, workspaceMigrations, func() time.Time { + return time.Date(2050, time.December, 30, 1, 2, 3, 0, time.UTC) + }) + if !result.OK { + t.Fatalf("applyWorkspaceMigrations(existing version): %v", result.Value) + } + var got time.Time + if err := database.Conn().QueryRow( + "SELECT applied_at FROM lem_schema_versions WHERE version = 1", + ).Scan(&got); err != nil { + t.Fatalf("read preserved schema version: %v", err) + } + if !got.Equal(appliedAt) { + t.Fatalf("existing applied_at = %v, want %v", got, appliedAt) + } +} + +func TestMigrations_AgentGood(t *testing.T) { + path := t.TempDir() + "/lem.duckdb" + first := openTestMigrationStore(t, path) + if result := applyWorkspaceMigrations(first, workspaceMigrations, time.Now); !result.OK { + t.Fatalf("apply agent migrations: %v", result.Value) + } + if result := applyWorkspaceMigrations(first, workspaceMigrations, time.Now); !result.OK { + t.Fatalf("reapply agent migrations: %v", result.Value) + } + var versions int + if err := first.Conn().QueryRow("SELECT COUNT(*) FROM lem_schema_versions").Scan(&versions); err != nil { + t.Fatalf("count schema versions: %v", err) + } + if versions != 2 { + t.Fatalf("schema versions = %d, want 2", versions) + } + var tables int + if err := first.Conn().QueryRow(` + SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema = 'main' AND table_name IN ( + 'agent_projects', 'agent_runs', 'agent_events', 'agent_log_chunks', + 'agent_questions', 'agent_answers', 'agent_acceptances', + 'agent_queue_state', 'agent_provider_state' + )`).Scan(&tables); err != nil { + t.Fatalf("count agent tables: %v", err) + } + if tables != 9 { + t.Fatalf("agent tables = %d, want 9", tables) + } + var indexes int + if err := first.Conn().QueryRow(`SELECT COUNT(*) FROM duckdb_indexes() + WHERE index_name IN ('agent_runs_work_number_attempt_idx', 'agent_runs_work_idx', + 'agent_runs_status_idx', 'agent_events_run_idx', 'agent_acceptances_work_idx')`).Scan(&indexes); err != nil { + t.Fatalf("count agent indexes: %v", err) + } + if indexes != 5 { + t.Fatalf("agent indexes = %d, want 5", indexes) + } + closeTestMigrationStore(t, first) + + reopened := openTestMigrationStore(t, path) + defer closeTestMigrationStore(t, reopened) + if result := applyWorkspaceMigrations(reopened, workspaceMigrations, time.Now); !result.OK { + t.Fatalf("migrate reopened database: %v", result.Value) + } + if result := reopened.Exec( + "INSERT INTO agent_queue_state (id, status, reason, updated_at) VALUES (?, ?, ?, ?)", + "default", "paused", "invalid", time.Now().UTC(), + ); result.OK { + t.Fatal("agent_queue_state accepted status outside its check constraint") + } +} + +func TestMigrations_AgentBad(t *testing.T) { + database := openTestMigrationStore(t, t.TempDir()+"/lem.duckdb") + defer closeTestMigrationStore(t, database) + if result := applyWorkspaceMigrations(database, workspaceMigrations[:1], time.Now); !result.OK { + t.Fatalf("apply version 1: %v", result.Value) + } + broken := []workspaceMigration{{Version: 2, Statements: []string{ + "CREATE TABLE agent_rollback_probe (id BIGINT)", + "CREATE TABLE agent_broken (", + }}} + if result := applyWorkspaceMigrations(database, broken, time.Now); result.OK { + t.Fatal("broken agent migration succeeded") + } + var tables int + if err := database.Conn().QueryRow(`SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema = 'main' AND table_name = 'agent_rollback_probe'`).Scan(&tables); err != nil { + t.Fatalf("count rollback probe: %v", err) + } + if tables != 0 { + t.Fatalf("agent rollback probe tables = %d, want 0", tables) + } + var versions int + if err := database.Conn().QueryRow("SELECT COUNT(*) FROM lem_schema_versions WHERE version = 2").Scan(&versions); err != nil { + t.Fatalf("count version 2: %v", err) + } + if versions != 0 { + t.Fatalf("failed version rows = %d, want 0", versions) + } +} + +func TestMigrations_AgentUgly(t *testing.T) { + path := t.TempDir() + "/lem.duckdb" + database := openTestMigrationStore(t, path) + if result := applyWorkspaceMigrations(database, workspaceMigrations[:1], time.Now); !result.OK { + t.Fatalf("apply version 1 fixture: %v", result.Value) + } + closeTestMigrationStore(t, database) + + database = openTestMigrationStore(t, path) + defer closeTestMigrationStore(t, database) + if result := applyWorkspaceMigrations(database, workspaceMigrations, time.Now); !result.OK { + t.Fatalf("upgrade version 1 database: %v", result.Value) + } + var durableDefault string + if result := database.Exec(`INSERT INTO agent_runs ( + id, work_id, project_id, parent_run_id, provider, model, source_revision, + execution_revision, accepted_revision, branch, worktree, command_receipt, + run_number, attempt, process_id, status, exit_code, failure_reason, + queued_at, started_at, finished_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + "legacy", "work", "project", "", "codex", "", "source", "", "", "branch", "tree", "", + 1, 1, 0, "queued", 0, "", time.Now(), time.Time{}, time.Time{}, time.Now(), + ); !result.OK { + t.Fatalf("insert legacy-shaped run: %v", result.Value) + } + if err := database.Conn().QueryRow("SELECT durable_revision FROM agent_runs WHERE id = ?", "legacy").Scan(&durableDefault); err != nil { + t.Fatalf("read durable revision default: %v", err) + } + if durableDefault != "" { + t.Fatalf("legacy durable revision = %q, want empty", durableDefault) + } +} + +func openTestMigrationStore(t *testing.T, path string) *store.DuckDB { + t.Helper() + result := store.OpenDuckDBReadWrite(path) + if !result.OK { + t.Fatalf("open DuckDB %q: %v", path, result.Value) + } + database, ok := result.Value.(*store.DuckDB) + if !ok { + t.Fatalf("OpenDuckDBReadWrite value = %T, want *store.DuckDB", result.Value) + } + return database +} + +func closeTestMigrationStore(t *testing.T, database *store.DuckDB) { + t.Helper() + if result := database.Close(); !result.OK { + t.Errorf("close DuckDB: %v", result.Value) + } +} diff --git a/cli/tui/modes.go b/cli/tui/modes.go index 8cf79167b..c0e2d5056 100644 --- a/cli/tui/modes.go +++ b/cli/tui/modes.go @@ -3,8 +3,7 @@ package tui import ( - "strings" - + core "dappco.re/go" "dappco.re/go/inference" ) @@ -42,16 +41,16 @@ func (m modeState) move(delta int) modeState { return m } -func (m modeState) view(width int) string { - var b strings.Builder - b.WriteString(styleTitle.Render("modes") + "\n\n") +func (m modeState) view(width int, styles uiStyles) string { + var b core.Builder + b.WriteString(styles.title.Render("modes") + "\n\n") for i, md := range modes { - cursor, name := " ", styleAnswer.Render(md.name) + cursor, name := " ", styles.answer.Render(md.name) if i == m.selected { - cursor, name = styleAccent.Render("› "), styleAccent.Render(md.name) + cursor, name = styles.accent.Render("› "), styles.accent.Render(md.name) } - b.WriteString(cursor + name + "\n " + styleThought.Render(md.hint) + "\n\n") + b.WriteString(cursor + name + "\n " + styles.thought.Render(md.hint) + "\n\n") } - b.WriteString(styleStatus.Render("↑/↓ select — applies to every following turn")) + b.WriteString(styles.status.Render("↑/↓ select — applies to every following turn")) return b.String() } diff --git a/cli/tui/palette.go b/cli/tui/palette.go new file mode 100644 index 000000000..9f8e76be8 --- /dev/null +++ b/cli/tui/palette.go @@ -0,0 +1,856 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package tui + +import ( + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + core "dappco.re/go" + coreio "dappco.re/go/io" +) + +type commandID string + +const ( + commandNewSession commandID = "session.new" + commandSwitchSession commandID = "session.switch" + commandSearchHistory commandID = "history.search" + commandToggleInspector commandID = "inspector.toggle" + commandShowHelp commandID = "help.show" + commandPanelChat commandID = "panel.chat" + commandPanelWork commandID = "panel.work" + commandPanelModels commandID = "panel.models" + commandPanelService commandID = "panel.service" + commandPanelData commandID = "panel.data" + commandSaveSettings commandID = "settings.save" + commandExportMarkdown commandID = "session.export.markdown" + commandExportJSON commandID = "session.export.json" + commandRefreshWork commandID = "work.refresh" + commandRefreshRuntimes commandID = "runtimes.refresh" + commandRefreshKnowledge commandID = "knowledge.refresh" + commandNewWork commandID = "work.new" + commandEditWork commandID = "work.edit" +) + +type workspaceCommand struct { + ID commandID + Title string + Description string + Available bool + Reason string + run func(*app) core.Result +} + +type commandListItem struct{ command workspaceCommand } + +func (item commandListItem) Title() string { return item.command.Title } +func (item commandListItem) Description() string { + if item.command.Available || item.command.Reason == "" { + return item.command.Description + } + return core.Concat(item.command.Description, " — unavailable: ", item.command.Reason) +} +func (item commandListItem) FilterValue() string { + return core.Concat(string(item.command.ID), " ", item.command.Title, " ", item.command.Description) +} + +type commandPalette struct { + commands []workspaceCommand + byID map[commandID]workspaceCommand + list list.Model + exporter sessionExporter + exportMedium coreio.Medium + exportDirectory string +} + +func newCommandPalette(styles uiStyles) *commandPalette { + commands := defaultWorkspaceCommands() + items := make([]list.Item, 0, len(commands)) + byID := make(map[commandID]workspaceCommand, len(commands)) + for _, command := range commands { + items = append(items, commandListItem{command: command}) + byID[command.ID] = command + } + model := list.New(items, list.NewDefaultDelegate(), 68, 16) + model.Title = "Commands" + model.SetShowStatusBar(false) + model.SetFilteringEnabled(true) + model.Styles.Title = styles.title + model.SetFilterState(list.Filtering) + return &commandPalette{commands: commands, byID: byID, list: model} +} + +func (palette *commandPalette) Filter(query string) []workspaceCommand { + if palette == nil { + return nil + } + query = core.Trim(query) + if query == "" { + return append([]workspaceCommand(nil), palette.commands...) + } + targets := make([]string, len(palette.commands)) + for i, command := range palette.commands { + targets[i] = commandListItem{command: command}.FilterValue() + } + ranks := list.DefaultFilter(query, targets) + matched := make([]workspaceCommand, 0, len(ranks)) + for _, rank := range ranks { + matched = append(matched, palette.commands[rank.Index]) + } + return matched +} + +func (palette *commandPalette) Invoke(id commandID, target *app) core.Result { + if palette == nil { + return core.Fail(core.E("tui.commandPalette.Invoke", "command palette is unavailable", nil)) + } + if target != nil { + target.refreshAgentPalette() + } + command, exists := palette.byID[id] + if !exists { + return core.Fail(core.E("tui.commandPalette.Invoke", core.Concat("unknown command: ", string(id)), nil)) + } + if !command.Available { + return core.Fail(core.E("tui.commandPalette.Invoke", core.Concat(command.Title, " is unavailable: ", command.Reason), nil)) + } + if target == nil || command.run == nil { + return core.Fail(core.E("tui.commandPalette.Invoke", "command target is unavailable", nil)) + } + return command.run(target) +} + +func (palette *commandPalette) SelectedID() commandID { + if palette == nil { + return "" + } + item, ok := palette.list.SelectedItem().(commandListItem) + if !ok { + return "" + } + return item.command.ID +} + +func (palette *commandPalette) Open() { + if palette == nil { + return + } + palette.list.SetFilterText("") + palette.list.SetFilterState(list.Filtering) +} + +func (palette *commandPalette) Update(message tea.Msg) tea.Cmd { + if palette == nil { + return nil + } + var command tea.Cmd + palette.list, command = palette.list.Update(message) + return command +} + +func (palette *commandPalette) View(width, height int) string { + if palette == nil { + return "" + } + palette.list.SetSize(max(1, width), max(6, height)) + return palette.list.View() +} + +func (palette *commandPalette) SetAgentCapabilities(capabilities []agentCapability) { + palette.SetAgentContext(capabilities, nil) +} + +func (palette *commandPalette) SetAgentContext(capabilities []agentCapability, selected *workItemRecord, state ...agentWorkSnapshot) { + if palette == nil { + return + } + commands := make([]workspaceCommand, 0, len(palette.commands)+len(capabilities)) + for _, command := range palette.commands { + if !core.HasPrefix(string(command.ID), "agent.") { + commands = append(commands, command) + } + } + commands = append(commands, agentWorkspaceCommandsForContext(capabilities, selected, state...)...) + items := make([]list.Item, 0, len(commands)) + byID := make(map[commandID]workspaceCommand, len(commands)) + for _, command := range commands { + items = append(items, commandListItem{command: command}) + byID[command.ID] = command + } + palette.commands = commands + palette.byID = byID + palette.list.SetItems(items) +} + +func (palette *commandPalette) SetWorkSelection(hasSelectedWork bool) { + if palette == nil { + return + } + for index := range palette.commands { + if palette.commands[index].ID != commandEditWork { + continue + } + palette.commands[index].Available = hasSelectedWork + palette.commands[index].Reason = "" + if !hasSelectedWork { + palette.commands[index].Reason = "a selected Work item is required" + } + palette.byID[commandEditWork] = palette.commands[index] + } + items := make([]list.Item, 0, len(palette.commands)) + for _, command := range palette.commands { + items = append(items, commandListItem{command: command}) + } + palette.list.SetItems(items) +} + +// SetDataContext rebuilds every "data."-prefixed command against a live +// dataPanel.Capabilities() snapshot — the SetAgentContext precedent +// applied to the Data panel, called after every selection or status +// change (app.refreshDataPalette). +func (palette *commandPalette) SetDataContext(capabilities []dataCapability) { + if palette == nil { + return + } + commands := make([]workspaceCommand, 0, len(palette.commands)+len(capabilities)) + for _, command := range palette.commands { + if !core.HasPrefix(string(command.ID), "data.") { + commands = append(commands, command) + } + } + commands = append(commands, dataWorkspaceCommandsForContext(capabilities)...) + items := make([]list.Item, 0, len(commands)) + byID := make(map[commandID]workspaceCommand, len(commands)) + for _, command := range commands { + items = append(items, commandListItem{command: command}) + byID[command.ID] = command + } + palette.commands = commands + palette.byID = byID + palette.list.SetItems(items) +} + +func (palette *commandPalette) SetExporter(exporter sessionExporter, medium coreio.Medium, directory string) { + if palette == nil { + return + } + palette.exporter = exporter + palette.exportMedium = medium + palette.exportDirectory = directory + available := exporter != nil && medium != nil + for index := range palette.commands { + format := exportFormat("") + switch palette.commands[index].ID { + case commandExportMarkdown: + format = exportMarkdown + case commandExportJSON: + format = exportJSON + default: + continue + } + palette.commands[index].Available = available + palette.commands[index].Reason = "" + if !available { + palette.commands[index].Reason = "export adapter not connected" + } + palette.commands[index].run = func(target *app) core.Result { + return palette.runExport(target, format) + } + } + items := make([]list.Item, 0, len(palette.commands)) + palette.byID = make(map[commandID]workspaceCommand, len(palette.commands)) + for _, command := range palette.commands { + items = append(items, commandListItem{command: command}) + palette.byID[command.ID] = command + } + palette.list.SetItems(items) +} + +func (palette *commandPalette) runExport(target *app, format exportFormat) core.Result { + if palette == nil || palette.exporter == nil || palette.exportMedium == nil { + return core.Fail(core.E("tui.command.export", "export adapter is unavailable", nil)) + } + if target == nil || target.repository == nil { + return core.Fail(core.E("tui.command.export", "workspace repository is unavailable", nil)) + } + result := palette.exporter.Export( + palette.exportMedium, + palette.exportDirectory, + target.sessionID, + format, + ) + if !result.OK { + return result + } + receipt, ok := result.Value.(exportReceipt) + if !ok { + return core.Fail(core.E("tui.command.export", "invalid export receipt", nil)) + } + artifact := artifactRecord{ + ID: newRecordID(), + SessionID: receipt.SessionID, + Kind: core.Concat("export.", string(receipt.Format)), + Path: receipt.Path, + Title: core.Concat("Session export · ", receipt.Title), + MetadataJSON: core.JSONMarshalString(receipt), + CreatedAt: receipt.ExportedAt, + ArchivedAt: unsetRecordTime(), + } + if saved := target.repository.SaveArtifact(artifact); !saved.OK { + return core.Fail(core.E("tui.command.export", core.Concat("export written but artifact persistence failed: ", receipt.Path), resultError(saved))) + } + return core.Ok(receipt) +} + +func defaultWorkspaceCommands() []workspaceCommand { + panelCommand := func(id commandID, title string, panel panelID) workspaceCommand { + return workspaceCommand{ID: id, Title: title, Description: "Go to the " + panelNames[panel] + " panel", Available: true, run: func(target *app) core.Result { + target.activePanel = panel + return core.Ok(nil) + }} + } + unavailable := func(id commandID, title, description, reason string) workspaceCommand { + return workspaceCommand{ID: id, Title: title, Description: description, Reason: reason} + } + commands := []workspaceCommand{ + {ID: commandNewSession, Title: "New session", Description: "Create and open a blank chat session", Available: true, run: func(target *app) core.Result { return target.createSession() }}, + {ID: commandSwitchSession, Title: "Switch session", Description: "Open the recent-session switcher", Available: true, run: func(target *app) core.Result { return target.openSessionSwitcher() }}, + {ID: commandSearchHistory, Title: "Search history", Description: "Search durable chat titles and turns", Available: true, run: func(target *app) core.Result { return target.openHistorySearch() }}, + {ID: commandToggleInspector, Title: "Toggle inspector", Description: "Show or hide contextual session details", Available: true, run: func(target *app) core.Result { + target.toggleInspector() + return core.Ok(nil) + }}, + {ID: commandShowHelp, Title: "Show help", Description: "Open the complete keyboard reference", Available: true, run: func(target *app) core.Result { + target.activeOverlay = overlayHelp + return core.Ok(nil) + }}, + panelCommand(commandPanelChat, "Chat panel", panelChat), + panelCommand(commandPanelWork, "Work panel", panelWork), + panelCommand(commandPanelModels, "Models panel", panelModels), + panelCommand(commandPanelService, "Service panel", panelService), + panelCommand(commandPanelData, "Data panel", panelData), + {ID: commandSaveSettings, Title: "Save settings", Description: "Commit generation and appearance preferences", Available: true, run: func(target *app) core.Result { + return target.inspector.Save(target) + }}, + {ID: commandNewWork, Title: "New Work", Description: "Create a reviewed agent Work item", Available: true, run: func(target *app) core.Result { + return target.openWorkEditor(workItemRecord{}) + }}, + {ID: commandEditWork, Title: "Edit Work", Description: "Edit the selected Work item", Reason: "a selected Work item is required", run: func(target *app) core.Result { + if target == nil || target.work == nil { + return core.Fail(core.E("tui.command.work.edit", "work panel is unavailable", nil)) + } + record, ok := target.work.Selected() + if !ok { + return core.Fail(core.E("tui.command.work.edit", "a selected Work item is required", nil)) + } + return target.openWorkEditor(record) + }}, + unavailable(commandExportMarkdown, "Export Markdown", "Export the active session as Markdown", "export adapter not connected"), + unavailable(commandExportJSON, "Export JSON", "Export the active session as structured JSON", "export adapter not connected"), + {ID: commandRefreshWork, Title: "Refresh work", Description: "Refresh local work and provider snapshots", Available: true, run: func(target *app) core.Result { + if target == nil || target.work == nil { + return core.Fail(core.E("tui.command.refreshWork", "work panel is unavailable", nil)) + } + target.agentCommand = target.requestAgentSnapshot() + return core.Ok(nil) + }}, + unavailable(commandRefreshRuntimes, "Refresh runtimes", "Refresh local runtime capabilities", "manual refresh is not connected; restart LEM to rescan"), + unavailable(commandRefreshKnowledge, "Refresh knowledge", "Refresh local knowledge packs", "manual refresh is not connected; restart LEM to rescan"), + } + commands = append(commands, agentWorkspaceCommands(agentFeatureCatalog(defaultAgentUnavailableReason))...) + // The static data.* catalogue is informational, same precedent as + // agentWorkspaceCommands above — runtime invokability is rebuilt + // through SetDataContext once an app has a connected Data panel. + // (*dataPanel)(nil).Capabilities() is safe: every dataPanel method is + // nil-receiver-guarded, matching workPanel's own idiom. + return append(commands, dataWorkspaceCommandsForContext((*dataPanel)(nil).Capabilities())...) +} + +func agentCommandID(feature agentFeature) commandID { + return commandID(core.Concat("agent.", string(feature))) +} + +func agentWorkspaceCommands(capabilities []agentCapability) []workspaceCommand { + // The static catalogue is informational. Runtime invokability is rebuilt + // through SetAgentContext once an app has a Work selection. + return agentWorkspaceCommandsForContext(capabilities, nil) +} + +func agentWorkspaceCommandsForContext(capabilities []agentCapability, selected *workItemRecord, state ...agentWorkSnapshot) []workspaceCommand { + commands := make([]workspaceCommand, 0, len(capabilities)) + for _, capability := range capabilities { + capability := capability + available, reason := agentCommandAvailability(capability, selected, state...) + commands = append(commands, workspaceCommand{ + ID: agentCommandID(capability.Feature), + Title: agentFeatureTitle(capability.Feature), + Description: core.Concat("Agent capability · ", string(capability.Feature)), + Available: available, + Reason: reason, + run: func(target *app) core.Result { + if target == nil || target.work == nil { + return core.Fail(core.E("tui.agentCommand", "work panel is unavailable", nil)) + } + target.activePanel = panelWork + target.inspectorOpen = true + return target.queueAgentAction(capability.Feature) + }, + }) + } + return commands +} + +func agentCommandAvailability(capability agentCapability, selected *workItemRecord, state ...agentWorkSnapshot) (bool, string) { + if !capability.Available { + return false, capability.Reason + } + selectedState := agentWorkSnapshot{} + hasSnapshotState := len(state) > 0 + if hasSnapshotState { + selectedState = state[0] + } + if capability.Feature == agentFeatureQueueStart || capability.Feature == agentFeatureQueueStop { + if !hasSnapshotState { + return true, "" + } + queue := core.Lower(core.Trim(selectedState.QueueStatus)) + if queue == "" { + return true, "" + } + if capability.Feature == agentFeatureQueueStart { + return queue == "frozen", "queue is not frozen; draining completes existing work before it can restart" + } + return queue == "accepting", "queue is not accepting; it is already frozen or draining" + } + if !agentFeatureNeedsWork(capability.Feature) { + return true, "" + } + if selected == nil { + return false, "a selected Work item is required" + } + status := core.Lower(core.Trim(selected.Status)) + allowed := false + reason := "selected Work is not in a state that allows this action" + switch capability.Feature { + case agentFeatureDispatch: + allowed = status == "" || status == workStatusActive || status == "ready" + reason = "selected Work must be ready before it can dispatch" + case agentFeatureCancel: + allowed = (!hasSnapshotState || selectedState.NativeRunID != "") && (status == "queued" || status == "running") + reason = "selected Work is not queued or running" + case agentFeatureAnswer: + allowed = (!hasSnapshotState || (selectedState.NativeRunID != "" && selectedState.QuestionID != "" && selectedState.AnswerID == "")) && (status == workStatusWaiting || status == "question" || status == "blocked" || status == "needs_input") + reason = "selected Work is not waiting for an answer" + case agentFeatureRetry: + allowed = (!hasSnapshotState || selectedState.NativeRunID != "") && (status == workStatusFailed || status == "error" || status == "cancelled" || status == "canceled") + reason = "selected Work is not failed or cancelled" + case agentFeatureResume: + waiting := status == workStatusWaiting || status == "question" || status == "blocked" || status == "needs_input" + interrupted := status == "interrupted" + allowed = (waiting || interrupted) && (!hasSnapshotState || (selectedState.NativeRunID != "" && ((waiting && selectedState.AnswerID != "") || interrupted))) + reason = "answer the selected native run before resuming it" + case agentFeatureChangesReview: + if !hasSnapshotState { + return false, "change review overlay is scheduled for Task 14" + } + if selectedState.RecoveryCount > 0 || selectedState.Recovery.EventID != "" { + return false, "resolve the retained cleanup recovery before reviewing changes" + } + return selectedState.NativeRunID != "" && status == "completed", "selected native run is not completed and reviewable" + case agentFeatureAccept: + if !hasSnapshotState { + return false, "no durable review-ready state is exposed yet" + } + return selectedState.ReviewID != "" && selectedState.ReviewStatus == "prepared" && selectedState.Review.Payload != nil && selectedState.Review.AcceptanceAllowed, "review changes first; conflicts and failed validation cannot be accepted" + case agentFeatureReject: + if !hasSnapshotState { + return false, "no durable review-ready state is exposed yet" + } + return selectedState.ReviewID != "" && selectedState.ReviewStatus != "accepted" && selectedState.ReviewStatus != "rejected", "review changes first before rejecting a native run" + case agentFeatureRecoveryAbandon: + if !hasSnapshotState { + return false, "no retained recovery is exposed for the selected Work" + } + return selectedState.Recovery.EventID != "", "selected Work has no retained recovery" + } + if !allowed { + return false, reason + } + return true, "" +} + +func agentFeatureNeedsWork(feature agentFeature) bool { + switch feature { + case agentFeatureDispatch, agentFeatureCancel, agentFeatureAnswer, agentFeatureRetry, + agentFeatureResume, agentFeatureChangesReview, agentFeatureAccept, agentFeatureReject, agentFeatureRecoveryAbandon: + return true + default: + return false + } +} + +// dataCommandID builds the palette command id for a Data panel action — +// "data." for a single-item action, "data.bulk." for its +// bulk-apply-to-filter counterpart — mirroring agentCommandID's "agent." +// namespace one level down. +func dataCommandID(action dataAction, bulk bool) commandID { + name := dataActionSlug(action) + if bulk { + return commandID(core.Concat("data.bulk.", name)) + } + return commandID(core.Concat("data.", name)) +} + +func dataActionSlug(action dataAction) string { + switch action { + case dataActionApprove: + return "approve" + case dataActionReject: + return "reject" + case dataActionQuarantineClear: + return "quarantine-clear" + case dataActionEditAsDerived: + return "edit-as-derived" + case dataActionTag: + return "tag" + default: + return "unknown" + } +} + +// dataWorkspaceCommandsForContext builds the "data."-prefixed palette +// commands from capabilities (dataPanel.Capabilities(), already carrying +// live Available/Reason per action) — the agentcap pattern applied to the +// Data panel: every action always appears, unavailable ones render their +// reason rather than hiding (commandListItem.Description). Every run +// closure forces the Data panel into view before dispatching, mirroring +// agentWorkspaceCommandsForContext's own target.activePanel assignment. +func dataWorkspaceCommandsForContext(capabilities []dataCapability) []workspaceCommand { + commands := make([]workspaceCommand, 0, len(capabilities)) + for _, capability := range capabilities { + capability := capability + commands = append(commands, workspaceCommand{ + ID: dataCommandID(capability.Action, capability.Bulk), + Title: capability.Title, + Description: core.Concat("Data review · ", capability.Title), + Available: capability.Available, + Reason: capability.Reason, + run: func(target *app) core.Result { + return target.runDataCommand(capability) + }, + }) + } + return commands +} + +type sessionSwitcherItem struct { + SessionID string + Title string + Description string +} + +type sessionListItem struct{ value sessionSwitcherItem } + +func (item sessionListItem) Title() string { return item.value.Title } +func (item sessionListItem) Description() string { return item.value.Description } +func (item sessionListItem) FilterValue() string { + return core.Concat(item.value.Title, " ", item.value.Description) +} + +type sessionSwitcher struct { + manager *sessionManager + items []sessionSwitcherItem + list list.Model +} + +func newSessionSwitcher(manager *sessionManager, styles uiStyles, width, height int) core.Result { + if manager == nil { + return core.Fail(core.E("tui.newSessionSwitcher", "session manager is unavailable", nil)) + } + model := list.New(nil, list.NewDefaultDelegate(), width, height) + model.Title = "Recent sessions" + model.SetShowStatusBar(false) + model.SetFilteringEnabled(true) + model.Styles.Title = styles.title + switcher := &sessionSwitcher{manager: manager, list: model} + switcher.Refresh() + return core.Ok(switcher) +} + +func (switcher *sessionSwitcher) Refresh() { + if switcher == nil || switcher.manager == nil { + return + } + sessions := switcher.manager.Recent() + switcher.items = make([]sessionSwitcherItem, 0, len(sessions)) + items := make([]list.Item, 0, len(sessions)) + for _, session := range sessions { + marker := sessionMarker(session) + model := session.Record.PreferredModel + if model == "" { + model = "no preferred model" + } + item := sessionSwitcherItem{ + SessionID: session.Record.ID, + Title: core.Concat(marker, " ", session.Record.Title), + Description: core.Concat(session.Record.Status, " · ", model), + } + switcher.items = append(switcher.items, item) + items = append(items, sessionListItem{value: item}) + } + switcher.list.SetItems(items) +} + +func sessionMarker(session *chatSession) string { + if session == nil { + return "○" + } + if session.Attention { + return "!" + } + switch session.Record.Status { + case "generating": + return "◉" + case "queued": + return "◌" + case "failed": + return "×" + default: + return "○" + } +} + +func (switcher *sessionSwitcher) Items() []sessionSwitcherItem { + if switcher == nil { + return nil + } + return append([]sessionSwitcherItem(nil), switcher.items...) +} + +func (switcher *sessionSwitcher) Update(message tea.Msg) tea.Cmd { + if switcher == nil { + return nil + } + var command tea.Cmd + switcher.list, command = switcher.list.Update(message) + return command +} + +func (switcher *sessionSwitcher) ActivateSelected() core.Result { + if switcher == nil || switcher.manager == nil { + return core.Fail(core.E("tui.sessionSwitcher.ActivateSelected", "session switcher is unavailable", nil)) + } + item, ok := switcher.list.SelectedItem().(sessionListItem) + if !ok { + return core.Fail(core.E("tui.sessionSwitcher.ActivateSelected", "no session is selected", nil)) + } + return switcher.manager.Switch(item.value.SessionID) +} + +func (switcher *sessionSwitcher) View(width, height int) string { + if switcher == nil { + return "" + } + switcher.list.SetSize(max(1, width), max(6, height)) + return switcher.list.View() +} + +type historySearchItem struct { + hit sessionSearchHit +} + +func (item historySearchItem) Title() string { return item.hit.Session.Title } +func (item historySearchItem) Description() string { return item.hit.Snippet } +func (item historySearchItem) FilterValue() string { + return core.Concat(item.hit.Session.Title, " ", item.hit.Snippet) +} + +type historySearch struct { + repository workspaceRepository + manager *sessionManager + input textinput.Model + list list.Model + hits []sessionSearchHit + query string + matchTurn string +} + +func newHistorySearch(repository workspaceRepository, manager *sessionManager, styles uiStyles, width, height int) core.Result { + if repository == nil || manager == nil { + return core.Fail(core.E("tui.newHistorySearch", "repository and session manager are required", nil)) + } + input := textinput.New() + input.Placeholder = "Search titles and turns…" + input.Prompt = "› " + input.Focus() + model := list.New(nil, list.NewDefaultDelegate(), width, max(4, height-2)) + model.Title = "History" + model.SetShowStatusBar(false) + model.SetFilteringEnabled(false) + model.Styles.Title = styles.title + return core.Ok(&historySearch{repository: repository, manager: manager, input: input, list: model}) +} + +func (search *historySearch) Search(query string) core.Result { + if search == nil || search.repository == nil { + return core.Fail(core.E("tui.historySearch.Search", "history search is unavailable", nil)) + } + query = core.Trim(query) + search.query = query + search.input.SetValue(query) + result := search.repository.SearchSessions(query, 50) + if !result.OK { + return result + } + hits, ok := result.Value.([]sessionSearchHit) + if !ok { + return core.Fail(core.E("tui.historySearch.Search", "invalid search result", nil)) + } + search.hits = append([]sessionSearchHit(nil), hits...) + items := make([]list.Item, 0, len(hits)) + for _, hit := range hits { + items = append(items, historySearchItem{hit: hit}) + } + search.list.SetItems(items) + return core.Ok(search.Hits()) +} + +func (search *historySearch) Hits() []sessionSearchHit { + if search == nil { + return nil + } + return append([]sessionSearchHit(nil), search.hits...) +} + +func (search *historySearch) MatchTurnID() string { + if search == nil { + return "" + } + return search.matchTurn +} + +func (search *historySearch) ActivateSelected() core.Result { + if search == nil || search.manager == nil { + return core.Fail(core.E("tui.historySearch.ActivateSelected", "history search is unavailable", nil)) + } + item, ok := search.list.SelectedItem().(historySearchItem) + if !ok { + return core.Fail(core.E("tui.historySearch.ActivateSelected", "no history result is selected", nil)) + } + if result := search.manager.Switch(item.hit.Session.ID); !result.OK { + return result + } + session := search.manager.Active() + position := 0 + search.matchTurn = "" + needle := core.Lower(search.query) + for index, turn := range session.Turns { + if core.Contains(core.Lower(turn.Visible), needle) { + position = index + search.matchTurn = turn.ID + break + } + } + if result := search.manager.SetViewport(session.Record.ID, position, false); !result.OK { + return result + } + return core.Ok(item.hit) +} + +func (search *historySearch) Update(message tea.Msg) tea.Cmd { + if search == nil { + return nil + } + if keyMessage, ok := message.(tea.KeyMsg); ok { + switch keyMessage.String() { + case "up", "down", "pgup", "pgdown", "ctrl+u", "ctrl+d": + var command tea.Cmd + search.list, command = search.list.Update(message) + return command + } + } + before := search.input.Value() + var command tea.Cmd + search.input, command = search.input.Update(message) + if search.input.Value() != before { + _ = search.Search(search.input.Value()) + } + return command +} + +func (search *historySearch) View(width, height int) string { + if search == nil { + return "" + } + search.input.Width = max(12, width-4) + search.list.SetSize(max(1, width), max(4, height-2)) + return lipgloss.JoinVertical(lipgloss.Left, search.input.View(), search.list.View()) +} + +type overlayKind uint8 + +const ( + overlayNone overlayKind = iota + overlayCommands + overlaySessions + overlaySearch + overlayHelp + overlayWorkEditor + overlayProjectReview + overlayGitEnableReview + overlayLaunchReview + overlayAgentSelection + overlayAgentAnswer + overlayChangeReview + overlayDataEditor + overlayDataNote + overlayDataFilter + overlayDataBulk +) + +type helpOverlay struct { + model help.Model + keys keyMap +} + +func newHelpOverlay(keys keyMap, styles uiStyles) *helpOverlay { + model := help.New() + model.ShowAll = true + model.Styles.FullKey = styles.accent + model.Styles.FullDesc = styles.status + model.Styles.FullSeparator = styles.separator + return &helpOverlay{model: model, keys: keys} +} + +func (overlay *helpOverlay) View(width int) string { + if overlay == nil { + return "" + } + overlay.model.Width = max(20, width) + return lipgloss.JoinVertical(lipgloss.Left, "Keyboard help", "", overlay.model.View(overlay.keys), "", "esc closes help") +} + +func renderOverlay(body string, width, height int, styles uiStyles) string { + if width <= 0 || height <= 0 { + return "" + } + overlayWidth := min(72, max(1, width-2)) + overlayHeight := min(26, max(3, height-2)) + box := styles.outerFrame.Copy(). + BorderForeground(styles.theme.focus). + Width(max(1, overlayWidth-2)). + Height(max(1, overlayHeight-2)). + MaxWidth(overlayWidth). + MaxHeight(overlayHeight). + Render(body) + return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Top, box) +} + +// overlayEmpty keeps unavailable overlays legible without adding a component. +func overlayEmpty(title, reason string) string { + return core.Join("\n", title, "", "○ "+reason, "", "esc closes") +} diff --git a/cli/tui/palette_test.go b/cli/tui/palette_test.go new file mode 100644 index 000000000..c97e29f17 --- /dev/null +++ b/cli/tui/palette_test.go @@ -0,0 +1,441 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package tui + +import ( + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + + core "dappco.re/go" + "dappco.re/go/inference/dataset" +) + +func TestCommandPalette_Good(t *testing.T) { + styles := newUIStyles(midnightTheme()) + palette := newCommandPalette(styles) + matches := palette.Filter("models panel") + if len(matches) == 0 || matches[0].ID != commandPanelModels { + t.Fatalf("models filter = %#v", matches) + } + a := newApp("", 0, 64) + a.activePanel = panelWork + if result := palette.Invoke(commandPanelModels, &a); !result.OK { + t.Fatalf("Invoke models: %v", result.Value) + } + if a.activePanel != panelModels { + t.Fatalf("active panel = %d, want Models", a.activePanel) + } + model, _ := a.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + a = model.(app) + beforeHeight := a.view.Height + if result := palette.Invoke(commandToggleInspector, &a); !result.OK { + t.Fatalf("Invoke inspector: %v", result.Value) + } + if !a.inspectorOpen || a.view.Height == beforeHeight || a.view.Height != a.transcriptHeight() { + t.Fatalf("palette inspector layout = open %v height %d (before %d)", a.inspectorOpen, a.view.Height, beforeHeight) + } +} + +func TestAgentCommandPalette_LifecycleActionMatrix(t *testing.T) { + capabilities := []agentCapability{ + {Feature: agentFeatureCancel, Available: true}, + {Feature: agentFeatureChangesReview, Available: true}, + } + statuses := []string{"queued", "preparing", "running", "completed", "waiting", "cancelling", "cancelled", "failed", "interrupted", "accepted", "rejected"} + for _, status := range statuses { + t.Run(status, func(t *testing.T) { + selected := workItemRecord{ID: "work-1", Status: status} + state := agentWorkSnapshot{NativeRunID: "run-1"} + commands := agentWorkspaceCommandsForContext(capabilities, &selected, state) + available := make(map[agentFeature]bool, len(commands)) + for _, command := range commands { + for _, feature := range []agentFeature{agentFeatureCancel, agentFeatureChangesReview} { + if command.ID == agentCommandID(feature) { + available[feature] = command.Available + } + } + } + wantCancel := status == "queued" || status == "running" + wantReview := status == "completed" + if available[agentFeatureCancel] != wantCancel || available[agentFeatureChangesReview] != wantReview { + t.Fatalf("status %q availability = cancel %v review %v, want %v/%v", status, available[agentFeatureCancel], available[agentFeatureChangesReview], wantCancel, wantReview) + } + }) + } +} + +// TestDataCommandPalette_Good proves the agentcap pattern applied to the +// Data panel: every data.* command exists from construction (informational, +// all unavailable with an honest reason before a store connects), and once +// a real dataPanel is attached, Invoke on a live command both runs the +// action through the exact same path the panel's own hotkeys use and +// forces the Data panel into view. +func TestDataCommandPalette_Good(t *testing.T) { + reason := "dataset store is not connected" + palette := newCommandPalette(newUIStyles(midnightTheme())) + for _, action := range []dataAction{dataActionApprove, dataActionReject, dataActionQuarantineClear, dataActionEditAsDerived, dataActionTag} { + id := dataCommandID(action, false) + command, exists := palette.byID[id] + if !exists || command.Available || command.Reason != reason { + t.Fatalf("data command %q = %#v, exists=%v", id, command, exists) + } + matches := palette.Filter(string(id)) + found := false + for _, match := range matches { + found = found || match.ID == id + } + if !found { + t.Fatalf("data command %q is not searchable", id) + } + } + bulkID := dataCommandID(dataActionApprove, true) + if command, exists := palette.byID[bulkID]; !exists || command.Available { + t.Fatalf("bulk data command %q = %#v, exists=%v", bulkID, command, exists) + } + + store := newTestDataPanelStore(t) + at := time.Now() + ds := seedDataDataset(t, store, "vents", at) + item := seedDataItem(t, store, ds.ID, "p", "r", at) + + a := newApp("", 0, 64) + if result := a.attachData(store); !result.OK { + t.Fatalf("attachData: %v", result.Value) + } + a.activePanel = panelWork + + approveID := dataCommandID(dataActionApprove, false) + if command, exists := a.palette.byID[approveID]; !exists || !command.Available { + t.Fatalf("live approve command = %#v, exists=%v", command, exists) + } + if result := a.palette.Invoke(approveID, &a); !result.OK { + t.Fatalf("Invoke approve: %v", result.Value) + } + if a.activePanel != panelData { + t.Fatalf("Invoke did not focus the Data panel: %d", a.activePanel) + } + if review := core.MustCast[dataset.Review](store.ReviewLatest(item.ID)); review.Status != dataset.StatusApproved { + t.Fatalf("palette-invoked approve did not write: %#v", review) + } +} + +func TestCommandPalette_Bad(t *testing.T) { + palette := newCommandPalette(newUIStyles(midnightTheme())) + a := newApp("", 0, 64) + a.activePanel = panelService + before := a.activePanel + result := palette.Invoke(commandID("missing.command"), &a) + if result.OK { + t.Fatal("unknown command invocation succeeded") + } + if a.activePanel != before || a.inspectorOpen { + t.Fatalf("unknown command mutated app: panel=%d inspector=%v", a.activePanel, a.inspectorOpen) + } +} + +func TestCommandPalette_AgentDispatchNeedsSelectedWork(t *testing.T) { + styles := newUIStyles(midnightTheme()) + palette := newCommandPalette(styles) + palette.SetAgentContext([]agentCapability{{Feature: agentFeatureDispatch, Available: true}}, nil) + a := newApp("", 0, 64) + command := palette.byID[agentCommandID(agentFeatureDispatch)] + if command.Available || !strings.Contains(command.Reason, "selected Work") { + t.Fatalf("dispatch without Work = %#v", command) + } + if result := palette.Invoke(agentCommandID(agentFeatureDispatch), &a); result.OK { + t.Fatal("dispatch without Work was invokable") + } + selected := workItemRecord{Status: workStatusActive} + palette.SetAgentContext([]agentCapability{{Feature: agentFeatureDispatch, Available: true}}, &selected) + if command = palette.byID[agentCommandID(agentFeatureDispatch)]; !command.Available { + t.Fatalf("dispatch with selected Work = %#v", command) + } +} + +func TestCommandPalette_AgentWorkStatus(t *testing.T) { + capabilities := []agentCapability{ + {Feature: agentFeatureDispatch, Available: true}, {Feature: agentFeatureCancel, Available: true}, + {Feature: agentFeatureAnswer, Available: true}, {Feature: agentFeatureRetry, Available: true}, + {Feature: agentFeatureResume, Available: true}, {Feature: agentFeatureChangesReview, Available: true}, + {Feature: agentFeatureAccept, Available: true}, {Feature: agentFeatureReject, Available: true}, + } + cases := []struct { + status string + ready []agentFeature + }{ + {workStatusActive, []agentFeature{agentFeatureDispatch}}, {"queued", []agentFeature{agentFeatureCancel}}, {"running", []agentFeature{agentFeatureCancel}}, + {workStatusWaiting, []agentFeature{agentFeatureAnswer, agentFeatureResume}}, {workStatusFailed, []agentFeature{agentFeatureRetry}}, {"interrupted", []agentFeature{agentFeatureResume}}, + {workStatusCompleted, []agentFeature{}}, + } + for _, test := range cases { + t.Run(test.status, func(t *testing.T) { + palette := newCommandPalette(newUIStyles(midnightTheme())) + selected := workItemRecord{Status: test.status} + palette.SetAgentContext(capabilities, &selected) + for _, feature := range []agentFeature{agentFeatureDispatch, agentFeatureCancel, agentFeatureAnswer, agentFeatureRetry, agentFeatureResume, agentFeatureChangesReview} { + command := palette.byID[agentCommandID(feature)] + want := false + for _, ready := range test.ready { + want = want || feature == ready + } + if got := command.Available; got != want { + t.Fatalf("%s for %s available=%v reason=%q", feature, test.status, got, command.Reason) + } + } + if command := palette.byID[agentCommandID(agentFeatureChangesReview)]; command.Available || !strings.Contains(command.Reason, "Task 14") { + t.Fatalf("change review state = %#v", command) + } + for _, feature := range []agentFeature{agentFeatureAccept, agentFeatureReject} { + if command := palette.byID[agentCommandID(feature)]; command.Available || !strings.Contains(command.Reason, "review-ready") { + t.Fatalf("%s state = %#v", feature, command) + } + } + }) + } +} + +func TestCommandPaletteAnsweredWaitingDisablesAnswerAndEnablesResume(t *testing.T) { + capabilities := []agentCapability{{Feature: agentFeatureAnswer, Available: true}, {Feature: agentFeatureResume, Available: true}} + selected := workItemRecord{ID: "work-1", Status: workStatusWaiting} + state := agentWorkSnapshot{ + ExternalID: "work-1", NativeRunID: "waiting-1", QuestionID: "question-1", + AnswerID: "answer-1", ResumeRunID: "resume-1", + } + palette := newCommandPalette(newUIStyles(midnightTheme())) + palette.SetAgentContext(capabilities, &selected, state) + if palette.byID[agentCommandID(agentFeatureAnswer)].Available { + t.Fatal("Answer remains available after its durable response") + } + if !palette.byID[agentCommandID(agentFeatureResume)].Available { + t.Fatal("Resume is unavailable after its durable response") + } +} + +func TestCommandPaletteRecoveryAbandonRequiresPendingReceipt(t *testing.T) { + capabilities := []agentCapability{{Feature: agentFeatureRecoveryAbandon, Available: true}} + selected := workItemRecord{ID: "work-1", Status: workStatusFailed} + palette := newCommandPalette(newUIStyles(midnightTheme())) + palette.SetAgentContext(capabilities, &selected, agentWorkSnapshot{NativeRunID: "attempt-9"}) + command := palette.byID[agentCommandID(agentFeatureRecoveryAbandon)] + if command.Available || !strings.Contains(command.Reason, "retained recovery") { + t.Fatalf("recovery action without receipt = %#v", command) + } + + state := agentWorkSnapshot{Recovery: agentPendingRecovery{EventID: "recovery-event-9", Receipt: agentRecoveryReceipt{ + Kind: "run", WorkID: selected.ID, RunID: "attempt-9", RunNumber: 9, WorkspaceRunID: "lineage-root", + }}} + palette.SetAgentContext(capabilities, &selected, state) + command = palette.byID[agentCommandID(agentFeatureRecoveryAbandon)] + if !command.Available { + t.Fatalf("recovery action with receipt = %#v", command) + } +} + +func TestCommandPaletteReviewChangesDisabledByPendingCleanupRecovery(t *testing.T) { + capabilities := []agentCapability{{Feature: agentFeatureChangesReview, Available: true}} + selected := workItemRecord{ID: "work-1", Status: workStatusCompleted} + for _, state := range []agentWorkSnapshot{ + {NativeRunID: "run-1", RecoveryCount: 1}, + {NativeRunID: "run-1", Recovery: agentPendingRecovery{EventID: "retained-cleanup-1"}}, + } { + palette := newCommandPalette(newUIStyles(midnightTheme())) + palette.SetAgentContext(capabilities, &selected, state) + command := palette.byID[agentCommandID(agentFeatureChangesReview)] + if command.Available || !strings.Contains(command.Reason, "retained cleanup recovery") { + t.Fatalf("Review Changes with retained cleanup = %#v", command) + } + } +} + +func TestCommandPalette_AgentNativeRunAndReviewState(t *testing.T) { + caps := []agentCapability{{Feature: agentFeatureCancel, Available: true}, {Feature: agentFeatureChangesReview, Available: true}, {Feature: agentFeatureAccept, Available: true}, {Feature: agentFeatureReject, Available: true}} + selected := workItemRecord{ID: "local", Status: "completed"} + state := agentWorkSnapshot{NativeRunID: "run-7"} + p := newCommandPalette(newUIStyles(midnightTheme())) + p.SetAgentContext(caps, &selected, state) + if p.byID[agentCommandID(agentFeatureCancel)].Available || !p.byID[agentCommandID(agentFeatureChangesReview)].Available { + t.Fatalf("run actions unavailable: %#v", p.byID) + } + if p.byID[agentCommandID(agentFeatureAccept)].Available || p.byID[agentCommandID(agentFeatureReject)].Available { + t.Fatalf("accept/reject available before reviewed receipt") + } + state.ReviewID, state.ReviewStatus, state.Review = "review-1", "prepared", agentReview{Feature: agentFeatureChangesReview, Payload: "opaque", AcceptanceAllowed: true} + p.SetAgentContext(caps, &selected, state) + if !p.byID[agentCommandID(agentFeatureAccept)].Available || !p.byID[agentCommandID(agentFeatureReject)].Available { + t.Fatalf("review actions unavailable: %#v", p.byID) + } + state.ReviewStatus = "validation_failed" + p.SetAgentContext(caps, &selected, state) + if p.byID[agentCommandID(agentFeatureAccept)].Available || !p.byID[agentCommandID(agentFeatureReject)].Available { + t.Fatalf("failed review actions = %#v", p.byID) + } +} + +func TestCommandPalette_AgentQueueState(t *testing.T) { + caps := []agentCapability{{Feature: agentFeatureQueueStart, Available: true}, {Feature: agentFeatureQueueStop, Available: true}} + for _, test := range []struct { + status string + start, stop bool + }{{"frozen", true, false}, {"accepting", false, true}, {"draining", false, false}} { + p := newCommandPalette(newUIStyles(midnightTheme())) + p.SetAgentContext(caps, nil, agentWorkSnapshot{QueueStatus: test.status}) + if p.byID[agentCommandID(agentFeatureQueueStart)].Available != test.start || p.byID[agentCommandID(agentFeatureQueueStop)].Available != test.stop { + t.Fatalf("queue %s = %#v", test.status, p.byID) + } + } +} + +func TestCommandPaletteLocalRefresh_Bad(t *testing.T) { + palette := newCommandPalette(newUIStyles(midnightTheme())) + a := newApp("", 0, 64) + for _, id := range []commandID{commandRefreshRuntimes, commandRefreshKnowledge} { + command := palette.byID[id] + if command.Available || !strings.Contains(command.Reason, "restart") { + t.Fatalf("refresh command %q = %#v", id, command) + } + if result := palette.Invoke(id, &a); result.OK { + t.Fatalf("unwired refresh command %q reported success", id) + } + } + if command := palette.byID[commandExportJSON]; command.Title != "Export JSON" || strings.Contains(command.Description, "JSON Lines") { + t.Fatalf("JSON export command = %#v", command) + } +} + +func TestSessionSwitcher_Good(t *testing.T) { + manager := openTestSessionManager(t, sequenceIDs("session-one", "session-two")) + first := manager.Create().Value.(*chatSession) + second := manager.Create().Value.(*chatSession) + first.Record.Status = "idle" + first.Record.PreferredModel = "gemma-4" + second.Record.Status = "generating" + second.Record.PreferredModel = "qwen-3" + second.ActiveJobID = "job-hidden" + second.Attention = true + if result := manager.repository.SaveSession(first.Record); !result.OK { + t.Fatalf("save first metadata: %v", result.Value) + } + if result := manager.repository.SaveSession(second.Record); !result.OK { + t.Fatalf("save second metadata: %v", result.Value) + } + if result := manager.Switch(first.Record.ID); !result.OK { + t.Fatalf("switch first: %v", result.Value) + } + second.Attention = true + + switcherResult := newSessionSwitcher(manager, newUIStyles(midnightTheme()), 72, 14) + if !switcherResult.OK { + t.Fatalf("newSessionSwitcher: %v", switcherResult.Value) + } + switcher := switcherResult.Value.(*sessionSwitcher) + items := switcher.Items() + if len(items) != 2 || items[0].SessionID != first.Record.ID || items[1].SessionID != second.Record.ID { + t.Fatalf("recent switcher order = %#v", items) + } + if !strings.Contains(items[1].Title, "!") || !strings.Contains(items[1].Description, "generating") || !strings.Contains(items[1].Description, "qwen-3") { + t.Fatalf("hidden session metadata = %#v", items[1]) + } + + switcher.list.Select(0) + switcher.Update(tea.KeyMsg{Type: tea.KeyDown}) + if result := switcher.ActivateSelected(); !result.OK { + t.Fatalf("activate selected: %v", result.Value) + } + if manager.Active().Record.ID != second.Record.ID { + t.Fatalf("active session = %q, want %q", manager.Active().Record.ID, second.Record.ID) + } + if second.ActiveJobID != "job-hidden" { + t.Fatalf("switch cancelled hidden job: %q", second.ActiveJobID) + } +} + +func TestHistorySearch_Good(t *testing.T) { + manager := openTestSessionManager(t, sequenceIDs("session-match", "session-other")) + matched := manager.Create().Value.(*chatSession) + first := testTurnRecord("turn-before", matched.Record.ID, 1, "user", "ordinary opening", time.Now().UTC()) + second := testTurnRecord("turn-match", matched.Record.ID, 2, "assistant", "the durable needle is here", time.Now().UTC()) + if result := manager.AddTurn(first); !result.OK { + t.Fatalf("add first turn: %v", result.Value) + } + if result := manager.AddTurn(second); !result.OK { + t.Fatalf("add matching turn: %v", result.Value) + } + other := manager.Create().Value.(*chatSession) + if manager.Active().Record.ID != other.Record.ID { + t.Fatal("search fixture did not leave another session active") + } + + searchResult := newHistorySearch(manager.repository, manager, newUIStyles(midnightTheme()), 72, 14) + if !searchResult.OK { + t.Fatalf("newHistorySearch: %v", searchResult.Value) + } + search := searchResult.Value.(*historySearch) + if result := search.Search("durable needle"); !result.OK { + t.Fatalf("Search: %v", result.Value) + } + if len(search.Hits()) != 1 || search.Hits()[0].Session.ID != matched.Record.ID { + t.Fatalf("search hits = %#v", search.Hits()) + } + if result := search.ActivateSelected(); !result.OK { + t.Fatalf("activate search hit: %v", result.Value) + } + if manager.Active().Record.ID != matched.Record.ID { + t.Fatalf("active after hit = %q", manager.Active().Record.ID) + } + if manager.Active().ViewportOffset != 1 || manager.Active().Follow { + t.Fatalf("matched viewport = offset %d follow %v, want 1/false", manager.Active().ViewportOffset, manager.Active().Follow) + } + if search.MatchTurnID() != second.ID { + t.Fatalf("match turn = %q, want %q", search.MatchTurnID(), second.ID) + } +} + +func TestHistorySearchUsesRenderedTurnOffset_Ugly(t *testing.T) { + a := newApp("", 0, 64) + a.view.Width = 24 + a.turns = []turn{ + {id: "turn-wrapped", role: "assistant", text: "This is a deliberately long markdown paragraph that wraps across several terminal lines before the next match."}, + {id: "turn-match", role: "assistant", text: "durable needle"}, + } + offset := a.transcriptTurnOffset("turn-match") + if offset <= 1 { + t.Fatalf("rendered match offset = %d, want wrapped line offset", offset) + } +} + +func TestOverlayRouting_Ugly(t *testing.T) { + a := newApp("", 0, 64) + m, _ := a.Update(tea.WindowSizeMsg{Width: 100, Height: 24}) + a = m.(app) + a.activePanel = panelService + a.generating = true + + m, _ = a.Update(tea.KeyMsg{Type: tea.KeyCtrlK}) + a = m.(app) + if a.activeOverlay != overlayCommands { + t.Fatalf("ctrl+k overlay = %d, want commands", a.activeOverlay) + } + a.palette.list.SetFilterText("models panel") + beforeAddress := a.svc.addrIdx + m, _ = a.Update(tea.KeyMsg{Type: tea.KeyDown}) + a = m.(app) + if a.activePanel != panelService || a.svc.addrIdx != beforeAddress { + t.Fatal("overlay arrow leaked into the Service panel") + } + m, _ = a.Update(tea.KeyMsg{Type: tea.KeyEnter}) + a = m.(app) + if a.activePanel != panelModels || a.svc.running || a.activeOverlay != overlayNone { + t.Fatalf("overlay Enter: panel=%d service=%v overlay=%d", a.activePanel, a.svc.running, a.activeOverlay) + } + + m, _ = a.Update(tea.KeyMsg{Type: tea.KeyCtrlK}) + a = m.(app) + m, _ = a.Update(tea.KeyMsg{Type: tea.KeyEsc}) + a = m.(app) + if a.activeOverlay != overlayNone || !a.generating { + t.Fatalf("overlay Escape: overlay=%d generating=%v", a.activeOverlay, a.generating) + } +} diff --git a/cli/tui/paths.go b/cli/tui/paths.go new file mode 100644 index 000000000..08a1a1792 --- /dev/null +++ b/cli/tui/paths.go @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package tui + +import ( + core "dappco.re/go" + coreio "dappco.re/go/io" +) + +const ( + appConfigPath = "config.yaml" + appAgentsPath = "agents.yaml" + appSoftServePath = "soft-serve" + appWorkspacesPath = "workspaces" + appPacksPath = "packs" + appExportsPath = "exports" + appJudgesPath = "judges" +) + +// appPaths keeps host-only database paths separate from medium-relative files. +type appPaths struct { + Root string + Database string + // Datasets is the host-only path to datasets.duckdb — a separate + // file from Database (lem.duckdb), per the dataset loop design's + // bulk/lifecycle/blast-radius decision. Only the DuckDB adapter + // (newDuckDatasetStore) receives this resolved filename. + Datasets string + State string + Config string + Agents string + SoftServe string + Workspaces string + Packs string + Exports string + // Judges is the host-only path to ~/.lem/judges — user overrides for + // judge-tier score templates (the dataset loop design's "in-repo + // defaults + ~/.lem/judges/ overrides" decision, Task 9). A CLI-side + // judge driver reads .md from here directly; a present override + // wins over the in-repo judges/.md default of the same name. + Judges string +} + +// appFiles is the application's replaceable file boundary. +type appFiles struct { + Paths appPaths + Medium coreio.Medium +} + +func defaultAppPaths() core.Result { + homeResult := core.UserHomeDir() + if !homeResult.OK { + return core.Fail(core.E("tui.defaultAppPaths", "resolve user home", resultError(homeResult))) + } + home := homeResult.String() + if core.Trim(home) == "" { + return core.Fail(core.E("tui.defaultAppPaths", "user home is empty", nil)) + } + return appPathsAt(core.Path(home, ".lem")) +} + +func appPathsAt(root string) core.Result { + root = core.Trim(root) + if root == "" { + return core.Fail(core.E("tui.appPathsAt", "application root is required", nil)) + } + root = core.Path(root) + return core.Ok(appPaths{ + Root: root, + Database: core.Path(root, "lem.duckdb"), + Datasets: core.Path(root, "datasets.duckdb"), + State: core.Path(root, "state.db"), + Config: appConfigPath, + Agents: appAgentsPath, + SoftServe: core.Path(root, appSoftServePath), + Workspaces: core.Path(root, appWorkspacesPath), + Packs: appPacksPath, + Exports: appExportsPath, + Judges: core.Path(root, appJudgesPath), + }) +} + +func openAppFilesAt(root string) core.Result { + pathsResult := appPathsAt(root) + if !pathsResult.OK { + return pathsResult + } + paths, ok := pathsResult.Value.(appPaths) + if !ok { + return core.Fail(core.E("tui.openAppFilesAt", "invalid path result", nil)) + } + + medium, err := coreio.NewSandboxed(paths.Root) + if err != nil { + return core.Fail(core.E("tui.openAppFilesAt", core.Concat("open application root: ", paths.Root), err)) + } + if ensureResult := ensureAppFiles(medium, paths); !ensureResult.OK { + return ensureResult + } + return core.Ok(appFiles{Paths: paths, Medium: medium}) +} + +func ensureAppFiles(medium coreio.Medium, paths appPaths) core.Result { + if medium == nil { + return core.Fail(core.E("tui.ensureAppFiles", "file medium is required", nil)) + } + for _, directory := range []string{"", appWorkspacesPath, paths.Packs, paths.Exports, appJudgesPath} { + if err := medium.EnsureDir(directory); err != nil { + label := directory + if label == "" { + label = paths.Root + } + return core.Fail(core.E("tui.ensureAppFiles", core.Concat("ensure directory: ", label), err)) + } + } + return core.Ok(nil) +} + +func resultError(result core.Result) error { + err, _ := result.Value.(error) + return err +} + +// OpenJudgesDir resolves and ensures ~/.lem/judges — the medium-backed +// override directory a CLI-side judge driver reads .md templates +// from (Task 9) — returning its host-absolute path. Mirrors +// OpenDatasetStore's resolve-then-ensure sequence without opening a +// database; there is no --home override, HOME is the only seam. +// +// dirResult := tui.OpenJudgesDir() +// dir := dirResult.String() // e.g. "/home/snider/.lem/judges" +func OpenJudgesDir() core.Result { + pathsResult := defaultAppPaths() + if !pathsResult.OK { + return pathsResult + } + paths, ok := pathsResult.Value.(appPaths) + if !ok { + return core.Fail(core.E("tui.OpenJudgesDir", "invalid application paths result", nil)) + } + if ensured := openAppFilesAt(paths.Root); !ensured.OK { + return core.Fail(core.E("tui.OpenJudgesDir", "ensure application layout", resultError(ensured))) + } + return core.Ok(paths.Judges) +} diff --git a/cli/tui/paths_test.go b/cli/tui/paths_test.go new file mode 100644 index 000000000..75df3eddb --- /dev/null +++ b/cli/tui/paths_test.go @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package tui + +import ( + "errors" + "os" + "testing" + + core "dappco.re/go" +) + +func TestAppPaths_Good(t *testing.T) { + root := t.TempDir() + result := appPathsAt(root) + if !result.OK { + t.Fatalf("appPathsAt(%q) failed: %v", root, result.Value) + } + + paths, ok := result.Value.(appPaths) + if !ok { + t.Fatalf("appPathsAt value = %T, want appPaths", result.Value) + } + want := appPaths{ + Root: root, + Database: core.Path(root, "lem.duckdb"), + Datasets: core.Path(root, "datasets.duckdb"), + State: core.Path(root, "state.db"), + Config: "config.yaml", + Agents: "agents.yaml", + SoftServe: core.Path(root, "soft-serve"), + Workspaces: core.Path(root, "workspaces"), + Packs: "packs", + Exports: "exports", + Judges: core.Path(root, "judges"), + } + if paths != want { + t.Fatalf("appPathsAt(%q) = %#v, want %#v", root, paths, want) + } +} + +func TestAppPaths_Bad(t *testing.T) { + result := appPathsAt("") + if result.OK { + t.Fatalf("appPathsAt empty root = %#v, want failure", result.Value) + } +} + +func TestAppFiles_Good(t *testing.T) { + parent := t.TempDir() + root := core.Path(parent, "lem") + result := openAppFilesAt(root) + if !result.OK { + t.Fatalf("openAppFilesAt(%q) failed: %v", root, result.Value) + } + + files, ok := result.Value.(appFiles) + if !ok { + t.Fatalf("openAppFilesAt value = %T, want appFiles", result.Value) + } + for _, directory := range []string{appWorkspacesPath, files.Paths.Packs, files.Paths.Exports, appJudgesPath} { + if !files.Medium.IsDir(directory) { + t.Errorf("medium directory %q was not created", directory) + } + } + for _, directory := range []string{files.Paths.SoftServe, files.Paths.Workspaces, files.Paths.Judges} { + if !core.PathIsAbs(directory) { + t.Errorf("host directory %q is not absolute", directory) + } + } + if err := files.Medium.Write(files.Paths.Config, "theme: midnight\n"); err != nil { + t.Fatalf("write config through medium: %v", err) + } + content, err := files.Medium.Read(files.Paths.Config) + if err != nil || content != "theme: midnight\n" { + t.Fatalf("read config = %q, %v", content, err) + } + + if err := files.Medium.Write("../escape.txt", "contained"); err != nil { + t.Fatalf("write normalised traversal path: %v", err) + } + if _, err := os.Stat(core.Path(parent, "escape.txt")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("traversal escaped sandbox: %v", err) + } +} + +func TestAppFiles_Ugly(t *testing.T) { + root := core.Path(t.TempDir(), "lem") + const original = "do not replace" + if err := os.WriteFile(root, []byte(original), 0600); err != nil { + t.Fatalf("write root fixture: %v", err) + } + + result := openAppFilesAt(root) + if result.OK { + t.Fatalf("openAppFilesAt regular file = %#v, want failure", result.Value) + } + content, err := os.ReadFile(root) + if err != nil { + t.Fatalf("read preserved root fixture: %v", err) + } + if string(content) != original { + t.Fatalf("root fixture changed to %q", content) + } +} + +// ---- OpenJudgesDir: the CLI-facing entry point (cli/judgetemplate.go's +// override resolution, Task 9) ---- + +// TestOpenJudgesDir_Good proves the exported entry point resolves + creates +// $HOME/.lem/judges (no --home flag surface — HOME is the only seam, same +// as TestOpenDatasetStore_Good one file over), and that it is idempotent on +// a second call against the same HOME. +func TestOpenJudgesDir_Good(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + want := core.Path(home, ".lem", "judges") + + first := OpenJudgesDir() + core.RequireTrue(t, first.OK, "OpenJudgesDir first open") + dir, ok := first.Value.(string) + if !ok || dir != want { + t.Fatalf("OpenJudgesDir value = %#v, want %q", first.Value, want) + } + if info, err := os.Stat(dir); err != nil || !info.IsDir() { + t.Fatalf("judges dir missing at %s: %v", dir, err) + } + + second := OpenJudgesDir() + core.RequireTrue(t, second.OK, "OpenJudgesDir second open") + if second.Value.(string) != want { + t.Fatalf("OpenJudgesDir second value = %#v, want %q", second.Value, want) + } +} + +// TestOpenJudgesDir_Bad proves a root that cannot be prepared (HOME points +// at a regular file, so $HOME/.lem cannot be created) fails closed rather +// than returning a bogus path — mirrors TestOpenDatasetStore_Bad. +func TestOpenJudgesDir_Bad(t *testing.T) { + blocker := core.Path(t.TempDir(), "home-is-a-file") + if err := os.WriteFile(blocker, []byte("not a directory"), 0o600); err != nil { + t.Fatalf("write blocker fixture: %v", err) + } + t.Setenv("HOME", blocker) + + result := OpenJudgesDir() + core.AssertFalse(t, result.OK, "OpenJudgesDir over a blocked root must fail") +} diff --git a/cli/tui/picker.go b/cli/tui/picker.go index eb084a7c3..ef2385b5e 100644 --- a/cli/tui/picker.go +++ b/cli/tui/picker.go @@ -3,14 +3,12 @@ package tui import ( - "os" - "path/filepath" "sort" - "strings" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" + core "dappco.re/go" "dappco.re/go/inference" ) @@ -33,10 +31,12 @@ type discoveredMsg struct{ items []list.Item } // documents. Runs as a tea.Cmd so a slow disk never blocks the first frame. func discoverModels() tea.Msg { dirs := []string{} - if home, err := os.UserHomeDir(); err == nil { - dirs = append(dirs, filepath.Join(home, ".cache", "huggingface", "hub")) + if homeResult := core.UserHomeDir(); homeResult.OK { + if home := homeResult.String(); core.Trim(home) != "" { + dirs = append(dirs, core.Path(home, ".cache", "huggingface", "hub")) + } } - if extra := os.Getenv("LEM_MODELS_DIR"); extra != "" { + if extra := core.Trim(core.Getenv("LEM_MODELS_DIR")); extra != "" { dirs = append(dirs, extra) } var items []list.Item @@ -63,24 +63,24 @@ func discoverModels() tea.Msg { // displayName compresses a hub snapshot path to its repo name — the hub layout // is models--ORG--NAME/snapshots/HASH, anything else keeps its base name. func displayName(path string) string { - for _, part := range strings.Split(path, string(filepath.Separator)) { - if rest, ok := strings.CutPrefix(part, "models--"); ok { - if _, name, found := strings.Cut(rest, "--"); found { + for _, part := range core.Split(path, string(core.PathSeparator)) { + if rest, ok := core.CutPrefix(part, "models--"); ok { + if _, name, found := core.Cut(rest, "--"); found { return name } return rest } } - return filepath.Base(path) + return core.PathBase(path) } // newPicker builds the model list with the house dark styling. -func newPicker() list.Model { +func newPicker(styles uiStyles) list.Model { delegate := list.NewDefaultDelegate() l := list.New(nil, delegate, 0, 0) - l.Title = "lem — pick a model" + l.Title = "Models" l.SetShowStatusBar(false) l.SetFilteringEnabled(true) - l.Styles.Title = styleTitle + l.Styles.Title = styles.title return l } diff --git a/cli/tui/preferences.go b/cli/tui/preferences.go new file mode 100644 index 000000000..4a994bd21 --- /dev/null +++ b/cli/tui/preferences.go @@ -0,0 +1,254 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package tui + +import ( + core "dappco.re/go" + config "dappco.re/go/config" + coreio "dappco.re/go/io" +) + +const ( + preferenceContextLength = "generation.context_length" + preferenceMaxTokens = "generation.max_tokens" + preferenceThinking = "generation.thinking" + preferenceTheme = "appearance.theme" + preferenceShowThinking = "appearance.show_thinking" + preferenceRecentSessions = "sessions.recent_limit" + preferenceKnowledgePaths = "knowledge.paths" + preferenceKnowledgeMaxBytes = "knowledge.max_bytes" + preferencePreferredRuntime = "runtime.preferred" + preferenceConfirmExecution = "execution.confirm" + preferenceEnvironmentPrefix = "LEM" +) + +type preferenceValues struct { + ContextLength int + MaxTokens int + Thinking string + Theme string + ShowThinking bool + RecentSessionLimit int + KnowledgePaths []string + KnowledgeMaxBytes int64 + PreferredRuntime string + ConfirmExecution bool +} + +type preferenceStore interface { + Values() preferenceValues + Set(key string, value any) core.Result + Commit() core.Result + Reload() core.Result + Warning() error +} + +type configPreferenceStore struct { + mu core.Mutex + configuration *config.Config + medium coreio.Medium + path string + warning error + commitDisabled bool +} + +func openPreferences(medium coreio.Medium, path string) core.Result { + if medium == nil { + return core.Fail(core.E("tui.openPreferences", "configuration medium is required", nil)) + } + path = core.Trim(path) + if path == "" { + return core.Fail(core.E("tui.openPreferences", "configuration path is required", nil)) + } + + opened := newPreferenceConfig(medium, path) + if opened.OK { + configuration, ok := opened.Value.(*config.Config) + if !ok { + return core.Fail(core.E("tui.openPreferences", "invalid config result", nil)) + } + return core.Ok(&configPreferenceStore{ + configuration: configuration, + medium: medium, + path: path, + }) + } + + warning := preferenceResultError(opened, "load configuration") + fallback := newPreferenceConfig(coreio.NewMemoryMedium(), path) + if !fallback.OK { + return core.Fail(core.E( + "tui.openPreferences", + "create default configuration fallback", + preferenceResultError(fallback, "create fallback"), + )) + } + configuration, ok := fallback.Value.(*config.Config) + if !ok { + return core.Fail(core.E("tui.openPreferences", "invalid fallback config result", nil)) + } + return core.Ok(&configPreferenceStore{ + configuration: configuration, + medium: medium, + path: path, + warning: warning, + commitDisabled: true, + }) +} + +func newPreferenceConfig(medium coreio.Medium, path string) core.Result { + return config.New( + config.WithMedium(medium), + config.WithPath(path), + config.WithEnvPrefix(preferenceEnvironmentPrefix), + config.WithDefaults(preferenceDefaults()), + ) +} + +func (preferences *configPreferenceStore) Values() preferenceValues { + values := defaultPreferenceValues() + if preferences == nil { + return values + } + preferences.mu.Lock() + configuration := preferences.configuration + preferences.mu.Unlock() + if configuration == nil { + return values + } + + preferenceGet(configuration, preferenceContextLength, &values.ContextLength) + preferenceGet(configuration, preferenceMaxTokens, &values.MaxTokens) + preferenceGet(configuration, preferenceThinking, &values.Thinking) + preferenceGet(configuration, preferenceTheme, &values.Theme) + preferenceGet(configuration, preferenceShowThinking, &values.ShowThinking) + preferenceGet(configuration, preferenceRecentSessions, &values.RecentSessionLimit) + preferenceGet(configuration, preferenceKnowledgePaths, &values.KnowledgePaths) + preferenceGet(configuration, preferenceKnowledgeMaxBytes, &values.KnowledgeMaxBytes) + preferenceGet(configuration, preferencePreferredRuntime, &values.PreferredRuntime) + preferenceGet(configuration, preferenceConfirmExecution, &values.ConfirmExecution) + return values +} + +func (preferences *configPreferenceStore) Set(key string, value any) core.Result { + if preferences == nil { + return core.Fail(core.E("tui.preferences.Set", "preferences are unavailable", nil)) + } + key = core.Trim(key) + if key == "" { + return core.Fail(core.E("tui.preferences.Set", "preference key is required", nil)) + } + preferences.mu.Lock() + configuration := preferences.configuration + preferences.mu.Unlock() + if configuration == nil { + return core.Fail(core.E("tui.preferences.Set", "configuration is unavailable", nil)) + } + return configuration.Set(key, value) +} + +func (preferences *configPreferenceStore) Commit() core.Result { + if preferences == nil { + return core.Fail(core.E("tui.preferences.Commit", "preferences are unavailable", nil)) + } + preferences.mu.Lock() + disabled := preferences.commitDisabled + warning := preferences.warning + configuration := preferences.configuration + preferences.mu.Unlock() + if disabled { + return core.Fail(core.E( + "tui.preferences.Commit", + "commit disabled until the configuration reloads successfully", + warning, + )) + } + if configuration == nil { + return core.Fail(core.E("tui.preferences.Commit", "configuration is unavailable", nil)) + } + return configuration.Commit() +} + +func (preferences *configPreferenceStore) Reload() core.Result { + if preferences == nil { + return core.Fail(core.E("tui.preferences.Reload", "preferences are unavailable", nil)) + } + preferences.mu.Lock() + medium := preferences.medium + path := preferences.path + preferences.mu.Unlock() + + opened := newPreferenceConfig(medium, path) + if !opened.OK { + warning := preferenceResultError(opened, "reload configuration") + preferences.mu.Lock() + preferences.warning = warning + preferences.commitDisabled = true + preferences.mu.Unlock() + return core.Fail(core.E("tui.preferences.Reload", "reload configuration", warning)) + } + configuration, ok := opened.Value.(*config.Config) + if !ok { + return core.Fail(core.E("tui.preferences.Reload", "invalid config result", nil)) + } + + preferences.mu.Lock() + preferences.configuration = configuration + preferences.warning = nil + preferences.commitDisabled = false + preferences.mu.Unlock() + return core.Ok(nil) +} + +func (preferences *configPreferenceStore) Warning() error { + if preferences == nil { + return nil + } + preferences.mu.Lock() + defer preferences.mu.Unlock() + return preferences.warning +} + +func defaultPreferenceValues() preferenceValues { + return preferenceValues{ + ContextLength: 0, + MaxTokens: 4096, + Thinking: "model", + Theme: "midnight", + ShowThinking: true, + RecentSessionLimit: 12, + KnowledgePaths: []string{appPacksPath}, + KnowledgeMaxBytes: 65536, + PreferredRuntime: "auto", + ConfirmExecution: true, + } +} + +func preferenceDefaults() map[string]any { + values := defaultPreferenceValues() + return map[string]any{ + preferenceContextLength: values.ContextLength, + preferenceMaxTokens: values.MaxTokens, + preferenceThinking: values.Thinking, + preferenceTheme: values.Theme, + preferenceShowThinking: values.ShowThinking, + preferenceRecentSessions: values.RecentSessionLimit, + preferenceKnowledgePaths: values.KnowledgePaths, + preferenceKnowledgeMaxBytes: values.KnowledgeMaxBytes, + preferencePreferredRuntime: values.PreferredRuntime, + preferenceConfirmExecution: values.ConfirmExecution, + } +} + +func preferenceGet(configuration *config.Config, key string, target any) { + if result := configuration.Get(key, target); !result.OK { + core.Warn("tui.preferences.get_default", "key", key, "error", result.Value) + } +} + +func preferenceResultError(result core.Result, message string) error { + if err := resultError(result); err != nil { + return err + } + return core.E("tui.preferences", message, nil) +} diff --git a/cli/tui/preferences_test.go b/cli/tui/preferences_test.go new file mode 100644 index 000000000..3159c09b1 --- /dev/null +++ b/cli/tui/preferences_test.go @@ -0,0 +1,146 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package tui + +import ( + "strings" + "testing" + + coreio "dappco.re/go/io" +) + +func TestPreferences_Good(t *testing.T) { + medium := coreio.NewMockMedium() + opened := openPreferences(medium, appConfigPath) + if !opened.OK { + t.Fatalf("openPreferences failed: %v", opened.Value) + } + preferences, ok := opened.Value.(preferenceStore) + if !ok { + t.Fatalf("openPreferences value = %T, want preferenceStore", opened.Value) + } + defaults := preferences.Values() + if defaults.ContextLength != 0 || defaults.MaxTokens != 4096 || defaults.Thinking != "model" { + t.Fatalf("generation defaults = %#v", defaults) + } + if defaults.Theme != "midnight" || !defaults.ShowThinking || defaults.RecentSessionLimit != 12 { + t.Fatalf("workspace defaults = %#v", defaults) + } + if len(defaults.KnowledgePaths) != 1 || defaults.KnowledgePaths[0] != appPacksPath || defaults.KnowledgeMaxBytes != 65536 { + t.Fatalf("knowledge defaults = %#v", defaults) + } + if defaults.PreferredRuntime != "auto" || !defaults.ConfirmExecution { + t.Fatalf("runtime defaults = %#v", defaults) + } + + if result := preferences.Set("appearance.theme", "aurora"); !result.OK { + t.Fatalf("set theme: %v", result.Value) + } + if result := preferences.Set("generation.max_tokens", 8192); !result.OK { + t.Fatalf("set max tokens: %v", result.Value) + } + if medium.Exists(appConfigPath) { + t.Fatalf("%q exists before Commit", appConfigPath) + } + if result := preferences.Commit(); !result.OK { + t.Fatalf("commit preferences: %v", result.Value) + } + if !medium.Exists(appConfigPath) { + t.Fatalf("%q does not exist after Commit", appConfigPath) + } + + reopened := openPreferences(medium, appConfigPath) + if !reopened.OK { + t.Fatalf("reopen preferences: %v", reopened.Value) + } + preferences, ok = reopened.Value.(preferenceStore) + if !ok { + t.Fatalf("reopened value = %T, want preferenceStore", reopened.Value) + } + values := preferences.Values() + if values.Theme != "aurora" || values.MaxTokens != 8192 { + t.Fatalf("reopened values = %#v, want aurora and 8192", values) + } + if preferences.Warning() != nil { + t.Fatalf("healthy preferences warning = %v, want nil", preferences.Warning()) + } +} + +func TestPreferences_Bad(t *testing.T) { + medium := coreio.NewMockMedium() + const malformed = "generation: [unterminated\nappearance:\n theme: should-not-load\n" + if err := medium.Write(appConfigPath, malformed); err != nil { + t.Fatalf("write malformed config: %v", err) + } + + opened := openPreferences(medium, appConfigPath) + if !opened.OK { + t.Fatalf("openPreferences malformed config should degrade: %v", opened.Value) + } + preferences, ok := opened.Value.(preferenceStore) + if !ok { + t.Fatalf("openPreferences value = %T, want preferenceStore", opened.Value) + } + values := preferences.Values() + if values.Theme != "midnight" || values.MaxTokens != 4096 { + t.Fatalf("malformed fallback values = %#v, want defaults", values) + } + if preferences.Warning() == nil { + t.Fatal("malformed preferences warning = nil, want parse warning") + } + if result := preferences.Set("appearance.theme", "unsafe-overwrite"); !result.OK { + t.Fatalf("stage fallback preference: %v", result.Value) + } + if result := preferences.Commit(); result.OK { + t.Fatalf("Commit malformed fallback = %#v, want failure", result.Value) + } + content, err := medium.Read(appConfigPath) + if err != nil { + t.Fatalf("read malformed config after blocked commit: %v", err) + } + if content != malformed { + t.Fatalf("malformed config changed to %q, want byte preservation", content) + } + + if err := medium.Write(appConfigPath, "appearance:\n theme: repaired\n"); err != nil { + t.Fatalf("repair config fixture: %v", err) + } + if result := preferences.Reload(); !result.OK { + t.Fatalf("Reload repaired config: %v", result.Value) + } + if preferences.Warning() != nil || preferences.Values().Theme != "repaired" { + t.Fatalf("reloaded preferences = %#v, warning %v", preferences.Values(), preferences.Warning()) + } +} + +func TestPreferences_Ugly(t *testing.T) { + t.Setenv("LEM_GENERATION_MAX_TOKENS", "12288") + medium := coreio.NewMockMedium() + opened := openPreferences(medium, appConfigPath) + if !opened.OK { + t.Fatalf("openPreferences with environment: %v", opened.Value) + } + preferences, ok := opened.Value.(preferenceStore) + if !ok { + t.Fatalf("openPreferences value = %T, want preferenceStore", opened.Value) + } + if got := preferences.Values().MaxTokens; got != 12288 { + t.Fatalf("environment max tokens = %d, want 12288", got) + } + if result := preferences.Set("appearance.theme", "daylight"); !result.OK { + t.Fatalf("set unrelated theme: %v", result.Value) + } + if result := preferences.Commit(); !result.OK { + t.Fatalf("commit unrelated theme: %v", result.Value) + } + content, err := medium.Read(appConfigPath) + if err != nil { + t.Fatalf("read committed config: %v", err) + } + if !strings.Contains(content, "daylight") { + t.Fatalf("committed config = %q, want explicit theme", content) + } + if strings.Contains(content, "max_tokens") || strings.Contains(content, "12288") { + t.Fatalf("environment-derived max tokens leaked into config: %q", content) + } +} diff --git a/cli/tui/reactive.go b/cli/tui/reactive.go new file mode 100644 index 000000000..ff1cfe5eb --- /dev/null +++ b/cli/tui/reactive.go @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package tui + +import ( + core "dappco.re/go" + "dappco.re/go/store" +) + +const ( + reactiveNamespace = "lem" + reactiveGroupWorkspace = "workspace" + reactiveGroupDrafts = "drafts" + reactiveGroupViewport = "viewport" + reactiveGroupCollapsed = "collapsed" +) + +type reactiveState interface { + Get(group, key string) (string, core.Result) + Set(group, key, value string) core.Result + Delete(group, key string) core.Result + Close() core.Result +} + +type storeReactiveState struct { + store *store.Store + scoped *store.ScopedStore +} + +func openReactiveState(paths appPaths) core.Result { + if core.Trim(paths.State) == "" { + return core.Fail(core.E("tui.openReactiveState", "state database path is required", nil)) + } + if core.Trim(paths.Root) == "" || core.Trim(paths.Workspaces) == "" { + return core.Fail(core.E("tui.openReactiveState", "workspace state directory is required", nil)) + } + + opened := store.New( + paths.State, + store.WithWorkspaceStateDirectory(paths.Workspaces), + ) + if !opened.OK { + return core.Fail(core.E("tui.openReactiveState", core.Concat("open state database: ", paths.State), resultError(opened))) + } + instance, ok := opened.Value.(*store.Store) + if !ok { + return core.Fail(core.E("tui.openReactiveState", "invalid go-store result", nil)) + } + scoped := store.NewScoped(instance, reactiveNamespace) + if scoped == nil { + if result := instance.Close(); !result.OK { + core.Warn("tui.reactive.close_after_scope_failure", "error", result.Value) + } + return core.Fail(core.E("tui.openReactiveState", "create scoped state", nil)) + } + return core.Ok(&storeReactiveState{store: instance, scoped: scoped}) +} + +func (state *storeReactiveState) Get(group, key string) (string, core.Result) { + if result := state.ready("Get", group, key); !result.OK { + return "", result + } + return state.scoped.GetFrom(group, key) +} + +func (state *storeReactiveState) Set(group, key, value string) core.Result { + if result := state.ready("Set", group, key); !result.OK { + return result + } + return state.scoped.SetIn(group, key, value) +} + +func (state *storeReactiveState) Delete(group, key string) core.Result { + if result := state.ready("Delete", group, key); !result.OK { + return result + } + return state.scoped.Delete(group, key) +} + +func (state *storeReactiveState) Close() core.Result { + if state == nil || state.store == nil { + return core.Ok(nil) + } + result := state.store.Close() + state.store = nil + state.scoped = nil + return result +} + +func (state *storeReactiveState) ready(operation, group, key string) core.Result { + if state == nil || state.store == nil || state.scoped == nil { + return core.Fail(core.E(core.Concat("tui.reactiveState.", operation), "reactive state is closed", nil)) + } + if !validReactiveGroup(group) { + return core.Fail(core.E(core.Concat("tui.reactiveState.", operation), core.Concat("unknown state group: ", group), nil)) + } + if core.Trim(key) == "" { + return core.Fail(core.E(core.Concat("tui.reactiveState.", operation), "state key is required", nil)) + } + return core.Ok(nil) +} + +func validReactiveGroup(group string) bool { + switch group { + case reactiveGroupWorkspace, reactiveGroupDrafts, reactiveGroupViewport, reactiveGroupCollapsed: + return true + default: + return false + } +} + +type disabledReactiveState struct { + reason error +} + +func newDisabledReactiveState(reason error) reactiveState { + return &disabledReactiveState{reason: reason} +} + +func (state *disabledReactiveState) Get(group, key string) (string, core.Result) { + return "", core.Fail(core.E( + "tui.disabledReactiveState.Get", + disabledStateMessage(state, group, key), + store.NotFoundError, + )) +} + +func (state *disabledReactiveState) Set(group, key, _ string) core.Result { + return core.Fail(core.E( + "tui.disabledReactiveState.Set", + disabledStateMessage(state, group, key), + disabledStateReason(state), + )) +} + +func (state *disabledReactiveState) Delete(group, key string) core.Result { + return core.Fail(core.E( + "tui.disabledReactiveState.Delete", + disabledStateMessage(state, group, key), + disabledStateReason(state), + )) +} + +func (*disabledReactiveState) Close() core.Result { + return core.Ok(nil) +} + +func disabledStateMessage(state *disabledReactiveState, group, key string) string { + message := core.Concat("state disabled for ", group, "/", key) + if reason := disabledStateReason(state); reason != nil { + message = core.Concat(message, ": ", reason.Error()) + } + return message +} + +func disabledStateReason(state *disabledReactiveState) error { + if state == nil { + return nil + } + return state.reason +} diff --git a/cli/tui/reactive_test.go b/cli/tui/reactive_test.go new file mode 100644 index 000000000..6b35fa814 --- /dev/null +++ b/cli/tui/reactive_test.go @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package tui + +import ( + "os" + "testing" +) + +func TestReactiveState_Good(t *testing.T) { + paths := testReactivePaths(t) + opened := openReactiveState(paths) + if !opened.OK { + t.Fatalf("openReactiveState failed: %v", opened.Value) + } + state, ok := opened.Value.(reactiveState) + if !ok { + t.Fatalf("openReactiveState value = %T, want reactiveState", opened.Value) + } + values := []struct { + group string + key string + value string + }{ + {group: reactiveGroupWorkspace, key: "active_session", value: "session-a"}, + {group: reactiveGroupDrafts, key: "session-a", value: "unfinished prompt"}, + {group: reactiveGroupViewport, key: "session-a", value: "41"}, + {group: reactiveGroupCollapsed, key: "turn-tool-1", value: "true"}, + } + for _, item := range values { + if result := state.Set(item.group, item.key, item.value); !result.OK { + t.Fatalf("Set(%q, %q): %v", item.group, item.key, result.Value) + } + } + if result := state.Close(); !result.OK { + t.Fatalf("close first state: %v", result.Value) + } + + reopened := openReactiveState(paths) + if !reopened.OK { + t.Fatalf("reopen reactive state: %v", reopened.Value) + } + state, ok = reopened.Value.(reactiveState) + if !ok { + t.Fatalf("reopened value = %T, want reactiveState", reopened.Value) + } + defer func() { + if result := state.Close(); !result.OK { + t.Errorf("close reopened state: %v", result.Value) + } + }() + for _, item := range values { + got, result := state.Get(item.group, item.key) + if !result.OK { + t.Errorf("Get(%q, %q): %v", item.group, item.key, result.Value) + continue + } + if got != item.value { + t.Errorf("Get(%q, %q) = %q, want %q", item.group, item.key, got, item.value) + } + } +} + +func TestReactiveState_Bad(t *testing.T) { + paths := testReactivePaths(t) + if err := os.Remove(paths.State); err != nil && !os.IsNotExist(err) { + t.Fatalf("remove unused state path: %v", err) + } + if err := os.Mkdir(paths.State, 0700); err != nil { + t.Fatalf("create directory at database path: %v", err) + } + + result := openReactiveState(paths) + if result.OK { + state, _ := result.Value.(reactiveState) + if state != nil { + _ = state.Close() + } + t.Fatalf("openReactiveState(directory path) = %#v, want failure", result.Value) + } +} + +func TestReactiveState_Ugly(t *testing.T) { + paths := testReactivePaths(t) + opened := openReactiveState(paths) + if !opened.OK { + t.Fatalf("openReactiveState failed: %v", opened.Value) + } + state, ok := opened.Value.(reactiveState) + if !ok { + t.Fatalf("openReactiveState value = %T, want reactiveState", opened.Value) + } + defer func() { + if result := state.Close(); !result.OK { + t.Errorf("close state: %v", result.Value) + } + }() + + if result := state.Set(reactiveGroupDrafts, "session-empty", ""); !result.OK { + t.Fatalf("store empty draft: %v", result.Value) + } + value, result := state.Get(reactiveGroupDrafts, "session-empty") + if !result.OK || value != "" { + t.Fatalf("stored empty draft = %q, %#v, want empty successful value", value, result.Value) + } + if result := state.Delete(reactiveGroupDrafts, "session-empty"); !result.OK { + t.Fatalf("delete draft: %v", result.Value) + } + value, result = state.Get(reactiveGroupDrafts, "session-empty") + if result.OK || value != "" { + t.Fatalf("deleted draft = %q, %#v, want distinguishable miss", value, result.Value) + } +} + +func testReactivePaths(t *testing.T) appPaths { + t.Helper() + result := appPathsAt(t.TempDir()) + if !result.OK { + t.Fatalf("appPathsAt: %v", result.Value) + } + paths, ok := result.Value.(appPaths) + if !ok { + t.Fatalf("appPathsAt value = %T, want appPaths", result.Value) + } + if err := os.MkdirAll(paths.Root+"/"+paths.Workspaces, 0700); err != nil { + t.Fatalf("create workspace state directory: %v", err) + } + return paths +} diff --git a/cli/tui/records.go b/cli/tui/records.go new file mode 100644 index 000000000..65b9ab22e --- /dev/null +++ b/cli/tui/records.go @@ -0,0 +1,294 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package tui + +import ( + "time" + + "dappco.re/go/orm" + "github.com/google/uuid" +) + +// The ORM's NotNull constraint also rejects empty strings. Apply it only to +// fields that must be non-empty; the migration separately gives every column +// SQL NOT NULL semantics, where an empty optional text value remains valid. + +type schemaVersionRecord struct { + Version int64 `json:"version"` + AppliedAt time.Time `json:"applied_at"` +} + +func (schemaVersionRecord) Schema() orm.Schema { + return orm.Define(func(builder *orm.Builder) { + builder.Name("lem_schema_versions") + builder.PK("version") + builder.Int64("version").NotNull() + builder.Time("applied_at").NotNull() + }) +} + +type sessionRecord struct { + ID string `json:"id"` + Title string `json:"title"` + Status string `json:"status"` + PreferredModel string `json:"preferred_model"` + Mode string `json:"mode"` + GenerationJSON string `json:"generation_json"` + ToolsJSON string `json:"tools_json"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + LastOpenedAt time.Time `json:"last_opened_at"` + Archived bool `json:"archived"` + ArchivedAt time.Time `json:"archived_at"` +} + +func (sessionRecord) Schema() orm.Schema { + return orm.Define(func(builder *orm.Builder) { + builder.Name("lem_sessions") + builder.PK("id") + builder.String("id").NotNull() + builder.String("title").NotNull() + builder.String("status").NotNull() + builder.String("preferred_model") + builder.String("mode").NotNull() + builder.String("generation_json").NotNull() + builder.String("tools_json").NotNull() + builder.Time("created_at").NotNull() + builder.Time("updated_at").NotNull() + builder.Time("last_opened_at").NotNull() + builder.Bool("archived").NotNull() + builder.Time("archived_at").NotNull() + builder.Index("archived", "last_opened_at") + }) +} + +type turnRecord struct { + ID string `json:"id"` + SessionID string `json:"session_id"` + Sequence int64 `json:"sequence"` + Role string `json:"role"` + Visible string `json:"visible"` + Thought string `json:"thought"` + ToolName string `json:"tool_name"` + ToolCallJSON string `json:"tool_call_json"` + ToolResultJSON string `json:"tool_result_json"` + Model string `json:"model"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (turnRecord) Schema() orm.Schema { + return orm.Define(func(builder *orm.Builder) { + builder.Name("lem_turns") + builder.PK("id") + builder.String("id").NotNull() + builder.String("session_id").NotNull() + builder.Int64("sequence").NotNull() + builder.String("role").NotNull() + builder.String("visible") + builder.String("thought") + builder.String("tool_name") + builder.String("tool_call_json") + builder.String("tool_result_json") + builder.String("model") + builder.Time("created_at").NotNull() + builder.Time("updated_at").NotNull() + builder.Index("session_id", "sequence") + }) +} + +type eventRecord struct { + ID string `json:"id"` + SessionID string `json:"session_id"` + WorkItemID string `json:"work_item_id"` + JobID string `json:"job_id"` + Kind string `json:"kind"` + Status string `json:"status"` + Title string `json:"title"` + Detail string `json:"detail"` + PayloadJSON string `json:"payload_json"` + CreatedAt time.Time `json:"created_at"` +} + +func (eventRecord) Schema() orm.Schema { + return orm.Define(func(builder *orm.Builder) { + builder.Name("lem_events") + builder.PK("id") + builder.String("id").NotNull() + builder.String("session_id").NotNull() + builder.String("work_item_id") + builder.String("job_id") + builder.String("kind").NotNull() + builder.String("status").NotNull() + builder.String("title").NotNull() + builder.String("detail") + builder.String("payload_json") + builder.Time("created_at").NotNull() + builder.Index("session_id", "created_at") + }) +} + +type generationJobRecord struct { + ID string `json:"id"` + SessionID string `json:"session_id"` + PromptTurnID string `json:"prompt_turn_id"` + AnswerTurnID string `json:"answer_turn_id"` + Status string `json:"status"` + Model string `json:"model"` + Error string `json:"error"` + MetricsJSON string `json:"metrics_json"` + CreatedAt time.Time `json:"created_at"` + StartedAt time.Time `json:"started_at"` + FinishedAt time.Time `json:"finished_at"` +} + +func (generationJobRecord) Schema() orm.Schema { + return orm.Define(func(builder *orm.Builder) { + builder.Name("lem_generation_jobs") + builder.PK("id") + builder.String("id").NotNull() + builder.String("session_id").NotNull() + builder.String("prompt_turn_id") + builder.String("answer_turn_id") + builder.String("status").NotNull() + builder.String("model") + builder.String("error") + builder.String("metrics_json") + builder.Time("created_at").NotNull() + builder.Time("started_at").NotNull() + builder.Time("finished_at").NotNull() + builder.Index("session_id", "status", "created_at") + }) +} + +type workItemRecord struct { + ID string `json:"id"` + ExternalID string `json:"external_id"` + Source string `json:"source"` + Title string `json:"title"` + Status string `json:"status"` + Agent string `json:"agent"` + Repo string `json:"repo"` + Org string `json:"org"` + Task string `json:"task"` + Branch string `json:"branch"` + Runtime string `json:"runtime"` + Question string `json:"question"` + PRURL string `json:"pr_url"` + SessionID string `json:"session_id"` + StartedAt time.Time `json:"started_at"` + UpdatedAt time.Time `json:"updated_at"` + Archived bool `json:"archived"` + ArchivedAt time.Time `json:"archived_at"` +} + +func (workItemRecord) Schema() orm.Schema { + return orm.Define(func(builder *orm.Builder) { + builder.Name("lem_work_items") + builder.PK("id") + builder.String("id").NotNull() + builder.String("external_id").NotNull().Unique() + builder.String("source").NotNull() + builder.String("title").NotNull() + builder.String("status").NotNull() + builder.String("agent") + builder.String("repo") + builder.String("org") + builder.String("task") + builder.String("branch") + builder.String("runtime") + builder.String("question") + builder.String("pr_url") + builder.String("session_id") + builder.Time("started_at").NotNull() + builder.Time("updated_at").NotNull() + builder.Bool("archived").NotNull() + builder.Time("archived_at").NotNull() + builder.Index("archived", "status", "updated_at") + }) +} + +type artifactRecord struct { + ID string `json:"id"` + SessionID string `json:"session_id"` + WorkItemID string `json:"work_item_id"` + Kind string `json:"kind"` + Path string `json:"path"` + Title string `json:"title"` + MetadataJSON string `json:"metadata_json"` + CreatedAt time.Time `json:"created_at"` + Archived bool `json:"archived"` + ArchivedAt time.Time `json:"archived_at"` +} + +func (artifactRecord) Schema() orm.Schema { + return orm.Define(func(builder *orm.Builder) { + builder.Name("lem_artifacts") + builder.PK("id") + builder.String("id").NotNull() + builder.String("session_id").NotNull() + builder.String("work_item_id") + builder.String("kind").NotNull() + builder.String("path").NotNull() + builder.String("title").NotNull() + builder.String("metadata_json") + builder.Time("created_at").NotNull() + builder.Bool("archived").NotNull() + builder.Time("archived_at").NotNull() + builder.Index("session_id", "created_at") + }) +} + +type attachmentRecord struct { + ID string `json:"id"` + SessionID string `json:"session_id"` + SourcePath string `json:"source_path"` + Title string `json:"title"` + ContentHash string `json:"content_hash"` + Snapshot string `json:"snapshot"` + AddedAt time.Time `json:"added_at"` + LastCheckedAt time.Time `json:"last_checked_at"` + Stale bool `json:"stale"` + Archived bool `json:"archived"` + ArchivedAt time.Time `json:"archived_at"` +} + +func (attachmentRecord) Schema() orm.Schema { + return orm.Define(func(builder *orm.Builder) { + builder.Name("lem_attachments") + builder.PK("id") + builder.String("id").NotNull() + builder.String("session_id").NotNull() + builder.String("source_path").NotNull() + builder.String("title").NotNull() + builder.String("content_hash") + builder.String("snapshot") + builder.Time("added_at").NotNull() + builder.Time("last_checked_at").NotNull() + builder.Bool("stale").NotNull() + builder.Bool("archived").NotNull() + builder.Time("archived_at").NotNull() + builder.Index("session_id", "archived", "added_at") + }) +} + +func newRecordID() string { + return uuid.NewString() +} + +func unsetRecordTime() time.Time { + return time.Unix(0, 0).UTC() +} + +func workspaceRecordSchemas() []orm.Schema { + return []orm.Schema{ + (schemaVersionRecord{}).Schema(), + (sessionRecord{}).Schema(), + (turnRecord{}).Schema(), + (eventRecord{}).Schema(), + (generationJobRecord{}).Schema(), + (workItemRecord{}).Schema(), + (artifactRecord{}).Schema(), + (attachmentRecord{}).Schema(), + } +} diff --git a/cli/tui/records_test.go b/cli/tui/records_test.go new file mode 100644 index 000000000..1072c2cb1 --- /dev/null +++ b/cli/tui/records_test.go @@ -0,0 +1,222 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package tui + +import ( + "reflect" + "testing" + "time" + + "dappco.re/go/orm" + "github.com/google/uuid" +) + +type schemaFieldExpectation struct { + typ string + constraints []string +} + +type recordSchemaExpectation struct { + name string + pk []string + fields map[string]schemaFieldExpectation + indexes [][]string +} + +func TestRecordSchemas_Good(t *testing.T) { + expectations := []struct { + schema orm.Schema + want recordSchemaExpectation + }{ + { + schema: (schemaVersionRecord{}).Schema(), + want: recordSchemaExpectation{ + name: "lem_schema_versions", + pk: []string{"version"}, + fields: map[string]schemaFieldExpectation{ + "version": {typ: "int64", constraints: []string{"pk", "notnull"}}, + "applied_at": {typ: "time", constraints: []string{"notnull"}}, + }, + }, + }, + { + schema: (sessionRecord{}).Schema(), + want: recordSchemaExpectation{ + name: "lem_sessions", + pk: []string{"id"}, + fields: requiredRecordFields(map[string]string{ + "id": "string", "title": "string", "status": "string", "preferred_model": "string", + "mode": "string", "generation_json": "string", "tools_json": "string", + "created_at": "time", "updated_at": "time", "last_opened_at": "time", + "archived": "bool", "archived_at": "time", + }, "id", "preferred_model"), + indexes: [][]string{{"archived", "last_opened_at"}}, + }, + }, + { + schema: (turnRecord{}).Schema(), + want: recordSchemaExpectation{ + name: "lem_turns", + pk: []string{"id"}, + fields: requiredRecordFields(map[string]string{ + "id": "string", "session_id": "string", "sequence": "int64", "role": "string", + "visible": "string", "thought": "string", "tool_name": "string", + "tool_call_json": "string", "tool_result_json": "string", "model": "string", + "created_at": "time", "updated_at": "time", + }, "id", "visible", "thought", "tool_name", "tool_call_json", "tool_result_json", "model"), + indexes: [][]string{{"session_id", "sequence"}}, + }, + }, + { + schema: (eventRecord{}).Schema(), + want: recordSchemaExpectation{ + name: "lem_events", + pk: []string{"id"}, + fields: requiredRecordFields(map[string]string{ + "id": "string", "session_id": "string", "work_item_id": "string", "job_id": "string", + "kind": "string", "status": "string", "title": "string", "detail": "string", + "payload_json": "string", "created_at": "time", + }, "id", "work_item_id", "job_id", "detail", "payload_json"), + indexes: [][]string{{"session_id", "created_at"}}, + }, + }, + { + schema: (generationJobRecord{}).Schema(), + want: recordSchemaExpectation{ + name: "lem_generation_jobs", + pk: []string{"id"}, + fields: requiredRecordFields(map[string]string{ + "id": "string", "session_id": "string", "prompt_turn_id": "string", + "answer_turn_id": "string", "status": "string", "model": "string", "error": "string", + "metrics_json": "string", "created_at": "time", "started_at": "time", "finished_at": "time", + }, "id", "prompt_turn_id", "answer_turn_id", "model", "error", "metrics_json"), + indexes: [][]string{{"session_id", "status", "created_at"}}, + }, + }, + { + schema: (workItemRecord{}).Schema(), + want: recordSchemaExpectation{ + name: "lem_work_items", + pk: []string{"id"}, + fields: requiredRecordFieldsWithUnique(map[string]string{ + "id": "string", "external_id": "string", "source": "string", "title": "string", + "status": "string", "agent": "string", "repo": "string", "org": "string", "task": "string", + "branch": "string", "runtime": "string", "question": "string", "pr_url": "string", + "session_id": "string", "started_at": "time", "updated_at": "time", "archived": "bool", + "archived_at": "time", + }, "id", "external_id", "agent", "repo", "org", "task", "branch", "runtime", "question", "pr_url", "session_id"), + indexes: [][]string{{"archived", "status", "updated_at"}}, + }, + }, + { + schema: (artifactRecord{}).Schema(), + want: recordSchemaExpectation{ + name: "lem_artifacts", + pk: []string{"id"}, + fields: requiredRecordFields(map[string]string{ + "id": "string", "session_id": "string", "work_item_id": "string", "kind": "string", + "path": "string", "title": "string", "metadata_json": "string", "created_at": "time", + "archived": "bool", "archived_at": "time", + }, "id", "work_item_id", "metadata_json"), + indexes: [][]string{{"session_id", "created_at"}}, + }, + }, + { + schema: (attachmentRecord{}).Schema(), + want: recordSchemaExpectation{ + name: "lem_attachments", + pk: []string{"id"}, + fields: requiredRecordFields(map[string]string{ + "id": "string", "session_id": "string", "source_path": "string", "title": "string", + "content_hash": "string", "snapshot": "string", "added_at": "time", + "last_checked_at": "time", "stale": "bool", "archived": "bool", "archived_at": "time", + }, "id", "content_hash", "snapshot"), + indexes: [][]string{{"session_id", "archived", "added_at"}}, + }, + }, + } + + for _, expectation := range expectations { + assertRecordSchema(t, expectation.schema, expectation.want) + } +} + +func TestNewRecordID_Good(t *testing.T) { + first := newRecordID() + second := newRecordID() + if _, err := uuid.Parse(first); err != nil { + t.Fatalf("newRecordID() = %q, want UUID: %v", first, err) + } + if first == second { + t.Fatalf("two newRecordID calls both returned %q", first) + } +} + +func TestUnsetRecordTime_Good(t *testing.T) { + want := time.Unix(0, 0).UTC() + if got := unsetRecordTime(); !got.Equal(want) || got.Location() != time.UTC { + t.Fatalf("unsetRecordTime() = %v (%v), want %v UTC", got, got.Location(), want) + } +} + +func requiredRecordFields(fields map[string]string, primaryKey string, optional ...string) map[string]schemaFieldExpectation { + return requiredRecordFieldsWithUnique(fields, primaryKey, "", optional...) +} + +func requiredRecordFieldsWithUnique(fields map[string]string, primaryKey, unique string, optional ...string) map[string]schemaFieldExpectation { + want := make(map[string]schemaFieldExpectation, len(fields)) + for name, typ := range fields { + constraints := []string{"notnull"} + for _, optionalName := range optional { + if name == optionalName { + constraints = nil + break + } + } + if name == primaryKey { + constraints = []string{"pk", "notnull"} + } + if name == unique { + constraints = append(constraints, "unique") + } + want[name] = schemaFieldExpectation{typ: typ, constraints: constraints} + } + return want +} + +func assertRecordSchema(t *testing.T, got orm.Schema, want recordSchemaExpectation) { + t.Helper() + if got.Name != want.name { + t.Errorf("schema name = %q, want %q", got.Name, want.name) + } + if !reflect.DeepEqual(got.PK, want.pk) { + t.Errorf("%s primary key = %#v, want %#v", want.name, got.PK, want.pk) + } + if len(got.Fields) != len(want.fields) { + t.Errorf("%s fields = %d, want %d", want.name, len(got.Fields), len(want.fields)) + } + for name, fieldWant := range want.fields { + field, ok := got.FieldByName(name) + if !ok { + t.Errorf("%s missing field %q", want.name, name) + continue + } + if field.Type != fieldWant.typ { + t.Errorf("%s.%s type = %q, want %q", want.name, name, field.Type, fieldWant.typ) + } + if !reflect.DeepEqual(field.Constraints, fieldWant.constraints) { + t.Errorf("%s.%s constraints = %#v, want %#v", want.name, name, field.Constraints, fieldWant.constraints) + } + } + if len(got.Indexes) != len(want.indexes) { + t.Errorf("%s indexes = %d, want %d", want.name, len(got.Indexes), len(want.indexes)) + } + for index, fields := range want.indexes { + if index >= len(got.Indexes) { + break + } + if !reflect.DeepEqual(got.Indexes[index].Fields, fields) { + t.Errorf("%s index %d = %#v, want %#v", want.name, index, got.Indexes[index].Fields, fields) + } + } +} diff --git a/cli/tui/repository.go b/cli/tui/repository.go new file mode 100644 index 000000000..fe442de10 --- /dev/null +++ b/cli/tui/repository.go @@ -0,0 +1,444 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package tui + +import ( + "database/sql" + "sync" + "time" + + core "dappco.re/go" + "dappco.re/go/orm" +) + +type sessionSearchHit struct { + Session sessionRecord + Snippet string +} + +type workspaceRepository interface { + Close() core.Result + Session(id string) core.Result + ListSessions(includeArchived bool) core.Result + SearchSessions(query string, limit int) core.Result + SaveSession(record sessionRecord) core.Result + Turns(sessionID string) core.Result + SaveTurn(record turnRecord) core.Result + Events(sessionID string) core.Result + SaveEvent(record eventRecord) core.Result + Jobs(sessionID string) core.Result + SaveJob(record generationJobRecord) core.Result + InterruptActiveJobs(at time.Time) core.Result + ListWorkItems(includeArchived bool) core.Result + WorkItemByExternalID(externalID string) core.Result + SaveWorkItem(record workItemRecord) core.Result + Artifacts(sessionID string) core.Result + SaveArtifact(record artifactRecord) core.Result + Attachments(sessionID string) core.Result + SaveAttachment(record attachmentRecord) core.Result +} + +type duckRepository struct { + database *workspaceDatabase + agentWriteMu sync.Mutex +} + +type workspaceConnectionProvider interface { + workspaceConnection() *sql.DB + workspaceWriteMutex() *sync.Mutex +} + +func openDuckRepository(path string) core.Result { + opened := openWorkspaceDatabase(path) + if !opened.OK { + return opened + } + database, ok := opened.Value.(*workspaceDatabase) + if !ok { + return core.Fail(core.E("tui.openDuckRepository", "invalid workspace database result", nil)) + } + return core.Ok(&duckRepository{database: database}) +} + +func (repository *duckRepository) Close() core.Result { + if repository == nil || repository.database == nil { + return core.Ok(nil) + } + result := repository.database.Close() + repository.database = nil + return result +} + +func (repository *duckRepository) workspaceConnection() *sql.DB { + if repository == nil || repository.database == nil || repository.database.store == nil { + return nil + } + return repository.database.store.Conn() +} + +func (repository *duckRepository) workspaceWriteMutex() *sync.Mutex { + if repository == nil { + return nil + } + return &repository.agentWriteMu +} + +func (repository *duckRepository) Session(id string) core.Result { + if result := repository.ready("Session"); !result.OK { + return result + } + if core.Trim(id) == "" { + return repositoryFailure("Session", "session ID is required") + } + return orm.Of[sessionRecord](repository.database.runtime).Find(id) +} + +func (repository *duckRepository) ListSessions(includeArchived bool) core.Result { + if result := repository.ready("ListSessions"); !result.OK { + return result + } + query := orm.Of[sessionRecord](repository.database.runtime) + if !includeArchived { + query = query.Where("archived", "=", false) + } + return query.Order("last_opened_at", "desc").Get() +} + +func (repository *duckRepository) SearchSessions(query string, limit int) core.Result { + if result := repository.ready("SearchSessions"); !result.OK { + return result + } + query = core.Trim(query) + if query == "" { + return core.Ok([]sessionSearchHit{}) + } + if limit < 1 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + pattern := core.Concat("%", query, "%") + rows, err := repository.database.store.Conn().Query(` + WITH ranked_turn_matches AS ( + SELECT + session_id, + visible, + ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY sequence) AS match_rank + FROM lem_turns + WHERE LOWER(visible) LIKE LOWER(?) + ) + SELECT + s.id, + s.title, + s.status, + s.preferred_model, + s.mode, + s.generation_json, + s.tools_json, + s.created_at, + s.updated_at, + s.last_opened_at, + s.archived, + s.archived_at, + COALESCE(matches.visible, s.title) AS snippet + FROM lem_sessions AS s + LEFT JOIN ranked_turn_matches AS matches + ON matches.session_id = s.id AND matches.match_rank = 1 + WHERE s.archived = FALSE + AND (LOWER(s.title) LIKE LOWER(?) OR matches.session_id IS NOT NULL) + ORDER BY s.last_opened_at DESC + LIMIT ? + `, pattern, pattern, limit) + if err != nil { + return core.Fail(core.E("tui.duckRepository.SearchSessions", "query sessions", err)) + } + defer func() { + if closeErr := rows.Close(); closeErr != nil { + core.Warn("tui.repository.search_rows_close", "error", closeErr) + } + }() + + hits := make([]sessionSearchHit, 0) + for rows.Next() { + var hit sessionSearchHit + if err := rows.Scan( + &hit.Session.ID, + &hit.Session.Title, + &hit.Session.Status, + &hit.Session.PreferredModel, + &hit.Session.Mode, + &hit.Session.GenerationJSON, + &hit.Session.ToolsJSON, + &hit.Session.CreatedAt, + &hit.Session.UpdatedAt, + &hit.Session.LastOpenedAt, + &hit.Session.Archived, + &hit.Session.ArchivedAt, + &hit.Snippet, + ); err != nil { + return core.Fail(core.E("tui.duckRepository.SearchSessions", "scan session", err)) + } + hits = append(hits, hit) + } + if err := rows.Err(); err != nil { + return core.Fail(core.E("tui.duckRepository.SearchSessions", "iterate sessions", err)) + } + return core.Ok(hits) +} + +func (repository *duckRepository) SaveSession(record sessionRecord) core.Result { + if result := repository.ready("SaveSession"); !result.OK { + return result + } + if record.ArchivedAt.IsZero() { + record.ArchivedAt = unsetRecordTime() + } + return orm.Of[sessionRecord](repository.database.runtime).Save(&record) +} + +func (repository *duckRepository) Turns(sessionID string) core.Result { + if result := repository.ready("Turns"); !result.OK { + return result + } + if core.Trim(sessionID) == "" { + return repositoryFailure("Turns", "session ID is required") + } + return orm.Of[turnRecord](repository.database.runtime). + Where("session_id", "=", sessionID). + Order("sequence", "asc"). + Get() +} + +func (repository *duckRepository) SaveTurn(record turnRecord) core.Result { + if result := repository.ready("SaveTurn"); !result.OK { + return result + } + return orm.Of[turnRecord](repository.database.runtime).Save(&record) +} + +func (repository *duckRepository) Events(sessionID string) core.Result { + if result := repository.ready("Events"); !result.OK { + return result + } + if core.Trim(sessionID) == "" { + return repositoryFailure("Events", "session ID is required") + } + return orm.Of[eventRecord](repository.database.runtime). + Where("session_id", "=", sessionID). + Order("created_at", "asc"). + Get() +} + +func (repository *duckRepository) SaveEvent(record eventRecord) core.Result { + if result := repository.ready("SaveEvent"); !result.OK { + return result + } + return orm.Of[eventRecord](repository.database.runtime).Save(&record) +} + +func (repository *duckRepository) Jobs(sessionID string) core.Result { + if result := repository.ready("Jobs"); !result.OK { + return result + } + if core.Trim(sessionID) == "" { + return repositoryFailure("Jobs", "session ID is required") + } + return orm.Of[generationJobRecord](repository.database.runtime). + Where("session_id", "=", sessionID). + Order("created_at", "asc"). + Get() +} + +func (repository *duckRepository) SaveJob(record generationJobRecord) core.Result { + if result := repository.ready("SaveJob"); !result.OK { + return result + } + if record.StartedAt.IsZero() { + record.StartedAt = unsetRecordTime() + } + if record.FinishedAt.IsZero() { + record.FinishedAt = unsetRecordTime() + } + return orm.Of[generationJobRecord](repository.database.runtime).Save(&record) +} + +func (repository *duckRepository) InterruptActiveJobs(at time.Time) core.Result { + if result := repository.ready("InterruptActiveJobs"); !result.OK { + return result + } + type interruptedJob struct { + ID string + SessionID string + Partial string + } + rows, err := repository.database.store.Conn().Query(` + SELECT j.id, j.session_id, COALESCE(t.visible, '') + FROM lem_generation_jobs AS j + LEFT JOIN lem_turns AS t ON t.id = j.answer_turn_id + WHERE j.status IN (?, ?) + ORDER BY j.created_at + `, "queued", "generating") + if err != nil { + return core.Fail(core.E("tui.repository.InterruptActiveJobs", "read active jobs", err)) + } + jobs := make([]interruptedJob, 0) + for rows.Next() { + var job interruptedJob + if err := rows.Scan(&job.ID, &job.SessionID, &job.Partial); err != nil { + if closeErr := rows.Close(); closeErr != nil { + core.Warn("tui.repository.recovery_rows_close", "error", closeErr) + } + return core.Fail(core.E("tui.repository.InterruptActiveJobs", "scan active job", err)) + } + jobs = append(jobs, job) + } + if err := rows.Err(); err != nil { + if closeErr := rows.Close(); closeErr != nil { + core.Warn("tui.repository.recovery_rows_close", "error", closeErr) + } + return core.Fail(core.E("tui.repository.InterruptActiveJobs", "iterate active jobs", err)) + } + if err := rows.Close(); err != nil { + return core.Fail(core.E("tui.repository.InterruptActiveJobs", "close active jobs", err)) + } + if len(jobs) == 0 { + return core.Ok(nil) + } + + transaction, err := repository.database.store.Conn().Begin() + if err != nil { + return core.Fail(core.E("tui.repository.InterruptActiveJobs", "begin recovery", err)) + } + rollback := func(operation string, cause error) core.Result { + rollbackWorkspaceMigration(transaction) + return core.Fail(core.E("tui.repository.InterruptActiveJobs", operation, cause)) + } + if _, err := transaction.Exec(` + UPDATE lem_generation_jobs + SET status = ?, error = ?, finished_at = ? + WHERE status IN (?, ?) + `, "interrupted", "application exited before generation completed", at.UTC(), "queued", "generating"); err != nil { + return rollback("interrupt active jobs", err) + } + + sessions := make(map[string]struct{}, len(jobs)) + for _, job := range jobs { + if _, seen := sessions[job.SessionID]; !seen { + if _, err := transaction.Exec(` + UPDATE lem_sessions SET status = ?, updated_at = ? WHERE id = ? + `, "interrupted", at.UTC(), job.SessionID); err != nil { + return rollback("interrupt active session", err) + } + sessions[job.SessionID] = struct{}{} + } + detail := "Generation stopped before producing output." + if core.Trim(job.Partial) != "" { + detail = "Partial output was preserved in the transcript." + } + if _, err := transaction.Exec(` + INSERT INTO lem_events + (id, session_id, work_item_id, job_id, kind, status, title, detail, payload_json, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, newRecordID(), job.SessionID, "", job.ID, "generation.interrupted", "interrupted", + "Generation interrupted", detail, "{}", at.UTC()); err != nil { + return rollback("record interrupted generation", err) + } + } + if err := transaction.Commit(); err != nil { + return rollback("commit recovery", err) + } + return core.Ok(len(jobs)) +} + +func (repository *duckRepository) ListWorkItems(includeArchived bool) core.Result { + if result := repository.ready("ListWorkItems"); !result.OK { + return result + } + query := orm.Of[workItemRecord](repository.database.runtime) + if !includeArchived { + query = query.Where("archived", "=", false) + } + return query.Order("updated_at", "desc").Get() +} + +func (repository *duckRepository) WorkItemByExternalID(externalID string) core.Result { + if result := repository.ready("WorkItemByExternalID"); !result.OK { + return result + } + if core.Trim(externalID) == "" { + return repositoryFailure("WorkItemByExternalID", "external ID is required") + } + return orm.Of[workItemRecord](repository.database.runtime). + Where("external_id", "=", externalID). + First() +} + +func (repository *duckRepository) SaveWorkItem(record workItemRecord) core.Result { + if result := repository.ready("SaveWorkItem"); !result.OK { + return result + } + if record.ArchivedAt.IsZero() { + record.ArchivedAt = unsetRecordTime() + } + return orm.Of[workItemRecord](repository.database.runtime).Save(&record) +} + +func (repository *duckRepository) Artifacts(sessionID string) core.Result { + if result := repository.ready("Artifacts"); !result.OK { + return result + } + if core.Trim(sessionID) == "" { + return repositoryFailure("Artifacts", "session ID is required") + } + return orm.Of[artifactRecord](repository.database.runtime). + Where("session_id", "=", sessionID). + Where("archived", "=", false). + Order("created_at", "asc"). + Get() +} + +func (repository *duckRepository) SaveArtifact(record artifactRecord) core.Result { + if result := repository.ready("SaveArtifact"); !result.OK { + return result + } + if record.ArchivedAt.IsZero() { + record.ArchivedAt = unsetRecordTime() + } + return orm.Of[artifactRecord](repository.database.runtime).Save(&record) +} + +func (repository *duckRepository) Attachments(sessionID string) core.Result { + if result := repository.ready("Attachments"); !result.OK { + return result + } + if core.Trim(sessionID) == "" { + return repositoryFailure("Attachments", "session ID is required") + } + return orm.Of[attachmentRecord](repository.database.runtime). + Where("session_id", "=", sessionID). + Where("archived", "=", false). + Order("added_at", "asc"). + Get() +} + +func (repository *duckRepository) SaveAttachment(record attachmentRecord) core.Result { + if result := repository.ready("SaveAttachment"); !result.OK { + return result + } + if record.ArchivedAt.IsZero() { + record.ArchivedAt = unsetRecordTime() + } + return orm.Of[attachmentRecord](repository.database.runtime).Save(&record) +} + +func (repository *duckRepository) ready(operation string) core.Result { + if repository == nil || repository.database == nil || repository.database.runtime == nil || repository.database.store == nil { + return repositoryFailure(operation, "repository is closed") + } + return core.Ok(nil) +} + +func repositoryFailure(operation, message string) core.Result { + return core.Fail(core.E(core.Concat("tui.duckRepository.", operation), message, nil)) +} diff --git a/cli/tui/repository_test.go b/cli/tui/repository_test.go new file mode 100644 index 000000000..c88b056ed --- /dev/null +++ b/cli/tui/repository_test.go @@ -0,0 +1,425 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package tui + +import ( + "strings" + "testing" + "time" + + core "dappco.re/go" +) + +func TestDuckRepository_Good(t *testing.T) { + path := t.TempDir() + "/lem.duckdb" + opened := openDuckRepository(path) + if !opened.OK { + t.Fatalf("openDuckRepository(%q) failed: %v", path, opened.Value) + } + repository, ok := opened.Value.(workspaceRepository) + if !ok { + t.Fatalf("openDuckRepository value = %T, want workspaceRepository", opened.Value) + } + + createdAt := time.Date(2026, time.July, 17, 10, 0, 0, 0, time.UTC) + session := testSessionRecord("session-good", "Persistent workspace", createdAt) + userTurn := testTurnRecord("turn-user", session.ID, 1, "user", "Remember this session", createdAt.Add(time.Minute)) + assistantTurn := testTurnRecord("turn-assistant", session.ID, 2, "assistant", "It will survive a restart.", createdAt.Add(2*time.Minute)) + event := eventRecord{ + ID: "event-good", + SessionID: session.ID, + Kind: "generation", + Status: "completed", + Title: "Answer completed", + Detail: "The response was persisted.", + PayloadJSON: "{}", + CreatedAt: createdAt.Add(3 * time.Minute), + } + job := generationJobRecord{ + ID: "job-good", + SessionID: session.ID, + PromptTurnID: userTurn.ID, + AnswerTurnID: assistantTurn.ID, + Status: "completed", + Model: "fixture-model", + MetricsJSON: "{}", + CreatedAt: createdAt.Add(time.Minute), + StartedAt: createdAt.Add(time.Minute), + FinishedAt: createdAt.Add(2 * time.Minute), + } + work := workItemRecord{ + ID: "work-good", + ExternalID: "forge-42", + Source: "forge", + Title: "Persist the workspace", + Status: "completed", + SessionID: session.ID, + StartedAt: createdAt, + UpdatedAt: createdAt.Add(4 * time.Minute), + ArchivedAt: unsetRecordTime(), + } + artifact := artifactRecord{ + ID: "artifact-good", + SessionID: session.ID, + WorkItemID: work.ID, + Kind: "patch", + Path: "workspaces/session-good/change.patch", + Title: "Workspace patch", + MetadataJSON: "{}", + CreatedAt: createdAt.Add(4 * time.Minute), + ArchivedAt: unsetRecordTime(), + } + attachment := attachmentRecord{ + ID: "attachment-good", + SessionID: session.ID, + SourcePath: "/tmp/context.md", + Title: "Context", + ContentHash: "sha256:fixture", + Snapshot: "A durable local snapshot.", + AddedAt: createdAt, + LastCheckedAt: createdAt.Add(4 * time.Minute), + ArchivedAt: unsetRecordTime(), + } + + for _, save := range []struct { + label string + call func() core.Result + }{ + {label: "session", call: func() core.Result { return repository.SaveSession(session) }}, + {label: "user turn", call: func() core.Result { return repository.SaveTurn(userTurn) }}, + {label: "answer turn", call: func() core.Result { return repository.SaveTurn(assistantTurn) }}, + {label: "event", call: func() core.Result { return repository.SaveEvent(event) }}, + {label: "job", call: func() core.Result { return repository.SaveJob(job) }}, + {label: "work item", call: func() core.Result { return repository.SaveWorkItem(work) }}, + {label: "artifact", call: func() core.Result { return repository.SaveArtifact(artifact) }}, + {label: "attachment", call: func() core.Result { return repository.SaveAttachment(attachment) }}, + } { + if result := save.call(); !result.OK { + t.Fatalf("save %s failed: %v", save.label, result.Value) + } + } + if result := repository.Close(); !result.OK { + t.Fatalf("close first repository: %v", result.Value) + } + + reopened := openDuckRepository(path) + if !reopened.OK { + t.Fatalf("reopen repository: %v", reopened.Value) + } + repository, ok = reopened.Value.(workspaceRepository) + if !ok { + t.Fatalf("reopened value = %T, want workspaceRepository", reopened.Value) + } + defer func() { + if result := repository.Close(); !result.OK { + t.Errorf("close reopened repository: %v", result.Value) + } + }() + + sessionResult := repository.Session(session.ID) + if !sessionResult.OK { + t.Fatalf("read session: %v", sessionResult.Value) + } + gotSession, ok := sessionResult.Value.(sessionRecord) + if !ok { + t.Fatalf("Session value = %T, want sessionRecord", sessionResult.Value) + } + if gotSession.ID != session.ID || gotSession.Title != session.Title || !gotSession.CreatedAt.Equal(session.CreatedAt) { + t.Fatalf("session round trip = %#v, want core fields from %#v", gotSession, session) + } + + turnsResult := repository.Turns(session.ID) + if !turnsResult.OK { + t.Fatalf("read turns: %v", turnsResult.Value) + } + turns, ok := turnsResult.Value.([]turnRecord) + if !ok || len(turns) != 2 { + t.Fatalf("Turns value = %#v (%T), want two []turnRecord", turnsResult.Value, turnsResult.Value) + } + if turns[0].ID != userTurn.ID || turns[1].ID != assistantTurn.ID { + t.Fatalf("turn order = %q, %q, want %q, %q", turns[0].ID, turns[1].ID, userTurn.ID, assistantTurn.ID) + } + + assertRecordSliceLength[eventRecord](t, "Events", repository.Events(session.ID), 1) + assertRecordSliceLength[generationJobRecord](t, "Jobs", repository.Jobs(session.ID), 1) + assertRecordSliceLength[workItemRecord](t, "ListWorkItems", repository.ListWorkItems(false), 1) + assertRecordSliceLength[artifactRecord](t, "Artifacts", repository.Artifacts(session.ID), 1) + assertRecordSliceLength[attachmentRecord](t, "Attachments", repository.Attachments(session.ID), 1) + + workResult := repository.WorkItemByExternalID(work.ExternalID) + if !workResult.OK { + t.Fatalf("read work item by external ID: %v", workResult.Value) + } + gotWork, ok := workResult.Value.(workItemRecord) + if !ok || gotWork.ID != work.ID { + t.Fatalf("WorkItemByExternalID value = %#v (%T), want ID %q", workResult.Value, workResult.Value, work.ID) + } +} + +func TestDuckRepository_Bad(t *testing.T) { + repository := openTestDuckRepository(t) + defer closeTestDuckRepository(t, repository) + + createdAt := time.Date(2026, time.July, 17, 11, 0, 0, 0, time.UTC) + session := testSessionRecord("session-duplicate", "Duplicate sequence", createdAt) + if result := repository.SaveSession(session); !result.OK { + t.Fatalf("save session: %v", result.Value) + } + first := testTurnRecord("turn-first", session.ID, 1, "user", "First", createdAt) + duplicate := testTurnRecord("turn-duplicate", session.ID, 1, "assistant", "Must fail", createdAt.Add(time.Second)) + if result := repository.SaveTurn(first); !result.OK { + t.Fatalf("save first turn: %v", result.Value) + } + if result := repository.SaveTurn(duplicate); result.OK { + t.Fatalf("SaveTurn duplicate sequence = %#v, want failure", result.Value) + } + + result := repository.Turns(session.ID) + if !result.OK { + t.Fatalf("read turns after duplicate: %v", result.Value) + } + turns, ok := result.Value.([]turnRecord) + if !ok || len(turns) != 1 || turns[0].ID != first.ID || turns[0].Visible != first.Visible { + t.Fatalf("turns after duplicate = %#v (%T), want only first turn", result.Value, result.Value) + } +} + +func TestDuckRepository_Ugly(t *testing.T) { + repository := openTestDuckRepository(t) + defer closeTestDuckRepository(t, repository) + + createdAt := time.Date(2026, time.July, 17, 12, 0, 0, 0, time.UTC) + session := testSessionRecord("session-recovery", "Recovery", createdAt) + session.Status = "generating" + if result := repository.SaveSession(session); !result.OK { + t.Fatalf("save session: %v", result.Value) + } + partial := testTurnRecord("turn-partial", session.ID, 1, "assistant", "partial answer", createdAt) + if result := repository.SaveTurn(partial); !result.OK { + t.Fatalf("save partial turn: %v", result.Value) + } + jobs := []generationJobRecord{ + testJobRecord("job-queued", session.ID, "queued", createdAt, unsetRecordTime()), + testJobRecord("job-generating", session.ID, "generating", createdAt.Add(time.Second), unsetRecordTime()), + testJobRecord("job-completed", session.ID, "completed", createdAt.Add(2*time.Second), createdAt.Add(3*time.Second)), + } + jobs[1].AnswerTurnID = partial.ID + for _, job := range jobs { + if result := repository.SaveJob(job); !result.OK { + t.Fatalf("save %s: %v", job.ID, result.Value) + } + } + + interruptedAt := createdAt.Add(time.Hour) + if result := repository.InterruptActiveJobs(interruptedAt); !result.OK { + t.Fatalf("InterruptActiveJobs: %v", result.Value) + } + result := repository.Jobs(session.ID) + if !result.OK { + t.Fatalf("read recovered jobs: %v", result.Value) + } + got, ok := result.Value.([]generationJobRecord) + if !ok || len(got) != 3 { + t.Fatalf("Jobs value = %#v (%T), want three jobs", result.Value, result.Value) + } + byID := make(map[string]generationJobRecord, len(got)) + for _, job := range got { + byID[job.ID] = job + } + for _, id := range []string{"job-queued", "job-generating"} { + if byID[id].Status != "interrupted" || !byID[id].FinishedAt.Equal(interruptedAt) { + t.Errorf("%s after recovery = %#v, want interrupted at %v", id, byID[id], interruptedAt) + } + } + if completed := byID["job-completed"]; completed.Status != "completed" || !completed.FinishedAt.Equal(jobs[2].FinishedAt) { + t.Errorf("completed job changed to %#v", completed) + } + recoveredSession := repository.Session(session.ID) + if !recoveredSession.OK || recoveredSession.Value.(sessionRecord).Status != "interrupted" { + t.Fatalf("session after recovery = %#v (%s)", recoveredSession.Value, recoveredSession.Error()) + } + eventResult := repository.Events(session.ID) + events, ok := eventResult.Value.([]eventRecord) + if !eventResult.OK || !ok || len(events) != 2 { + t.Fatalf("recovery events = %#v (%s)", eventResult.Value, eventResult.Error()) + } + preserved := false + for _, event := range events { + if event.Kind != "generation.interrupted" || event.Status != "interrupted" { + t.Errorf("recovery event = %#v", event) + } + preserved = preserved || strings.Contains(event.Detail, "Partial output") + } + if !preserved { + t.Fatal("recovery events did not report the preserved partial output") + } +} + +func TestDuckRepository_AgentConnection_Good(t *testing.T) { + if result := newDuckAgentStore(nil); result.OK { + t.Fatal("newDuckAgentStore accepted nil repository") + } + repository := openTestDuckRepository(t) + provider, ok := repository.(workspaceConnectionProvider) + if !ok || provider.workspaceConnection() == nil { + t.Fatalf("workspace repository connection provider = %#v, want open SQL connection", provider) + } + withoutConnection := struct{ workspaceRepository }{workspaceRepository: repository} + if result := newDuckAgentStore(withoutConnection); result.OK { + t.Fatal("newDuckAgentStore accepted repository without connection capability") + } + if result := repository.Close(); !result.OK { + t.Fatalf("close repository: %v", result.Value) + } + if provider.workspaceConnection() != nil { + t.Fatal("closed workspace repository still exposes SQL connection") + } + if result := newDuckAgentStore(repository); result.OK { + t.Fatal("newDuckAgentStore accepted closed repository") + } +} + +func TestDuckRepository_SearchAndArchive_Good(t *testing.T) { + repository := openTestDuckRepository(t) + defer closeTestDuckRepository(t, repository) + + createdAt := time.Date(2026, time.July, 17, 13, 0, 0, 0, time.UTC) + titleMatch := testSessionRecord("session-title-match", "Database cleanup", createdAt) + turnMatch := testSessionRecord("session-turn-match", "Query planning", createdAt.Add(time.Minute)) + for _, session := range []sessionRecord{titleMatch, turnMatch} { + if result := repository.SaveSession(session); !result.OK { + t.Fatalf("save %s: %v", session.ID, result.Value) + } + } + if result := repository.SaveTurn(testTurnRecord( + "turn-search", turnMatch.ID, 1, "user", "Please tune this DUCKDB query", createdAt.Add(2*time.Minute), + )); !result.OK { + t.Fatalf("save search turn: %v", result.Value) + } + + titleResult := repository.SearchSessions("DATABASE", 10) + if !titleResult.OK { + t.Fatalf("search title case-insensitively: %v", titleResult.Value) + } + titleHits, ok := titleResult.Value.([]sessionSearchHit) + if !ok || len(titleHits) != 1 || titleHits[0].Session.ID != titleMatch.ID { + t.Fatalf("title search hits = %#v (%T), want %q", titleResult.Value, titleResult.Value, titleMatch.ID) + } + if !strings.Contains(strings.ToLower(titleHits[0].Snippet), "database") { + t.Fatalf("title snippet = %q, want useful matching text", titleHits[0].Snippet) + } + + turnResult := repository.SearchSessions("duckdb", 10) + if !turnResult.OK { + t.Fatalf("search turn case-insensitively: %v", turnResult.Value) + } + turnHits, ok := turnResult.Value.([]sessionSearchHit) + if !ok || len(turnHits) != 1 || turnHits[0].Session.ID != turnMatch.ID { + t.Fatalf("turn search hits = %#v (%T), want %q", turnResult.Value, turnResult.Value, turnMatch.ID) + } + if !strings.Contains(strings.ToLower(turnHits[0].Snippet), "duckdb") { + t.Fatalf("turn snippet = %q, want matching turn content", turnHits[0].Snippet) + } + + turnMatch.Archived = true + turnMatch.ArchivedAt = createdAt.Add(3 * time.Minute) + if result := repository.SaveSession(turnMatch); !result.OK { + t.Fatalf("archive session: %v", result.Value) + } + visibleResult := repository.ListSessions(false) + if !visibleResult.OK { + t.Fatalf("list visible sessions: %v", visibleResult.Value) + } + visible, ok := visibleResult.Value.([]sessionRecord) + if !ok || len(visible) != 1 || visible[0].ID != titleMatch.ID { + t.Fatalf("visible sessions = %#v (%T), want only %q", visibleResult.Value, visibleResult.Value, titleMatch.ID) + } + archivedSearch := repository.SearchSessions("duckdb", 10) + if !archivedSearch.OK { + t.Fatalf("search after archive: %v", archivedSearch.Value) + } + if hits, ok := archivedSearch.Value.([]sessionSearchHit); !ok || len(hits) != 0 { + t.Fatalf("archived search hits = %#v (%T), want empty", archivedSearch.Value, archivedSearch.Value) + } + allResult := repository.ListSessions(true) + if !allResult.OK { + t.Fatalf("list sessions including archived: %v", allResult.Value) + } + all, ok := allResult.Value.([]sessionRecord) + if !ok || len(all) != 2 { + t.Fatalf("all sessions = %#v (%T), want two", allResult.Value, allResult.Value) + } +} + +func openTestDuckRepository(t *testing.T) workspaceRepository { + t.Helper() + result := openDuckRepository(t.TempDir() + "/lem.duckdb") + if !result.OK { + t.Fatalf("open test repository: %v", result.Value) + } + repository, ok := result.Value.(workspaceRepository) + if !ok { + t.Fatalf("openDuckRepository value = %T, want workspaceRepository", result.Value) + } + return repository +} + +func closeTestDuckRepository(t *testing.T, repository workspaceRepository) { + t.Helper() + if result := repository.Close(); !result.OK { + t.Errorf("close test repository: %v", result.Value) + } +} + +func testSessionRecord(id, title string, createdAt time.Time) sessionRecord { + return sessionRecord{ + ID: id, + Title: title, + Status: "ready", + Mode: "chat", + GenerationJSON: "{}", + ToolsJSON: "[]", + CreatedAt: createdAt, + UpdatedAt: createdAt, + LastOpenedAt: createdAt, + ArchivedAt: unsetRecordTime(), + } +} + +func testTurnRecord(id, sessionID string, sequence int64, role, visible string, createdAt time.Time) turnRecord { + return turnRecord{ + ID: id, + SessionID: sessionID, + Sequence: sequence, + Role: role, + Visible: visible, + ToolCallJSON: "{}", + ToolResultJSON: "{}", + CreatedAt: createdAt, + UpdatedAt: createdAt, + } +} + +func testJobRecord(id, sessionID, status string, createdAt, finishedAt time.Time) generationJobRecord { + return generationJobRecord{ + ID: id, + SessionID: sessionID, + Status: status, + Model: "fixture-model", + MetricsJSON: "{}", + CreatedAt: createdAt, + StartedAt: unsetRecordTime(), + FinishedAt: finishedAt, + } +} + +func assertRecordSliceLength[T any](t *testing.T, label string, result core.Result, want int) { + t.Helper() + if !result.OK { + t.Fatalf("%s failed: %v", label, result.Value) + } + records, ok := result.Value.([]T) + if !ok || len(records) != want { + t.Fatalf("%s value = %#v (%T), want %d records", label, result.Value, result.Value, want) + } +} diff --git a/cli/tui/runtime.go b/cli/tui/runtime.go new file mode 100644 index 000000000..6b3fb9d6e --- /dev/null +++ b/cli/tui/runtime.go @@ -0,0 +1,153 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package tui + +import ( + container "dappco.re/go/container" + + core "dappco.re/go" +) + +type runtimeCapability struct { + Name string + Version string + Path string + GPU bool + NetworkIsolation bool + VolumeMounts bool + Encryption bool + HardwareIsolation bool + SubSecondStart bool +} + +type runtimeDetector interface { + Detect() core.Result +} + +type runtimeProbe interface { + RuntimeName() string + RuntimeVersion() string + RuntimePath() string + HasGPU() bool + HasNetworkIsolation() bool + HasVolumeMounts() bool + HasEncryption() bool + IsHardwareIsolated() bool + HasSubSecondStart() bool +} + +type runtimeSource interface { + All() core.Result +} + +type runtimeAdapter struct{ source runtimeSource } + +func newRuntimeAdapter(source runtimeSource) runtimeDetector { + return &runtimeAdapter{source: source} +} + +func newContainerRuntimeDetector() runtimeDetector { + return newRuntimeAdapter(containerRuntimeSource{}) +} + +func (adapter *runtimeAdapter) Detect() core.Result { + if adapter == nil || adapter.source == nil { + return core.Fail(core.E("tui.runtimeAdapter.Detect", "runtime source is unavailable", nil)) + } + result := adapter.source.All() + if !result.OK { + return result + } + probes, ok := result.Value.([]runtimeProbe) + if !ok { + return core.Fail(core.E("tui.runtimeAdapter.Detect", "invalid runtime probe result", nil)) + } + capabilities := make([]runtimeCapability, 0, len(probes)) + for _, probe := range probes { + if probe == nil { + continue + } + capabilities = append(capabilities, runtimeCapability{ + Name: probe.RuntimeName(), + Version: probe.RuntimeVersion(), + Path: probe.RuntimePath(), + GPU: probe.HasGPU(), + NetworkIsolation: probe.HasNetworkIsolation(), + VolumeMounts: probe.HasVolumeMounts(), + Encryption: probe.HasEncryption(), + HardwareIsolation: probe.IsHardwareIsolated(), + SubSecondStart: probe.HasSubSecondStart(), + }) + } + return core.Ok(capabilities) +} + +type containerRuntimeSource struct{} + +func (containerRuntimeSource) All() core.Result { + detected := container.DetectAll() + probes := make([]runtimeProbe, 0, len(detected)) + for _, runtime := range detected { + probes = append(probes, containerRuntimeProbe{runtime: runtime}) + } + return core.Ok(probes) +} + +type containerRuntimeProbe struct{ runtime container.ContainerRuntime } + +func (probe containerRuntimeProbe) RuntimeName() string { + return string(probe.runtime.Type) +} + +func (probe containerRuntimeProbe) RuntimeVersion() string { + return probe.runtime.Version +} + +func (probe containerRuntimeProbe) RuntimePath() string { + return probe.runtime.Path +} + +func (probe containerRuntimeProbe) HasGPU() bool { + return probe.runtime.HasGPU() +} + +func (probe containerRuntimeProbe) HasNetworkIsolation() bool { + return probe.runtime.HasNetworkIsolation() +} + +func (probe containerRuntimeProbe) HasVolumeMounts() bool { + return probe.runtime.HasVolumeMounts() +} + +func (probe containerRuntimeProbe) HasEncryption() bool { + return probe.runtime.HasEncryption() +} + +func (probe containerRuntimeProbe) IsHardwareIsolated() bool { + return probe.runtime.IsHardwareIsolated() +} + +func (probe containerRuntimeProbe) HasSubSecondStart() bool { + return probe.runtime.HasSubSecondStart() +} + +type runtimeInspection struct { + ready bool + reason string + capabilities []runtimeCapability +} + +func runtimeInspectionFrom(result core.Result) runtimeInspection { + inspection := runtimeInspection{ready: true, capabilities: []runtimeCapability{}} + if !result.OK { + inspection.reason = result.Error() + return inspection + } + capabilities, ok := result.Value.([]runtimeCapability) + if !ok { + inspection.reason = "invalid runtime capability result" + return inspection + } + inspection.capabilities = append(inspection.capabilities, capabilities...) + return inspection +} diff --git a/cli/tui/runtime_test.go b/cli/tui/runtime_test.go new file mode 100644 index 000000000..aa3893ba7 --- /dev/null +++ b/cli/tui/runtime_test.go @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package tui + +import ( + "strings" + "testing" + + core "dappco.re/go" +) + +func TestRuntimeAdapter_Good(t *testing.T) { + source := fixtureRuntimeSource{result: core.Ok([]runtimeProbe{ + fixtureRuntime{name: "apple", version: "1.2.0", path: "/usr/local/bin/container", network: true, volumes: true, hardware: true, subSecond: true}, + fixtureRuntime{name: "vz", version: "native", path: "Virtualization.framework", gpu: true, network: true, volumes: true, encryption: true, hardware: true}, + fixtureRuntime{name: "docker", version: "28.1", path: "/usr/local/bin/docker", gpu: true, network: true, volumes: true}, + fixtureRuntime{name: "podman", version: "5.5", path: "/opt/homebrew/bin/podman", network: true, volumes: true}, + })} + result := newRuntimeAdapter(source).Detect() + if !result.OK { + t.Fatalf("Detect: %v", result.Value) + } + capabilities, ok := result.Value.([]runtimeCapability) + if !ok || len(capabilities) != 4 { + t.Fatalf("Detect = %#v (%T)", result.Value, result.Value) + } + if capabilities[0].Name != "apple" || capabilities[1].Name != "vz" || capabilities[2].Name != "docker" || capabilities[3].Name != "podman" { + t.Fatalf("priority order = %#v", capabilities) + } + apple := capabilities[0] + if apple.GPU || !apple.NetworkIsolation || !apple.VolumeMounts || apple.Encryption || !apple.HardwareIsolation || !apple.SubSecondStart { + t.Fatalf("Apple projection = %#v", apple) + } + vz := capabilities[1] + if !vz.GPU || !vz.Encryption || !vz.HardwareIsolation || vz.SubSecondStart { + t.Fatalf("VZ projection = %#v", vz) + } +} + +func TestRuntimeAdapter_Bad(t *testing.T) { + reason := "runtime probe permission denied" + result := newRuntimeAdapter(fixtureRuntimeSource{result: core.Fail(core.E("test.runtime", reason, nil))}).Detect() + if result.OK { + t.Fatal("Detect unexpectedly succeeded") + } + a := newApp("", 0, 64) + a.activePanel = panelWork + a.inspector.ApplyRuntime(result) + view := a.inspector.View(a, 72, 24) + if !strings.Contains(view, "RUNTIME") || !strings.Contains(view, "unavailable") || !strings.Contains(view, reason) { + t.Fatalf("disabled runtime inspector:\n%s", view) + } +} + +func TestRuntimeAdapter_Ugly(t *testing.T) { + result := newRuntimeAdapter(fixtureRuntimeSource{result: core.Ok([]runtimeProbe{})}).Detect() + if !result.OK { + t.Fatalf("Detect empty: %v", result.Value) + } + a := newApp("", 0, 64) + a.activePanel = panelWork + a.inspector.ApplyRuntime(result) + view := a.inspector.View(a, 48, 20) + if !strings.Contains(view, "RUNTIME") || !strings.Contains(view, "none available") { + t.Fatalf("empty runtime inspector:\n%s", view) + } +} + +type fixtureRuntimeSource struct{ result core.Result } + +func (source fixtureRuntimeSource) All() core.Result { return source.result } + +type fixtureRuntime struct { + name string + version string + path string + gpu bool + network bool + volumes bool + encryption bool + hardware bool + subSecond bool +} + +func (runtime fixtureRuntime) RuntimeName() string { return runtime.name } +func (runtime fixtureRuntime) RuntimeVersion() string { return runtime.version } +func (runtime fixtureRuntime) RuntimePath() string { return runtime.path } +func (runtime fixtureRuntime) HasGPU() bool { return runtime.gpu } +func (runtime fixtureRuntime) HasNetworkIsolation() bool { return runtime.network } +func (runtime fixtureRuntime) HasVolumeMounts() bool { return runtime.volumes } +func (runtime fixtureRuntime) HasEncryption() bool { return runtime.encryption } +func (runtime fixtureRuntime) IsHardwareIsolated() bool { return runtime.hardware } +func (runtime fixtureRuntime) HasSubSecondStart() bool { return runtime.subSecond } diff --git a/cli/tui/service.go b/cli/tui/service.go index 112784204..0ac1e8714 100644 --- a/cli/tui/service.go +++ b/cli/tui/service.go @@ -4,7 +4,6 @@ package tui import ( "context" - "strings" "sync/atomic" "time" @@ -13,14 +12,13 @@ import ( core "dappco.re/go" "dappco.re/go/inference" "dappco.re/go/inference/serving" - "dappco.re/go/inference/serving/scheduler" ) // The Service tab hosts the OpenAI/Anthropic/Ollama-compatible HTTP API for // the model already loaded in the TUI — the same weights, not a second load. -// The model is wrapped in a serial scheduler (one lane) shared by TUI chat and -// HTTP requests, so a coding agent hitting the API and a turn typed in Chat -// queue behind each other instead of racing the engine. +// The application supplies its already-scheduled model lane, so a coding agent +// hitting the API and a turn typed in Chat queue behind each other instead of +// racing the engine. The service owns only its listener and context. // serviceAddrs are the listen presets cycled with ←/→ while stopped. var serviceAddrs = []struct{ addr, hint string }{ @@ -37,7 +35,6 @@ type serviceState struct { custom string // overrides the preset when non-empty (tests, future flag) requests *atomic.Int64 - sched *scheduler.Model // the serial lane; TUI chat routes through it while running cancel context.CancelFunc events chan serviceEvent note string // status / error line for the tab @@ -65,16 +62,16 @@ func (s serviceState) addr() string { // address, which only the operator knows. func (s serviceState) baseURL() string { addr := s.addr() - if strings.HasPrefix(addr, ":") { + if core.HasPrefix(addr, ":") { return "http://localhost" + addr } - if after, ok := strings.CutPrefix(addr, "0.0.0.0"); ok { + if after, ok := core.CutPrefix(addr, "0.0.0.0"); ok { return "http://" + after } return "http://" + addr } -// serviceResolver answers every model name with the one scheduled model — the +// serviceResolver answers every model name with the supplied model lane — the // request's `model` field is cosmetic, exactly like `lem serve`. The counter // is the tab's requests-served receipt. type serviceResolver struct { @@ -87,8 +84,8 @@ func (r serviceResolver) ResolveModel(context.Context, string) (inference.TextMo return r.model, nil } -// start wraps model in the serial lane and boots the HTTP listener on its own -// goroutine. Listener failures (port in use) arrive as a serviceMsg. +// start boots the HTTP listener on its own goroutine. Listener failures (port +// in use) arrive as a serviceMsg. The model's lifecycle remains with app. func (s *serviceState) start(model inference.TextModel) tea.Cmd { if s.running { return nil @@ -97,26 +94,15 @@ func (s *serviceState) start(model inference.TextModel) tea.Cmd { s.note = "load a model first — pick one in Models" return nil } - sched, err := scheduler.New(model, scheduler.Config{ - Mode: scheduler.ModeSerial, - MaxConcurrent: 1, // one GPU, one lane: TUI turns and API requests queue - MaxQueue: 64, - StreamBuffer: 16, - RequestIDPrefix: "tui", - }) - if err != nil { - s.note = err.Error() - return nil - } ctx, cancel := context.WithCancel(context.Background()) events := make(chan serviceEvent, 1) s.requests.Store(0) - resolver := serviceResolver{model: sched, n: s.requests} + resolver := serviceResolver{model: model, n: s.requests} addr := s.addr() go func() { events <- serviceEvent{err: serving.Serve(ctx, addr, resolver)} }() - s.sched, s.cancel, s.events = sched, cancel, events + s.cancel, s.events = cancel, events s.running, s.stopping, s.note = true, false, "" return tea.Batch(waitService(events), serviceTick()) } @@ -133,10 +119,6 @@ func (s *serviceState) stop() { // finish folds Serve's return into the tab — idempotent with teardown, so a // late serviceMsg after a synchronous teardown is absorbed cleanly. func (s *serviceState) finish(err error) { - if s.sched != nil { - s.sched.CloseEngine() - s.sched = nil - } s.cancel = nil s.running, s.stopping = false, false if err != nil { @@ -155,10 +137,7 @@ func (s *serviceState) teardown(reason string) { if s.cancel != nil { s.cancel() } - if s.sched != nil { - s.sched.CloseEngine() - s.sched = nil - } + s.cancel = nil s.running, s.stopping = false, false s.note = reason } @@ -173,53 +152,53 @@ func serviceTick() tea.Cmd { return tea.Tick(time.Second, func(time.Time) tea.Msg { return serviceTickMsg{} }) } -func (s serviceState) view(modelName string, width int) string { - var b strings.Builder - b.WriteString(styleTitle.Render("service") + " " + - styleThought.Render("OpenAI · Anthropic · Ollama HTTP API for the loaded model") + "\n\n") +func (s serviceState) view(modelName string, width int, styles uiStyles) string { + var b core.Builder + b.WriteString(styles.title.Render("service") + " " + + styles.thought.Render("OpenAI · Anthropic · Ollama HTTP API for the loaded model") + "\n\n") - state := styleStatus.Render("○ stopped") + state := styles.status.Render("○ stopped") if s.running { label := "● serving on " + s.addr() if modelName != "" { label = "● serving " + modelName + " on " + s.addr() } - state = styleAccent.Render(label) + state = styles.success.Render(label) } b.WriteString(" " + state + "\n\n") - addrLabel := styleAnswer.Render("address") + addrLabel := styles.answer.Render("address") value := "‹ " + s.addr() + " ›" hint := serviceAddrs[s.addrIdx].hint if s.running { value = s.addr() hint = "locked while serving — stop first to change it" } - b.WriteString(" " + addrLabel + " " + styleTitle.Render(value) + "\n") - b.WriteString(" " + styleThought.Render(hint) + "\n\n") + b.WriteString(" " + addrLabel + " " + styles.title.Render(value) + "\n") + b.WriteString(" " + styles.thought.Render(hint) + "\n\n") if s.running || s.requests.Load() > 0 { - b.WriteString(" " + styleAnswer.Render("requests") + " " + - styleTitle.Render(core.Sprintf("%d", s.requests.Load())) + "\n\n") + b.WriteString(" " + styles.answer.Render("requests") + " " + + styles.title.Render(core.Sprintf("%d", s.requests.Load())) + "\n\n") } base := s.baseURL() - b.WriteString(styleTitle.Render("point a client here") + " " + - styleThought.Render("the request's model name is cosmetic — the loaded model answers") + "\n") - b.WriteString(" " + styleAnswer.Render("opencode / codex / OpenAI SDKs") + " " + styleAccent.Render(base+"/v1") + "\n") - b.WriteString(" " + styleAnswer.Render("Claude Code / Anthropic SDKs ") + " " + styleAccent.Render(base) + "\n") - b.WriteString(" " + styleAnswer.Render("Ollama clients ") + " " + styleAccent.Render(base) + "\n\n") + b.WriteString(styles.title.Render("point a client here") + " " + + styles.thought.Render("the request's model name is cosmetic — the loaded model answers") + "\n") + b.WriteString(" " + styles.answer.Render("opencode / codex / OpenAI SDKs") + " " + styles.accent.Render(base+"/v1") + "\n") + b.WriteString(" " + styles.answer.Render("Claude Code / Anthropic SDKs ") + " " + styles.accent.Render(base) + "\n") + b.WriteString(" " + styles.answer.Render("Ollama clients ") + " " + styles.accent.Render(base) + "\n\n") - b.WriteString(styleTitle.Render("smoke") + "\n") - b.WriteString(" " + styleThought.Render("curl -s "+base+"/v1/chat/completions \\") + "\n") - b.WriteString(" " + styleThought.Render(` -d '{"model":"lem","messages":[{"role":"user","content":"hello"}]}'`) + "\n\n") + b.WriteString(styles.title.Render("smoke") + "\n") + b.WriteString(" " + styles.thought.Render("curl -s "+base+"/v1/chat/completions \\") + "\n") + b.WriteString(" " + styles.thought.Render(` -d '{"model":"lem","messages":[{"role":"user","content":"hello"}]}'`) + "\n\n") - b.WriteString(" " + styleThought.Render("TUI chat and API requests share the model through one serial lane —") + "\n") - b.WriteString(" " + styleThought.Render("turns queue behind each other, nothing races the engine.") + "\n\n") + b.WriteString(" " + styles.thought.Render("TUI chat and API requests share the model through one serial lane —") + "\n") + b.WriteString(" " + styles.thought.Render("turns queue behind each other, nothing races the engine.") + "\n\n") if s.note != "" { - b.WriteString(" " + styleErr.Render(s.note) + "\n\n") + b.WriteString(" " + styles.err.Render(s.note) + "\n\n") } - b.WriteString(styleStatus.Render("enter start/stop · ←/→ address (while stopped)")) + b.WriteString(styles.status.Render("enter start/stop · ←/→ address (while stopped)")) return b.String() } diff --git a/cli/tui/sessions.go b/cli/tui/sessions.go new file mode 100644 index 000000000..6644e2915 --- /dev/null +++ b/cli/tui/sessions.go @@ -0,0 +1,532 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package tui + +import ( + "time" + + core "dappco.re/go" +) + +const ( + newSessionTitle = "New session" + sessionTitleMaxRunes = 48 + stateActiveSession = "active_session" +) + +type chatSession struct { + Record sessionRecord + Turns []turnRecord + Draft string + ViewportOffset int + Follow bool + Attention bool + ToolHops int + ActiveJobID string +} + +type sessionManager struct { + repository workspaceRepository + state reactiveState + ids func() string + now func() time.Time + order []string + activeID string + sessions map[string]*chatSession +} + +type persistedViewport struct { + Offset int `json:"offset"` + Follow bool `json:"follow"` +} + +func newSessionManager( + repository workspaceRepository, + state reactiveState, + ids func() string, + now func() time.Time, +) core.Result { + if repository == nil { + return core.Fail(core.E("tui.newSessionManager", "repository is required", nil)) + } + if state == nil { + return core.Fail(core.E("tui.newSessionManager", "reactive state is required", nil)) + } + if ids == nil { + ids = newRecordID + } + if now == nil { + now = time.Now + } + + listed := repository.ListSessions(false) + if !listed.OK { + return core.Fail(core.E("tui.newSessionManager", "list sessions", resultError(listed))) + } + records, ok := listed.Value.([]sessionRecord) + if !ok { + return core.Fail(core.E("tui.newSessionManager", "invalid session list result", nil)) + } + manager := &sessionManager{ + repository: repository, + state: state, + ids: ids, + now: now, + order: make([]string, 0, len(records)), + sessions: make(map[string]*chatSession, len(records)), + } + for _, record := range records { + session := &chatSession{Record: record, Follow: true} + manager.restoreSessionState(session) + manager.sessions[record.ID] = session + manager.order = append(manager.order, record.ID) + } + + if activeID, result := state.Get(reactiveGroupWorkspace, stateActiveSession); result.OK { + if _, exists := manager.sessions[activeID]; exists { + manager.activeID = activeID + } + } + if manager.activeID == "" && len(manager.order) > 0 { + manager.activeID = manager.order[0] + } + if manager.activeID != "" { + manager.moveToFront(manager.activeID) + if result := manager.loadSessionTurns(manager.sessions[manager.activeID]); !result.OK { + return result + } + } + return core.Ok(manager) +} + +func (manager *sessionManager) Active() *chatSession { + if manager == nil || manager.activeID == "" { + return nil + } + return manager.sessions[manager.activeID] +} + +func (manager *sessionManager) Recent() []*chatSession { + if manager == nil { + return nil + } + recent := make([]*chatSession, 0, len(manager.order)) + for _, id := range manager.order { + if session, ok := manager.sessions[id]; ok { + recent = append(recent, session) + } + } + return recent +} + +func (manager *sessionManager) Session(id string) core.Result { + if manager == nil { + return core.Fail(core.E("tui.sessionManager.Session", "session manager is unavailable", nil)) + } + session, ok := manager.sessions[id] + if !ok { + return core.Fail(core.E("tui.sessionManager.Session", core.Concat("unknown session: ", id), nil)) + } + if result := manager.loadSessionTurns(session); !result.OK { + return result + } + return core.Ok(session) +} + +func (manager *sessionManager) Create() core.Result { + if manager == nil || manager.repository == nil { + return core.Fail(core.E("tui.sessionManager.Create", "session manager is unavailable", nil)) + } + id := core.Trim(manager.ids()) + if id == "" { + return core.Fail(core.E("tui.sessionManager.Create", "session ID generator returned an empty value", nil)) + } + if _, exists := manager.sessions[id]; exists { + return core.Fail(core.E("tui.sessionManager.Create", core.Concat("duplicate session ID: ", id), nil)) + } + createdAt := manager.now().UTC() + record := sessionRecord{ + ID: id, + Title: newSessionTitle, + Status: "idle", + Mode: "chat", + GenerationJSON: "{}", + ToolsJSON: "[]", + CreatedAt: createdAt, + UpdatedAt: createdAt, + LastOpenedAt: createdAt, + ArchivedAt: unsetRecordTime(), + } + if result := manager.repository.SaveSession(record); !result.OK { + return result + } + session := &chatSession{ + Record: record, + Turns: []turnRecord{}, + Follow: true, + } + manager.sessions[id] = session + manager.moveToFront(id) + manager.activeID = id + manager.persistActive() + return core.Ok(session) +} + +func (manager *sessionManager) Switch(id string) core.Result { + if manager == nil { + return core.Fail(core.E("tui.sessionManager.Switch", "session manager is unavailable", nil)) + } + session, ok := manager.sessions[id] + if !ok { + return core.Fail(core.E("tui.sessionManager.Switch", core.Concat("unknown session: ", id), nil)) + } + if result := manager.loadSessionTurns(session); !result.OK { + return result + } + updated := session.Record + updated.LastOpenedAt = manager.now().UTC() + updated.UpdatedAt = updated.LastOpenedAt + if result := manager.repository.SaveSession(updated); !result.OK { + return result + } + session.Record = updated + session.Attention = false + manager.activeID = id + manager.moveToFront(id) + manager.persistActive() + return core.Ok(session) +} + +func (manager *sessionManager) Previous() core.Result { + return manager.switchRelative(1, "Previous") +} + +func (manager *sessionManager) Next() core.Result { + return manager.switchRelative(-1, "Next") +} + +func (manager *sessionManager) SetDraft(sessionID, draft string) core.Result { + session, result := manager.knownSession("SetDraft", sessionID) + if !result.OK { + return result + } + session.Draft = draft + return manager.state.Set(reactiveGroupDrafts, sessionID, draft) +} + +func (manager *sessionManager) SetViewport(sessionID string, offset int, follow bool) core.Result { + session, result := manager.knownSession("SetViewport", sessionID) + if !result.OK { + return result + } + if offset < 0 { + offset = 0 + } + session.ViewportOffset = offset + session.Follow = follow + persisted := core.JSONMarshalString(persistedViewport{Offset: offset, Follow: follow}) + return manager.state.Set(reactiveGroupViewport, sessionID, persisted) +} + +func (manager *sessionManager) Complete(sessionID string) core.Result { + session, result := manager.knownSession("Complete", sessionID) + if !result.OK { + return result + } + updated := session.Record + updated.Status = "idle" + updated.UpdatedAt = manager.now().UTC() + if result := manager.repository.SaveSession(updated); !result.OK { + return result + } + session.Record = updated + session.ActiveJobID = "" + session.ToolHops = 0 + session.Attention = sessionID != manager.activeID + return core.Ok(session) +} + +func (manager *sessionManager) BeginGeneration(sessionID, jobID string) core.Result { + return manager.setGenerationStatus("BeginGeneration", sessionID, jobID, "queued", false) +} + +func (manager *sessionManager) MarkGenerating(sessionID, jobID string) core.Result { + return manager.setGenerationStatus("MarkGenerating", sessionID, jobID, "generating", false) +} + +func (manager *sessionManager) FailGeneration(sessionID, jobID string) core.Result { + return manager.setGenerationStatus("FailGeneration", sessionID, jobID, "failed", true) +} + +func (manager *sessionManager) cancelGeneration(sessionID, jobID string) core.Result { + return manager.setGenerationStatus("CancelGeneration", sessionID, jobID, "cancelled", true) +} + +func (manager *sessionManager) setGenerationStatus(operation, sessionID, jobID, status string, finished bool) core.Result { + session, result := manager.knownSession(operation, sessionID) + if !result.OK { + return result + } + updated := session.Record + updated.Status = status + updated.UpdatedAt = manager.now().UTC() + if result := manager.repository.SaveSession(updated); !result.OK { + return result + } + session.Record = updated + if finished { + session.ActiveJobID = "" + session.ToolHops = 0 + session.Attention = sessionID != manager.activeID + } else { + session.ActiveJobID = jobID + } + return core.Ok(session) +} + +func (manager *sessionManager) AddTurn(record turnRecord) core.Result { + session, result := manager.knownSession("AddTurn", record.SessionID) + if !result.OK { + return result + } + if loadResult := manager.loadSessionTurns(session); !loadResult.OK { + return loadResult + } + hadUserTurn := sessionHasUserTurn(session.Turns) + if result := manager.repository.SaveTurn(record); !result.OK { + return result + } + session.Turns = upsertOrderedTurn(session.Turns, record) + + if record.Role == "user" && !hadUserTurn && session.Record.Title == newSessionTitle { + title := compactSessionTitle(record.Visible) + if title != "" { + updated := session.Record + updated.Title = title + updated.UpdatedAt = manager.now().UTC() + if result := manager.repository.SaveSession(updated); !result.OK { + return result + } + session.Record = updated + } + } + return core.Ok(session) +} + +func (manager *sessionManager) Rename(sessionID, title string) core.Result { + session, result := manager.knownSession("Rename", sessionID) + if !result.OK { + return result + } + title = compactSessionTitle(title) + if title == "" { + return core.Fail(core.E("tui.sessionManager.Rename", "session title is required", nil)) + } + updated := session.Record + updated.Title = title + updated.UpdatedAt = manager.now().UTC() + if result := manager.repository.SaveSession(updated); !result.OK { + return result + } + session.Record = updated + return core.Ok(session) +} + +func (manager *sessionManager) Archive(sessionID string) core.Result { + session, result := manager.knownSession("Archive", sessionID) + if !result.OK { + return result + } + updated := session.Record + updated.Archived = true + updated.ArchivedAt = manager.now().UTC() + updated.UpdatedAt = updated.ArchivedAt + if result := manager.repository.SaveSession(updated); !result.OK { + return result + } + delete(manager.sessions, sessionID) + manager.removeFromOrder(sessionID) + if manager.activeID == sessionID { + manager.activeID = "" + if len(manager.order) > 0 { + manager.activeID = manager.order[0] + if loadResult := manager.loadSessionTurns(manager.sessions[manager.activeID]); !loadResult.OK { + return loadResult + } + } + manager.persistActive() + } + return core.Ok(updated) +} + +func (manager *sessionManager) Reopen(sessionID string) core.Result { + if manager == nil { + return core.Fail(core.E("tui.sessionManager.Reopen", "session manager is unavailable", nil)) + } + if _, exists := manager.sessions[sessionID]; exists { + return manager.Switch(sessionID) + } + loaded := manager.repository.Session(sessionID) + if !loaded.OK { + return loaded + } + record, ok := loaded.Value.(sessionRecord) + if !ok { + return core.Fail(core.E("tui.sessionManager.Reopen", "invalid session result", nil)) + } + record.Archived = false + record.ArchivedAt = unsetRecordTime() + record.UpdatedAt = manager.now().UTC() + record.LastOpenedAt = record.UpdatedAt + if result := manager.repository.SaveSession(record); !result.OK { + return result + } + session := &chatSession{Record: record, Follow: true} + manager.restoreSessionState(session) + manager.sessions[sessionID] = session + manager.moveToFront(sessionID) + manager.activeID = sessionID + if result := manager.loadSessionTurns(session); !result.OK { + return result + } + manager.persistActive() + return core.Ok(session) +} + +func (manager *sessionManager) switchRelative(delta int, operation string) core.Result { + if manager == nil || manager.activeID == "" || len(manager.order) == 0 { + return core.Fail(core.E(core.Concat("tui.sessionManager.", operation), "no active session", nil)) + } + if len(manager.order) == 1 { + return core.Ok(manager.Active()) + } + index := 0 + for candidate, id := range manager.order { + if id == manager.activeID { + index = candidate + break + } + } + target := (index + delta) % len(manager.order) + if target < 0 { + target += len(manager.order) + } + return manager.Switch(manager.order[target]) +} + +func (manager *sessionManager) knownSession(operation, id string) (*chatSession, core.Result) { + if manager == nil { + return nil, core.Fail(core.E(core.Concat("tui.sessionManager.", operation), "session manager is unavailable", nil)) + } + session, ok := manager.sessions[id] + if !ok { + return nil, core.Fail(core.E(core.Concat("tui.sessionManager.", operation), core.Concat("unknown session: ", id), nil)) + } + return session, core.Ok(nil) +} + +func (manager *sessionManager) loadSessionTurns(session *chatSession) core.Result { + if session == nil { + return core.Fail(core.E("tui.sessionManager.loadSessionTurns", "session is required", nil)) + } + if session.Turns != nil { + return core.Ok(nil) + } + loaded := manager.repository.Turns(session.Record.ID) + if !loaded.OK { + return loaded + } + turns, ok := loaded.Value.([]turnRecord) + if !ok { + return core.Fail(core.E("tui.sessionManager.loadSessionTurns", "invalid turns result", nil)) + } + if turns == nil { + turns = []turnRecord{} + } + session.Turns = turns + return core.Ok(nil) +} + +func (manager *sessionManager) restoreSessionState(session *chatSession) { + if session == nil { + return + } + if draft, result := manager.state.Get(reactiveGroupDrafts, session.Record.ID); result.OK { + session.Draft = draft + } + if raw, result := manager.state.Get(reactiveGroupViewport, session.Record.ID); result.OK { + var persisted persistedViewport + if decoded := core.JSONUnmarshalString(raw, &persisted); decoded.OK { + if persisted.Offset > 0 { + session.ViewportOffset = persisted.Offset + } + session.Follow = persisted.Follow + } + } +} + +func (manager *sessionManager) persistActive() { + if manager == nil || manager.state == nil { + return + } + if result := manager.state.Set(reactiveGroupWorkspace, stateActiveSession, manager.activeID); !result.OK { + core.Warn("tui.sessions.persist_active", "session", manager.activeID, "error", result.Value) + } +} + +func (manager *sessionManager) moveToFront(id string) { + manager.removeFromOrder(id) + manager.order = append([]string{id}, manager.order...) +} + +func (manager *sessionManager) removeFromOrder(id string) { + for index, candidate := range manager.order { + if candidate != id { + continue + } + copy(manager.order[index:], manager.order[index+1:]) + manager.order = manager.order[:len(manager.order)-1] + return + } +} + +func sessionHasUserTurn(turns []turnRecord) bool { + for _, turn := range turns { + if turn.Role == "user" { + return true + } + } + return false +} + +func upsertOrderedTurn(turns []turnRecord, record turnRecord) []turnRecord { + for index := range turns { + if turns[index].ID == record.ID { + turns[index] = record + return turns + } + } + insertAt := len(turns) + for index, turn := range turns { + if turn.Sequence > record.Sequence { + insertAt = index + break + } + } + turns = append(turns, turnRecord{}) + copy(turns[insertAt+1:], turns[insertAt:]) + turns[insertAt] = record + return turns +} + +func compactSessionTitle(text string) string { + title := core.Join(" ", core.Fields(text)...) + if title == "" { + return "" + } + runes := []rune(title) + if len(runes) <= sessionTitleMaxRunes { + return title + } + return core.Concat(string(runes[:sessionTitleMaxRunes-1]), "…") +} diff --git a/cli/tui/sessions_test.go b/cli/tui/sessions_test.go new file mode 100644 index 000000000..4919dd76b --- /dev/null +++ b/cli/tui/sessions_test.go @@ -0,0 +1,273 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package tui + +import ( + "reflect" + "testing" + "time" + + core "dappco.re/go" +) + +func TestSessionManager_Good(t *testing.T) { + paths := testReactivePaths(t) + repository := openSessionRepository(t, paths.Database) + state := openSessionState(t, paths) + ids := sequenceIDs("session-one", "session-two") + clock := sequenceClock(time.Date(2026, time.July, 17, 15, 0, 0, 0, time.UTC)) + + opened := newSessionManager(repository, state, ids, clock) + if !opened.OK { + t.Fatalf("newSessionManager empty: %v", opened.Value) + } + manager, ok := opened.Value.(*sessionManager) + if !ok { + t.Fatalf("newSessionManager value = %T, want *sessionManager", opened.Value) + } + if manager.Active() != nil || len(manager.Recent()) != 0 { + t.Fatalf("empty manager active/recent = %#v / %#v", manager.Active(), manager.Recent()) + } + + firstResult := manager.Create() + if !firstResult.OK { + t.Fatalf("create first session: %v", firstResult.Value) + } + first := firstResult.Value.(*chatSession) + if first.Record.ID != "session-one" || manager.Active().Record.ID != first.Record.ID { + t.Fatalf("first session = %#v, active %#v", first, manager.Active()) + } + if result := manager.SetDraft(first.Record.ID, "first draft"); !result.OK { + t.Fatalf("set first draft: %v", result.Value) + } + if result := manager.SetViewport(first.Record.ID, 17, false); !result.OK { + t.Fatalf("set first viewport: %v", result.Value) + } + + secondResult := manager.Create() + if !secondResult.OK { + t.Fatalf("create second session: %v", secondResult.Value) + } + second := secondResult.Value.(*chatSession) + if result := manager.SetDraft(second.Record.ID, "second draft"); !result.OK { + t.Fatalf("set second draft: %v", result.Value) + } + if result := manager.SetViewport(second.Record.ID, 29, true); !result.OK { + t.Fatalf("set second viewport: %v", result.Value) + } + if result := manager.Previous(); !result.OK || manager.Active().Record.ID != first.Record.ID { + t.Fatalf("Previous = %#v, active %v, want %q", result.Value, manager.Active(), first.Record.ID) + } + if result := manager.Next(); !result.OK || manager.Active().Record.ID != second.Record.ID { + t.Fatalf("Next = %#v, active %v, want %q", result.Value, manager.Active(), second.Record.ID) + } + + if result := state.Close(); !result.OK { + t.Fatalf("close first state: %v", result.Value) + } + if result := repository.Close(); !result.OK { + t.Fatalf("close first repository: %v", result.Value) + } + + repository = openSessionRepository(t, paths.Database) + state = openSessionState(t, paths) + defer closeSessionFixture(t, repository, state) + restoredResult := newSessionManager(repository, state, sequenceIDs("unused"), clock) + if !restoredResult.OK { + t.Fatalf("restore session manager: %v", restoredResult.Value) + } + restored := restoredResult.Value.(*sessionManager) + if restored.Active() == nil || restored.Active().Record.ID != second.Record.ID { + t.Fatalf("restored active = %#v, want %q", restored.Active(), second.Record.ID) + } + recent := restored.Recent() + if len(recent) != 2 || recent[0].Record.ID != second.Record.ID || recent[1].Record.ID != first.Record.ID { + t.Fatalf("restored recent order = %#v, want [%s %s]", recent, second.Record.ID, first.Record.ID) + } + firstRestored := restored.sessions[first.Record.ID] + secondRestored := restored.sessions[second.Record.ID] + if firstRestored.Draft != "first draft" || firstRestored.ViewportOffset != 17 || firstRestored.Follow { + t.Fatalf("restored first UI state = %#v", firstRestored) + } + if secondRestored.Draft != "second draft" || secondRestored.ViewportOffset != 29 || !secondRestored.Follow { + t.Fatalf("restored second UI state = %#v", secondRestored) + } + if firstRestored.Turns != nil { + t.Fatalf("inactive turns eagerly loaded = %#v, want nil", firstRestored.Turns) + } + if secondRestored.Turns == nil { + t.Fatal("active turns were not loaded") + } +} + +func TestSessionManager_Bad(t *testing.T) { + manager := openTestSessionManager(t, sequenceIDs("session-known")) + if result := manager.Create(); !result.OK { + t.Fatalf("create known session: %v", result.Value) + } + activeBefore := manager.Active().Record.ID + orderBefore := append([]string(nil), manager.order...) + + result := manager.Switch("session-missing") + if result.OK { + t.Fatalf("Switch unknown session = %#v, want failure", result.Value) + } + if manager.Active().Record.ID != activeBefore || !reflect.DeepEqual(manager.order, orderBefore) { + t.Fatalf("unknown switch mutated active/order to %q / %#v", manager.Active().Record.ID, manager.order) + } +} + +func TestSessionManager_Ugly(t *testing.T) { + manager := openTestSessionManager(t, sequenceIDs("session-a", "session-b", "session-c")) + for range 3 { + if result := manager.Create(); !result.OK { + t.Fatalf("create session: %v", result.Value) + } + } + if result := manager.Switch("session-a"); !result.OK { + t.Fatalf("switch to session-a: %v", result.Value) + } + if result := manager.Complete("session-b"); !result.OK { + t.Fatalf("complete hidden session-b: %v", result.Value) + } + if result := manager.Complete("session-c"); !result.OK { + t.Fatalf("complete hidden session-c: %v", result.Value) + } + if !manager.sessions["session-b"].Attention || !manager.sessions["session-c"].Attention { + t.Fatalf("hidden attention = b:%v c:%v, want both", manager.sessions["session-b"].Attention, manager.sessions["session-c"].Attention) + } + + if result := manager.Switch("session-b"); !result.OK { + t.Fatalf("switch to completed session-b: %v", result.Value) + } + if manager.sessions["session-b"].Attention { + t.Fatal("active session-b retained attention marker") + } + if !manager.sessions["session-c"].Attention { + t.Fatal("switching session-b cleared session-c attention") + } +} + +func TestSessionManager_Title_Good(t *testing.T) { + manager := openTestSessionManager(t, sequenceIDs("session-title", "session-pre-renamed")) + created := manager.Create() + if !created.OK { + t.Fatalf("create title session: %v", created.Value) + } + session := created.Value.(*chatSession) + firstText := " Explain\n\twhy a durable multi-session terminal workspace should preserve context across restarts and model changes " + first := testTurnRecord("turn-title-first", session.Record.ID, 1, "user", firstText, time.Now().UTC()) + if result := manager.AddTurn(first); !result.OK { + t.Fatalf("add first user turn: %v", result.Value) + } + derived := session.Record.Title + if derived == "New session" || core.ContainsAny(derived, "\n\t") || core.RuneCount(derived) > sessionTitleMaxRunes { + t.Fatalf("derived title = %q (%d runes)", derived, core.RuneCount(derived)) + } + + if result := manager.Rename(session.Record.ID, "Hand edited title"); !result.OK { + t.Fatalf("rename session: %v", result.Value) + } + second := testTurnRecord("turn-title-second", session.Record.ID, 2, "user", "This later prompt must not rename it", time.Now().UTC()) + if result := manager.AddTurn(second); !result.OK { + t.Fatalf("add later user turn: %v", result.Value) + } + if session.Record.Title != "Hand edited title" { + t.Fatalf("later prompt renamed session to %q", session.Record.Title) + } + + preRenamedResult := manager.Create() + if !preRenamedResult.OK { + t.Fatalf("create pre-renamed session: %v", preRenamedResult.Value) + } + preRenamed := preRenamedResult.Value.(*chatSession) + if result := manager.Rename(preRenamed.Record.ID, "Named before prompting"); !result.OK { + t.Fatalf("pre-rename session: %v", result.Value) + } + turn := testTurnRecord("turn-pre-renamed", preRenamed.Record.ID, 1, "user", "First prompt", time.Now().UTC()) + if result := manager.AddTurn(turn); !result.OK { + t.Fatalf("add pre-renamed first turn: %v", result.Value) + } + if preRenamed.Record.Title != "Named before prompting" { + t.Fatalf("first prompt replaced manual title with %q", preRenamed.Record.Title) + } + + stored := manager.repository.Session(session.Record.ID) + if !stored.OK || stored.Value.(sessionRecord).Title != "Hand edited title" { + t.Fatalf("persisted edited title = %#v", stored.Value) + } +} + +func openTestSessionManager(t *testing.T, ids func() string) *sessionManager { + t.Helper() + paths := testReactivePaths(t) + repository := openSessionRepository(t, paths.Database) + state := openSessionState(t, paths) + t.Cleanup(func() { closeSessionFixture(t, repository, state) }) + result := newSessionManager( + repository, + state, + ids, + sequenceClock(time.Date(2026, time.July, 17, 16, 0, 0, 0, time.UTC)), + ) + if !result.OK { + t.Fatalf("newSessionManager: %v", result.Value) + } + return result.Value.(*sessionManager) +} + +func openSessionRepository(t *testing.T, path string) workspaceRepository { + t.Helper() + result := openDuckRepository(path) + if !result.OK { + t.Fatalf("open session repository: %v", result.Value) + } + repository, ok := result.Value.(workspaceRepository) + if !ok { + t.Fatalf("openDuckRepository value = %T, want workspaceRepository", result.Value) + } + return repository +} + +func openSessionState(t *testing.T, paths appPaths) reactiveState { + t.Helper() + result := openReactiveState(paths) + if !result.OK { + t.Fatalf("open session state: %v", result.Value) + } + state, ok := result.Value.(reactiveState) + if !ok { + t.Fatalf("openReactiveState value = %T, want reactiveState", result.Value) + } + return state +} + +func closeSessionFixture(t *testing.T, repository workspaceRepository, state reactiveState) { + t.Helper() + if result := state.Close(); !result.OK { + t.Errorf("close session state: %v", result.Value) + } + if result := repository.Close(); !result.OK { + t.Errorf("close session repository: %v", result.Value) + } +} + +func sequenceIDs(ids ...string) func() string { + index := 0 + return func() string { + if index >= len(ids) { + return "" + } + id := ids[index] + index++ + return id + } +} + +func sequenceClock(start time.Time) func() time.Time { + current := start.Add(-time.Second) + return func() time.Time { + current = current.Add(time.Second) + return current + } +} diff --git a/cli/tui/settings.go b/cli/tui/settings.go index 3683307fc..599e7ecba 100644 --- a/cli/tui/settings.go +++ b/cli/tui/settings.go @@ -2,11 +2,7 @@ package tui -import ( - "strings" - - core "dappco.re/go" -) +import core "dappco.re/go" // The Settings tab: a cursor list of knobs adjusted with ←/→ (or h/l). Values // apply to the NEXT load (context) or the next turn (the rest) — the honest @@ -29,6 +25,30 @@ func newSettings() settings { return settings{maxTokIdx: 3} // 4096 — thinking spends from the budget } +func (s settings) withPreferenceValues(values preferenceValues) settings { + for index, value := range ctxSteps { + if value == values.ContextLength { + s.ctxIdx = index + break + } + } + for index, value := range maxTokSteps { + if value == values.MaxTokens { + s.maxTokIdx = index + break + } + } + switch values.Thinking { + case "on": + s.thinkIdx = 1 + case "off": + s.thinkIdx = 2 + default: + s.thinkIdx = 0 + } + return s +} + func (s settings) contextLen() int { return ctxSteps[s.ctxIdx] } func (s settings) maxTokens() int { return maxTokSteps[s.maxTokIdx] } @@ -83,19 +103,19 @@ func (s settings) move(delta int) settings { return s } -func (s settings) view(width int) string { - var b strings.Builder - b.WriteString(styleTitle.Render("settings") + "\n\n") +func (s settings) view(width int, styles uiStyles) string { + var b core.Builder + b.WriteString(styles.title.Render("settings") + "\n\n") for i, row := range s.rows() { cursor := " " - name := styleAnswer.Render(row.name) + name := styles.answer.Render(row.name) if i == s.cursor { - cursor = styleAccent.Render("› ") - name = styleAccent.Render(row.name) + cursor = styles.accent.Render("› ") + name = styles.accent.Render(row.name) } - b.WriteString(cursor + name + " " + styleTitle.Render("‹ "+row.value+" ›") + "\n") - b.WriteString(" " + styleThought.Render(row.hint) + "\n\n") + b.WriteString(cursor + name + " " + styles.title.Render("‹ "+row.value+" ›") + "\n") + b.WriteString(" " + styles.thought.Render(row.hint) + "\n\n") } - b.WriteString(styleStatus.Render("↑/↓ select · ←/→ change · values apply as hinted")) + b.WriteString(styles.status.Render("↑/↓ select · ←/→ change · values apply as hinted")) return b.String() } diff --git a/cli/tui/stream.go b/cli/tui/stream.go index 89d15b645..33161982a 100644 --- a/cli/tui/stream.go +++ b/cli/tui/stream.go @@ -16,32 +16,37 @@ import ( // iterator to the update loop. type streamEvent struct { - visible string // answer text delta (thinking stripped) - thought string // reasoning text delta - done bool - err error - metrics *inference.GenerateMetrics + SessionID string + JobID string + visible string // answer text delta (thinking stripped) + thought string // reasoning text delta + done bool + err error + metrics *inference.GenerateMetrics } type streamMsg streamEvent -// generation is one in-flight turn: its cancel func and event channel. -type generation struct { - cancel context.CancelFunc - events chan streamEvent -} - -// startGeneration launches m.Chat on its own goroutine, splitting the raw -// stream through the family-aware reasoning parser (thinking → thought deltas, -// the rest → visible deltas). genOpts carries the Modes preset + Settings -// overrides; the metrics sink is appended here so every turn reports tok/s. -func startGeneration(model inference.TextModel, history []inference.Message, genOpts []inference.GenerateOption) *generation { - ctx, cancel := context.WithCancel(context.Background()) - g := &generation{cancel: cancel, events: make(chan streamEvent, 64)} - +// streamGeneration runs m.Chat, splitting the raw stream through the +// family-aware reasoning parser (thinking → thought deltas, the rest → +// visible deltas). The caller owns the context and registration lifecycle. +func streamGeneration(ctx context.Context, g *generation, model inference.TextModel, history []inference.Message, genOpts []inference.GenerateOption, finish func()) { + var requestErr error + errorSinkCalled := false + tag := func(event streamEvent) streamEvent { + event.SessionID = g.SessionID + event.JobID = g.JobID + return event + } + emit := func(event streamEvent) { + select { + case g.events <- tag(event): + case <-ctx.Done(): + } + } proc := parser.NewProcessor( parser.Config{Mode: inference.ThinkingCapture, Capture: func(c inference.ThinkingChunk) { - g.events <- streamEvent{thought: c.Text} + emit(streamEvent{thought: c.Text}) }}, parser.HintFromInference(model.Info()), ) @@ -49,37 +54,57 @@ func startGeneration(model inference.TextModel, history []inference.Message, gen opts := append([]inference.GenerateOption{}, genOpts...) opts = append(opts, inference.WithMetricsSink(func(gm inference.GenerateMetrics) { m := gm - g.events <- streamEvent{metrics: &m} + emit(streamEvent{metrics: &m}) })) - go func() { - defer close(g.events) - for tok := range model.Chat(ctx, history, opts...) { - if visible := proc.Process(tok.Text); visible != "" { - g.events <- streamEvent{visible: visible} - } + defer func() { + if finish != nil { + finish() } - if tail := proc.Flush(); tail != "" { - g.events <- streamEvent{visible: tail} + close(g.events) + }() + stream := model.Chat(ctx, history, opts...) + if scoped, ok := model.(scopedErrorChatModel); ok { + stream = scoped.chatWithErrorSink(ctx, history, func(err error) { + requestErr = err + errorSinkCalled = true + }, opts...) + } + for tok := range stream { + if visible := proc.Process(tok.Text); visible != "" { + emit(streamEvent{visible: visible}) } - ev := streamEvent{done: true} - if r := model.Err(); !r.OK { - if err, ok := r.Value.(error); ok { - ev.err = err - } + } + if tail := proc.Flush(); tail != "" { + emit(streamEvent{visible: tail}) + } + ev := streamEvent{done: true} + if errorSinkCalled { + ev.err = requestErr + } else if r := model.Err(); !r.OK { + if err, ok := r.Value.(error); ok { + ev.err = err } - g.events <- ev - }() - return g + } + // A cancelled stream may decline the send above; use a final non-blocking + // attempt so an attentive UI sees done immediately. Closing the channel is + // still the fallback completion signal when its buffer is full. + select { + case g.events <- tag(ev): + default: + } } // waitEvent yields the next stream event to the update loop; a closed channel // surfaces as a final done message. func waitEvent(g *generation) tea.Cmd { return func() tea.Msg { + if g == nil { + return streamMsg{done: true} + } ev, ok := <-g.events if !ok { - return streamMsg{done: true} + return streamMsg{SessionID: g.SessionID, JobID: g.JobID, done: true} } return streamMsg(ev) } diff --git a/cli/tui/style.go b/cli/tui/style.go index 8d34c010b..8ee699f34 100644 --- a/cli/tui/style.go +++ b/cli/tui/style.go @@ -2,26 +2,111 @@ // Package tui is the lem terminal UI (`lem tui`): a model picker over // inference.Discover and a streaming chat over inference.TextModel.Chat, -// built on Bubble Tea + Bubbles + Lip Gloss. Dark by default. +// built on Bubble Tea + Bubbles + Lip Gloss. package tui import "github.com/charmbracelet/lipgloss" -// The palette stays deliberately dim — dark background, low-brightness -// accents — with colour reserved for state (thinking vs answer vs status). -var ( - styleTitle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("246")) +// theme contains semantic colours rather than component-specific paint. Every +// colour is adaptive so the same hierarchy remains legible on light terminals. +type theme struct { + name string + text lipgloss.AdaptiveColor + muted lipgloss.AdaptiveColor + border lipgloss.AdaptiveColor + focus lipgloss.AdaptiveColor + assistant lipgloss.AdaptiveColor + attention lipgloss.AdaptiveColor + success lipgloss.AdaptiveColor + error lipgloss.AdaptiveColor +} - styleUser = lipgloss.NewStyle().Foreground(lipgloss.Color("110")).Bold(true) - styleAnswer = lipgloss.NewStyle().Foreground(lipgloss.Color("252")) - styleThought = lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Italic(true) - styleErr = lipgloss.NewStyle().Foreground(lipgloss.Color("167")) +func midnightTheme() theme { + return theme{ + name: "midnight", + text: lipgloss.AdaptiveColor{Light: "#172033", Dark: "#E2E8F0"}, + muted: lipgloss.AdaptiveColor{Light: "#475569", Dark: "#718096"}, + border: lipgloss.AdaptiveColor{Light: "#94A3B8", Dark: "#334155"}, + focus: lipgloss.AdaptiveColor{Light: "#007A89", Dark: "#67E8F9"}, + assistant: lipgloss.AdaptiveColor{Light: "#6D28D9", Dark: "#C4B5FD"}, + attention: lipgloss.AdaptiveColor{Light: "#92400E", Dark: "#FBBF24"}, + success: lipgloss.AdaptiveColor{Light: "#047857", Dark: "#6EE7B7"}, + error: lipgloss.AdaptiveColor{Light: "#BE123C", Dark: "#FB7185"}, + } +} - styleStatus = lipgloss.NewStyle().Foreground(lipgloss.Color("244")) - styleAccent = lipgloss.NewStyle().Foreground(lipgloss.Color("109")) +func themeForName(name string) theme { + switch name { + case "aurora": + value := midnightTheme() + value.name = "aurora" + value.focus = lipgloss.AdaptiveColor{Light: "#047857", Dark: "#5EEAD4"} + value.assistant = lipgloss.AdaptiveColor{Light: "#7E22CE", Dark: "#E879F9"} + value.attention = lipgloss.AdaptiveColor{Light: "#B45309", Dark: "#FDE68A"} + return value + case "daylight": + value := midnightTheme() + value.name = "daylight" + value.focus = lipgloss.AdaptiveColor{Light: "#0369A1", Dark: "#7DD3FC"} + value.assistant = lipgloss.AdaptiveColor{Light: "#5B21B6", Dark: "#DDD6FE"} + return value + default: + return midnightTheme() + } +} - styleInputBorder = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("238")). - Padding(0, 1) -) +// uiStyles is owned by app. Components receive it explicitly, which lets a +// future preference change rebuild the complete visual language in place. +type uiStyles struct { + theme theme + + title lipgloss.Style + user lipgloss.Style + answer lipgloss.Style + thought lipgloss.Style + err lipgloss.Style + status lipgloss.Style + accent lipgloss.Style + assistant lipgloss.Style + attention lipgloss.Style + success lipgloss.Style + + brand lipgloss.Style + navActive lipgloss.Style + navInactive lipgloss.Style + header lipgloss.Style + session lipgloss.Style + panel lipgloss.Style + inspector lipgloss.Style + footer lipgloss.Style + separator lipgloss.Style + outerFrame lipgloss.Style + inputBorder lipgloss.Style +} + +func newUIStyles(t theme) uiStyles { + return uiStyles{ + theme: t, + title: lipgloss.NewStyle().Bold(true).Foreground(t.text), + user: lipgloss.NewStyle().Bold(true).Foreground(t.focus), + answer: lipgloss.NewStyle().Foreground(t.text), + thought: lipgloss.NewStyle().Italic(true).Foreground(t.muted), + err: lipgloss.NewStyle().Foreground(t.error), + status: lipgloss.NewStyle().Foreground(t.muted), + accent: lipgloss.NewStyle().Foreground(t.focus), + assistant: lipgloss.NewStyle().Bold(true).Foreground(t.assistant), + attention: lipgloss.NewStyle().Bold(true).Foreground(t.attention), + success: lipgloss.NewStyle().Foreground(t.success), + brand: lipgloss.NewStyle().Bold(true).Foreground(t.focus), + navActive: lipgloss.NewStyle().Bold(true).Foreground(t.text).Underline(true).UnderlineSpaces(false), + navInactive: lipgloss.NewStyle().Foreground(t.muted), + header: lipgloss.NewStyle().Foreground(t.text), + session: lipgloss.NewStyle().Foreground(t.muted), + panel: lipgloss.NewStyle().Foreground(t.text), + inspector: lipgloss.NewStyle().Foreground(t.muted), + footer: lipgloss.NewStyle().Foreground(t.muted), + separator: lipgloss.NewStyle().Foreground(t.border), + outerFrame: lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(t.border), + inputBorder: lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(t.border).Padding(0, 1), + } +} diff --git a/cli/tui/tabs.ctml b/cli/tui/tabs.ctml new file mode 100644 index 000000000..a1e7b5519 --- /dev/null +++ b/cli/tui/tabs.ctml @@ -0,0 +1,21 @@ + + + diff --git a/cli/tui/tabs.go b/cli/tui/tabs.go index 4416f4cfe..bd93fbea2 100644 --- a/cli/tui/tabs.go +++ b/cli/tui/tabs.go @@ -3,73 +3,178 @@ package tui import ( - "strings" + _ "embed" "github.com/charmbracelet/lipgloss" -) + "github.com/charmbracelet/x/ansi" -// The tab bar follows the bubbletea tabs example: joined bordered boxes with -// the active tab's bottom border opened into the content pane. + core "dappco.re/go" + "dappco.re/go/html" + "dappco.re/go/html/ctml" + "dappco.re/go/html/teabox" +) -type tabID int +type panelID uint8 const ( - tabChat tabID = iota - tabModels - tabService - tabSettings - tabTools - tabModes - tabCount + panelChat panelID = iota + panelWork + panelModels + panelService + panelData + panelCount ) -var tabNames = [tabCount]string{"Chat", "Models", "Service", "Settings", "Tools", "Modes"} +var panelNames = [panelCount]string{"Chat", "Work", "Models", "Service", "Data"} +var compactPanelNames = [panelCount]string{"Chat", "Work", "Models", "API", "Data"} -func tabBorder(left, middle, right string) lipgloss.Border { - b := lipgloss.RoundedBorder() - b.BottomLeft, b.Bottom, b.BottomRight = left, middle, right - return b -} +func (panel panelID) next() panelID { return (panel + 1) % panelCount } +func (panel panelID) prev() panelID { return (panel + panelCount - 1) % panelCount } -var ( - inactiveTabBorder = tabBorder("┴", "─", "┴") - activeTabBorder = tabBorder("┘", " ", "└") - inactiveTabStyle = lipgloss.NewStyle().Border(inactiveTabBorder, true). - BorderForeground(lipgloss.Color("238")). - Foreground(lipgloss.Color("245")).Padding(0, 1) - activeTabStyle = inactiveTabStyle. - Border(activeTabBorder, true). - BorderForeground(lipgloss.Color("109")). - Foreground(lipgloss.Color("252")).Bold(true) - tabGapStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("238")) -) +// tabsCTML is the tab strip's markup — see tabs.ctml for the seams it +// exposes (row sequences, class tokens, the panel-bar box id). +// +//go:embed tabs.ctml +var tabsCTML []byte -// renderTabBar draws the header row, padding the gap to width with the open -// bottom rule so the bar reads as the top of the content frame. -func renderTabBar(active tabID, width int) string { - var rendered []string - for i := tabID(0); i < tabCount; i++ { - if i == active { - rendered = append(rendered, activeTabStyle.Render(tabNames[i])) - } else { - rendered = append(rendered, inactiveTabStyle.Render(tabNames[i])) +// panelBarBlockID is the id attribute on the strip's