From e920397741f45ac75ebe3ad8d636ad0cd44b258f Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 6 Mar 2026 13:48:00 +0000 Subject: [PATCH] refactor: extract remaining pkg/ packages to standalone modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pkg/session → go-session - pkg/ws → go-ws - pkg/webview → go-webview - pkg/workspace → go-io/workspace - pkg/lab → lthn/lem/pkg/lab - pkg/build deleted (empty dirs) - lem-chat moved to lthn/LEM - internal/core-ide + cmd/core-ide deleted (Wails artifacts, source in core/ide) - internal/cmd deleted (Wails updater artifacts) - Taskfile.yaml deleted (stale Wails duplicate) pkg/ now contains only framework + log (stays). Co-Authored-By: Virgil --- .coderabbit.yaml | 15 - .gitleaks.toml | 10 - CONTRIBUTING.md | 35 - GEMINI.md | 55 -- Taskfile.yaml | 6 - go.mod | 26 +- go.sum | 52 +- lem-chat/.gitignore | 1 - lem-chat/index.html | 34 - lem-chat/package-lock.json | 515 -------------- lem-chat/package.json | 16 - lem-chat/src/lem-chat.ts | 195 ------ lem-chat/src/lem-input.ts | 110 --- lem-chat/src/lem-message.ts | 154 ----- lem-chat/src/lem-messages.ts | 70 -- lem-chat/src/markdown.ts | 80 --- lem-chat/src/styles.ts | 325 --------- lem-chat/src/types.ts | 31 - lem-chat/tsconfig.json | 14 - mkdocs.yml | 104 --- pkg/cache/cache.go | 171 ----- pkg/cache/cache_test.go | 104 --- pkg/config/config.go | 212 ------ pkg/config/config_test.go | 277 -------- pkg/config/env.go | 40 -- pkg/config/service.go | 83 --- pkg/lab/collector/collector.go | 82 --- pkg/lab/collector/docker.go | 94 --- pkg/lab/collector/forgejo.go | 130 ---- pkg/lab/collector/huggingface.go | 55 -- pkg/lab/collector/influxdb.go | 358 ---------- pkg/lab/collector/prometheus.go | 104 --- pkg/lab/collector/services.go | 107 --- pkg/lab/collector/system.go | 374 ---------- pkg/lab/collector/training.go | 123 ---- pkg/lab/config.go | 84 --- pkg/lab/config_test.go | 129 ---- pkg/lab/handler/api.go | 65 -- pkg/lab/handler/chart.go | 595 ---------------- pkg/lab/handler/static/.gitkeep | 0 pkg/lab/handler/templates/agents.html | 56 -- pkg/lab/handler/templates/dashboard.html | 115 ---- pkg/lab/handler/templates/dataset.html | 392 ----------- pkg/lab/handler/templates/golden-set.html | 108 --- pkg/lab/handler/templates/layout.html | 103 --- pkg/lab/handler/templates/models.html | 29 - pkg/lab/handler/templates/runs.html | 113 --- pkg/lab/handler/templates/services.html | 65 -- pkg/lab/handler/templates/training.html | 278 -------- pkg/lab/handler/web.go | 502 -------------- pkg/lab/model.go | 219 ------ pkg/lab/store.go | 275 -------- pkg/lab/store_test.go | 391 ----------- pkg/manifest/loader.go | 43 -- pkg/manifest/loader_test.go | 63 -- pkg/manifest/manifest.go | 50 -- pkg/manifest/manifest_test.go | 65 -- pkg/manifest/sign.go | 44 -- pkg/manifest/sign_test.go | 51 -- pkg/marketplace/installer.go | 196 ------ pkg/marketplace/installer_test.go | 263 ------- pkg/marketplace/marketplace.go | 67 -- pkg/marketplace/marketplace_test.go | 65 -- pkg/plugin/config.go | 10 - pkg/plugin/installer.go | 195 ------ pkg/plugin/installer_test.go | 166 ----- pkg/plugin/loader.go | 63 -- pkg/plugin/loader_test.go | 146 ---- pkg/plugin/manifest.go | 50 -- pkg/plugin/manifest_test.go | 109 --- pkg/plugin/plugin.go | 54 -- pkg/plugin/plugin_test.go | 39 -- pkg/plugin/registry.go | 118 ---- pkg/plugin/registry_test.go | 192 ------ pkg/process/actions.go | 37 - pkg/process/buffer.go | 108 --- pkg/process/buffer_test.go | 72 -- pkg/process/exec/exec.go | 176 ----- pkg/process/exec/exec_test.go | 148 ---- pkg/process/exec/logger.go | 35 - pkg/process/global_test.go | 298 -------- pkg/process/process.go | 167 ----- pkg/process/process_global.go | 133 ---- pkg/process/process_test.go | 227 ------- pkg/process/runner.go | 293 -------- pkg/process/runner_test.go | 176 ----- pkg/process/service.go | 378 ----------- pkg/process/service_test.go | 257 ------- pkg/process/types.go | 89 --- pkg/ratelimit/ratelimit.go | 389 ----------- pkg/ratelimit/ratelimit_test.go | 176 ----- pkg/repos/registry.go | 342 ---------- pkg/repos/registry_test.go | 486 ------------- pkg/session/html.go | 257 ------- pkg/session/html_test.go | 194 ------ pkg/session/parser.go | 384 ----------- pkg/session/parser_test.go | 498 -------------- pkg/session/search.go | 54 -- pkg/session/search_test.go | 172 ----- pkg/session/video.go | 128 ---- pkg/session/video_test.go | 189 ------ pkg/store/store.go | 153 ----- pkg/store/store_test.go | 103 --- pkg/webview/actions.go | 548 --------------- pkg/webview/angular.go | 627 ----------------- pkg/webview/cdp.go | 388 ----------- pkg/webview/console.go | 509 -------------- pkg/webview/webview.go | 734 -------------------- pkg/webview/webview_test.go | 335 --------- pkg/workspace/service.go | 149 ---- pkg/workspace/service_test.go | 55 -- pkg/ws/ws.go | 466 ------------- pkg/ws/ws_test.go | 792 ---------------------- 113 files changed, 28 insertions(+), 20424 deletions(-) delete mode 100644 .coderabbit.yaml delete mode 100644 .gitleaks.toml delete mode 100644 CONTRIBUTING.md delete mode 100644 GEMINI.md delete mode 100644 Taskfile.yaml delete mode 100644 lem-chat/.gitignore delete mode 100644 lem-chat/index.html delete mode 100644 lem-chat/package-lock.json delete mode 100644 lem-chat/package.json delete mode 100644 lem-chat/src/lem-chat.ts delete mode 100644 lem-chat/src/lem-input.ts delete mode 100644 lem-chat/src/lem-message.ts delete mode 100644 lem-chat/src/lem-messages.ts delete mode 100644 lem-chat/src/markdown.ts delete mode 100644 lem-chat/src/styles.ts delete mode 100644 lem-chat/src/types.ts delete mode 100644 lem-chat/tsconfig.json delete mode 100644 mkdocs.yml delete mode 100644 pkg/cache/cache.go delete mode 100644 pkg/cache/cache_test.go delete mode 100644 pkg/config/config.go delete mode 100644 pkg/config/config_test.go delete mode 100644 pkg/config/env.go delete mode 100644 pkg/config/service.go delete mode 100644 pkg/lab/collector/collector.go delete mode 100644 pkg/lab/collector/docker.go delete mode 100644 pkg/lab/collector/forgejo.go delete mode 100644 pkg/lab/collector/huggingface.go delete mode 100644 pkg/lab/collector/influxdb.go delete mode 100644 pkg/lab/collector/prometheus.go delete mode 100644 pkg/lab/collector/services.go delete mode 100644 pkg/lab/collector/system.go delete mode 100644 pkg/lab/collector/training.go delete mode 100644 pkg/lab/config.go delete mode 100644 pkg/lab/config_test.go delete mode 100644 pkg/lab/handler/api.go delete mode 100644 pkg/lab/handler/chart.go delete mode 100644 pkg/lab/handler/static/.gitkeep delete mode 100644 pkg/lab/handler/templates/agents.html delete mode 100644 pkg/lab/handler/templates/dashboard.html delete mode 100644 pkg/lab/handler/templates/dataset.html delete mode 100644 pkg/lab/handler/templates/golden-set.html delete mode 100644 pkg/lab/handler/templates/layout.html delete mode 100644 pkg/lab/handler/templates/models.html delete mode 100644 pkg/lab/handler/templates/runs.html delete mode 100644 pkg/lab/handler/templates/services.html delete mode 100644 pkg/lab/handler/templates/training.html delete mode 100644 pkg/lab/handler/web.go delete mode 100644 pkg/lab/model.go delete mode 100644 pkg/lab/store.go delete mode 100644 pkg/lab/store_test.go delete mode 100644 pkg/manifest/loader.go delete mode 100644 pkg/manifest/loader_test.go delete mode 100644 pkg/manifest/manifest.go delete mode 100644 pkg/manifest/manifest_test.go delete mode 100644 pkg/manifest/sign.go delete mode 100644 pkg/manifest/sign_test.go delete mode 100644 pkg/marketplace/installer.go delete mode 100644 pkg/marketplace/installer_test.go delete mode 100644 pkg/marketplace/marketplace.go delete mode 100644 pkg/marketplace/marketplace_test.go delete mode 100644 pkg/plugin/config.go delete mode 100644 pkg/plugin/installer.go delete mode 100644 pkg/plugin/installer_test.go delete mode 100644 pkg/plugin/loader.go delete mode 100644 pkg/plugin/loader_test.go delete mode 100644 pkg/plugin/manifest.go delete mode 100644 pkg/plugin/manifest_test.go delete mode 100644 pkg/plugin/plugin.go delete mode 100644 pkg/plugin/plugin_test.go delete mode 100644 pkg/plugin/registry.go delete mode 100644 pkg/plugin/registry_test.go delete mode 100644 pkg/process/actions.go delete mode 100644 pkg/process/buffer.go delete mode 100644 pkg/process/buffer_test.go delete mode 100644 pkg/process/exec/exec.go delete mode 100644 pkg/process/exec/exec_test.go delete mode 100644 pkg/process/exec/logger.go delete mode 100644 pkg/process/global_test.go delete mode 100644 pkg/process/process.go delete mode 100644 pkg/process/process_global.go delete mode 100644 pkg/process/process_test.go delete mode 100644 pkg/process/runner.go delete mode 100644 pkg/process/runner_test.go delete mode 100644 pkg/process/service.go delete mode 100644 pkg/process/service_test.go delete mode 100644 pkg/process/types.go delete mode 100644 pkg/ratelimit/ratelimit.go delete mode 100644 pkg/ratelimit/ratelimit_test.go delete mode 100644 pkg/repos/registry.go delete mode 100644 pkg/repos/registry_test.go delete mode 100644 pkg/session/html.go delete mode 100644 pkg/session/html_test.go delete mode 100644 pkg/session/parser.go delete mode 100644 pkg/session/parser_test.go delete mode 100644 pkg/session/search.go delete mode 100644 pkg/session/search_test.go delete mode 100644 pkg/session/video.go delete mode 100644 pkg/session/video_test.go delete mode 100644 pkg/store/store.go delete mode 100644 pkg/store/store_test.go delete mode 100644 pkg/webview/actions.go delete mode 100644 pkg/webview/angular.go delete mode 100644 pkg/webview/cdp.go delete mode 100644 pkg/webview/console.go delete mode 100644 pkg/webview/webview.go delete mode 100644 pkg/webview/webview_test.go delete mode 100644 pkg/workspace/service.go delete mode 100644 pkg/workspace/service_test.go delete mode 100644 pkg/ws/ws.go delete mode 100644 pkg/ws/ws_test.go diff --git a/.coderabbit.yaml b/.coderabbit.yaml deleted file mode 100644 index ffeed06..0000000 --- a/.coderabbit.yaml +++ /dev/null @@ -1,15 +0,0 @@ -# CodeRabbit Configuration -# Manual trigger only: @coderabbitai review - -reviews: - auto_review: - enabled: false - review_status: false - - path_instructions: - - path: "cmd/**" - instructions: "CLI command code - check for proper cobra usage and flag handling" - - path: "pkg/**" - instructions: "Library code - ensure good API design and documentation" - - path: "internal/**" - instructions: "Internal packages - check for proper encapsulation" diff --git a/.gitleaks.toml b/.gitleaks.toml deleted file mode 100644 index 893d718..0000000 --- a/.gitleaks.toml +++ /dev/null @@ -1,10 +0,0 @@ -# Gitleaks configuration for host-uk/core -# Test fixtures contain private keys for cryptographic testing — not real secrets. - -[allowlist] - description = "Test fixture allowlist" - paths = [ - '''pkg/crypt/pgp/pgp_test\.go''', - '''pkg/crypt/rsa/rsa_test\.go''', - '''pkg/crypt/openpgp/test_util\.go''', - ] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 6b96297..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,35 +0,0 @@ -# Contributing - -Thank you for your interest in contributing! - -## Requirements -- **Go Version**: 1.26 or higher is required. -- **Tools**: `golangci-lint` and `task` (Taskfile.dev) are recommended. - -## Development Workflow -1. **Testing**: Ensure all tests pass before submitting changes. - ```bash - go test ./... - ``` -2. **Code Style**: All code must follow standard Go formatting. - ```bash - gofmt -w . - go vet ./... - ``` -3. **Linting**: We use `golangci-lint` to maintain code quality. - ```bash - golangci-lint run ./... - ``` - -## Commit Message Format -We follow the [Conventional Commits](https://www.conventionalcommits.org/) specification: -- `feat`: A new feature -- `fix`: A bug fix -- `docs`: Documentation changes -- `refactor`: A code change that neither fixes a bug nor adds a feature -- `chore`: Changes to the build process or auxiliary tools and libraries - -Example: `feat: add new endpoint for health check` - -## License -By contributing to this project, you agree that your contributions will be licensed under the **European Union Public Licence (EUPL-1.2)**. diff --git a/GEMINI.md b/GEMINI.md deleted file mode 100644 index 30a96e5..0000000 --- a/GEMINI.md +++ /dev/null @@ -1,55 +0,0 @@ -# GEMINI.md - -This file provides guidance for agentic interactions within this repository, specifically for Gemini and other MCP-compliant agents. - -## Agentic Context & MCP - -This project is built with an **Agentic** design philosophy. It is not exclusive to any single LLM provider (like Claude). - -- **MCP Support**: The system is designed to leverage the Model Context Protocol (MCP) to provide rich context and tools to agents. -- **Developer Image**: You are running within a standardized developer image (`host-uk/core` dev environment), ensuring consistent tooling and configuration. - -## Core CLI (Agent Interface) - -The `core` command is the primary interface for agents to manage the project. Agents should **always** prefer `core` commands over raw shell commands (like `go test`, `php artisan`, etc.). - -### Key Commands for Agents - -| Task | Command | Notes | -|------|---------|-------| -| **Health Check** | `core doctor` | Verify tools and environment | -| **Repo Status** | `core dev health` | Quick summary of all repos | -| **Work Status** | `core dev work --status` | Detailed dirty/ahead status | -| **Run Tests** | `core go test` | Run Go tests with correct flags | -| **Coverage** | `core go cov` | Generate coverage report | -| **Build** | `core build` | Build the project safely | -| **Search Code** | `core pkg search` | Find packages/repos | - -## Project Architecture - -Core is a Web3 Framework written in Go using Wails v3. - -### Core Framework - -- **Services**: Managed via dependency injection (`ServiceFor[T]()`). -- **Lifecycle**: `OnStartup` and `OnShutdown` hooks. -- **IPC**: Message-passing system for service communication. - -### Development Workflow - -1. **Check State**: `core dev work --status` -2. **Make Changes**: Modify code, add tests. -3. **Verify**: `core go test` (or `core php test` for PHP components). -4. **Commit**: `core dev commit` (or standard git if automated). -5. **Push**: `core dev push` (handles multiple repos). - -## Testing Standards - -- **Suffix Pattern**: - - `_Good`: Happy path - - `_Bad`: Expected errors - - `_Ugly`: Edge cases/panics - -## Go Workspace - -The project uses Go workspaces (`go.work`). Always run `core go work sync` after modifying modules. diff --git a/Taskfile.yaml b/Taskfile.yaml deleted file mode 100644 index 877af8c..0000000 --- a/Taskfile.yaml +++ /dev/null @@ -1,6 +0,0 @@ -version: '3' - -tasks: - build: - cmds: - - go build -o build/bin/core cmd/app/main.go diff --git a/go.mod b/go.mod index 49a5d75..3cb553f 100644 --- a/go.mod +++ b/go.mod @@ -3,33 +3,22 @@ module forge.lthn.ai/core/go go 1.26.0 require ( - forge.lthn.ai/Snider/Borg v0.2.1 - forge.lthn.ai/core/cli v0.1.0 - forge.lthn.ai/core/go-crypt v0.1.0 - forge.lthn.ai/core/go-devops v0.1.0 - forge.lthn.ai/core/go-io v0.0.1 + forge.lthn.ai/core/cli v0.1.1 + forge.lthn.ai/core/go-crypt v0.1.2 + forge.lthn.ai/core/go-devops v0.1.2 + forge.lthn.ai/core/go-i18n v0.1.1 + forge.lthn.ai/core/go-io v0.0.4 forge.lthn.ai/core/go-log v0.0.1 - github.com/aws/aws-sdk-go-v2 v1.41.3 - github.com/aws/aws-sdk-go-v2/service/s3 v1.96.4 github.com/gorilla/websocket v1.5.3 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 - golang.org/x/crypto v0.48.0 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.46.1 ) require ( - github.com/ProtonMail/go-crypto v1.3.0 // indirect - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.20 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.11 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.19 // indirect - github.com/aws/smithy-go v1.24.2 // indirect + forge.lthn.ai/core/go-inference v0.1.1 // indirect + github.com/ProtonMail/go-crypto v1.4.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/bubbletea v1.3.10 // indirect github.com/charmbracelet/colorprofile v0.4.2 // indirect @@ -67,6 +56,7 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.48.0 // indirect golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/term v0.40.0 // indirect diff --git a/go.sum b/go.sum index d91bf1e..9013624 100644 --- a/go.sum +++ b/go.sum @@ -1,39 +1,27 @@ -forge.lthn.ai/Snider/Borg v0.2.1 h1:Uf/YtUJLL8jlxTCjvP4J+5GHe3LLeALGtbh7zj8d8Qc= -forge.lthn.ai/Snider/Borg v0.2.1/go.mod h1:MVfolb7F6/A2LOIijcbBhWImu5db5NSMcSjvShMoMCA= -forge.lthn.ai/core/cli v0.1.0 h1:2XRiEMVzUElnQlZnHYDyfKIKQVPcCzGuYHlnz55GjsM= -forge.lthn.ai/core/cli v0.1.0/go.mod h1:mZ7dzccfzo0BP2dE7Mwuw9dXuIowiEd1G5ZGMoLuxVc= -forge.lthn.ai/core/go-crypt v0.1.0 h1:92gwdQi7iAwktpvZhL/8Cu+QS6xKCtGP4FJfyInPGnw= -forge.lthn.ai/core/go-crypt v0.1.0/go.mod h1:zVAgx6ZiGtC+dbX4R/VKvEPqsEqjyuLl4gQZH9SXBUw= -forge.lthn.ai/core/go-devops v0.1.0 h1:xT3J//gilwVz15ju63xhg/Lz700cOYjqQkRWhTZDHLk= -forge.lthn.ai/core/go-devops v0.1.0/go.mod h1:V5/YaRsrDsYlSnCCJXKX7h1zSbaGyRdRQApPF5XwGAo= -forge.lthn.ai/core/go-io v0.0.1 h1:N/GCl6Asusfr4gs53JZixJVtqcnerQ6GcxSN8F8iJXY= -forge.lthn.ai/core/go-io v0.0.1/go.mod h1:l+gG/G5TMIOTG8G7y0dg4fh1a7Suy8wCYVwsz4duV7M= +forge.lthn.ai/core/cli v0.1.1 h1:AEefSo0ydYV1bZAbUgxsg1mi/llnC3+jqkjR/PyGdj4= +forge.lthn.ai/core/cli v0.1.1/go.mod h1:gST3hY7vyrnlpLYtkRAQU2FyPxJBxLD1xa4+/KPOhn8= +forge.lthn.ai/core/go-crypt v0.1.2 h1:MpVOX9wu0pBTw2+qsExZy2J5n6lo1LjgwrOMQmHTKgc= +forge.lthn.ai/core/go-crypt v0.1.2/go.mod h1:1nD3bQ2NyK5iM2aCd+mi/+TTWwHEp+P/qf9tXLAUPuw= +forge.lthn.ai/core/go-devops v0.1.2 h1:H3MgGxnfoydZVFZU2ZxvkIbmPMiKmAfUuGOohkbyQBc= +forge.lthn.ai/core/go-devops v0.1.2/go.mod h1:48QM3Qv94NbcGF0Y16k7Z8o/wCQXxKwNTrU3F3qUMlQ= +forge.lthn.ai/core/go-i18n v0.1.0 h1:F7JVSoVkZtzx9JfhpntM9z3iQm1vnuMUi/Zklhz8PCI= +forge.lthn.ai/core/go-i18n v0.1.0/go.mod h1:Q4xsrxuNCl/6NfMv1daria7t1RSiyy8ml+6jiPtUcBs= +forge.lthn.ai/core/go-i18n v0.1.1 h1:wxKLPAdITSqcdOqzgwb3yzUgMLdOFi3E5LdV9OBi7eg= +forge.lthn.ai/core/go-i18n v0.1.1/go.mod h1:AGdDRA+Bo67FsU2XGpZxHIGEo6sfos41k0zHoCJ6j4c= +forge.lthn.ai/core/go-inference v0.0.2 h1:aHjBkYyLKxLr9tbO4AvzzV/lsZueGq/jeo33SLh113k= +forge.lthn.ai/core/go-inference v0.0.2/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw= +forge.lthn.ai/core/go-inference v0.1.1 h1:uM3dtWitE4vvSCwA6CNPA2l0BRAjUNelENh7z58aecU= +forge.lthn.ai/core/go-inference v0.1.1/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw= +forge.lthn.ai/core/go-io v0.0.3 h1:TlhYpGTyjPgAlbEHyYrVSeUChZPhJXcLZ7D/8IbFqfI= +forge.lthn.ai/core/go-io v0.0.3/go.mod h1:ZlU9OQpsvNFNmTJoaHbFIkisZyc0eCq0p8znVWQLRf0= +forge.lthn.ai/core/go-io v0.0.4 h1:vXs3JTWquZKKG48Tik54DlzqP0WRJD9rnpn/D0GlRDk= +forge.lthn.ai/core/go-io v0.0.4/go.mod h1:ZlU9OQpsvNFNmTJoaHbFIkisZyc0eCq0p8znVWQLRf0= forge.lthn.ai/core/go-log v0.0.1 h1:x/E6EfF9vixzqiLHQOl2KT25HyBcMc9qiBkomqVlpPg= forge.lthn.ai/core/go-log v0.0.1/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw= github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= -github.com/aws/aws-sdk-go-v2 v1.41.3 h1:4kQ/fa22KjDt13QCy1+bYADvdgcxpfH18f0zP542kZA= -github.com/aws/aws-sdk-go-v2 v1.41.3/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6 h1:N4lRUXZpZ1KVEUn6hxtco/1d2lgYhNn1fHkkl8WhlyQ= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 h1:/sECfyq2JTifMI2JPyZ4bdRN77zJmr6SrS1eL3augIA= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19/go.mod h1:dMf8A5oAqr9/oxOfLkC/c2LU/uMcALP0Rgn2BD5LWn0= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 h1:AWeJMk33GTBf6J20XJe6qZoRSJo0WfUhsMdUKhoODXE= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19/go.mod h1:+GWrYoaAsV7/4pNHpwh1kiNLXkKaSoppxQq9lbH8Ejw= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.20 h1:qi3e/dmpdONhj1RyIZdi6DKKpDXS5Lb8ftr3p7cyHJc= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.20/go.mod h1:V1K+TeJVD5JOk3D9e5tsX2KUdL7BlB+FV6cBhdobN8c= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 h1:XAq62tBTJP/85lFD5oqOOe7YYgWxY9LvWq8plyDvDVg= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.11 h1:BYf7XNsJMzl4mObARUBUib+j2tf0U//JAAtTnYqvqCw= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.11/go.mod h1:aEUS4WrNk/+FxkBZZa7tVgp4pGH+kFGW40Y8rCPqt5g= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 h1:X1Tow7suZk9UCJHE1Iw9GMZJJl0dAnKXXP1NaSDHwmw= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19/go.mod h1:/rARO8psX+4sfjUQXp5LLifjUt8DuATZ31WptNJTyQA= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.19 h1:JnQeStZvPHFHeyky/7LbMlyQjUa+jIBj36OlWm0pzIk= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.19/go.mod h1:HGyasyHvYdFQeJhvDHfH7HXkHh57htcJGKDZ+7z+I24= -github.com/aws/aws-sdk-go-v2/service/s3 v1.96.4 h1:4ExZyubQ6LQQVuF2Qp9OsfEvsTdAWh5Gfwf6PgIdLdk= -github.com/aws/aws-sdk-go-v2/service/s3 v1.96.4/go.mod h1:NF3JcMGOiARAss1ld3WGORCw71+4ExDD2cbbdKS5PpA= -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/ProtonMail/go-crypto v1.4.0 h1:Zq/pbM3F5DFgJiMouxEdSVY44MVoQNEKp5d5QxIQceQ= +github.com/ProtonMail/go-crypto v1.4.0/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= 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/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= diff --git a/lem-chat/.gitignore b/lem-chat/.gitignore deleted file mode 100644 index c2658d7..0000000 --- a/lem-chat/.gitignore +++ /dev/null @@ -1 +0,0 @@ -node_modules/ diff --git a/lem-chat/index.html b/lem-chat/index.html deleted file mode 100644 index 2f2d068..0000000 --- a/lem-chat/index.html +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - LEM Chat - - - - - - - diff --git a/lem-chat/package-lock.json b/lem-chat/package-lock.json deleted file mode 100644 index 306ea82..0000000 --- a/lem-chat/package-lock.json +++ /dev/null @@ -1,515 +0,0 @@ -{ - "name": "lem-chat", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "lem-chat", - "version": "0.1.0", - "license": "EUPL-1.2", - "devDependencies": { - "esbuild": "^0.25.0", - "typescript": "^5.7.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - } - } -} diff --git a/lem-chat/package.json b/lem-chat/package.json deleted file mode 100644 index fb841e9..0000000 --- a/lem-chat/package.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "lem-chat", - "version": "0.1.0", - "private": true, - "license": "EUPL-1.2", - "scripts": { - "build": "esbuild src/lem-chat.ts --bundle --format=esm --outfile=dist/lem-chat.js", - "watch": "esbuild src/lem-chat.ts --bundle --format=esm --outfile=dist/lem-chat.js --watch", - "dev": "esbuild src/lem-chat.ts --bundle --format=esm --outfile=dist/lem-chat.js --watch --servedir=.", - "typecheck": "tsc --noEmit" - }, - "devDependencies": { - "esbuild": "^0.25.0", - "typescript": "^5.7.0" - } -} diff --git a/lem-chat/src/lem-chat.ts b/lem-chat/src/lem-chat.ts deleted file mode 100644 index b379333..0000000 --- a/lem-chat/src/lem-chat.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { chatStyles } from './styles'; -import type { ChatMessage, ChatCompletionChunk, LemSendDetail } from './types'; -import { LemMessages } from './lem-messages'; -import { LemInput } from './lem-input'; -import './lem-message'; - -export class LemChat extends HTMLElement { - private shadow!: ShadowRoot; - private messages!: LemMessages; - private input!: LemInput; - private statusEl!: HTMLDivElement; - private history: ChatMessage[] = []; - private abortController: AbortController | null = null; - - static get observedAttributes(): string[] { - return ['endpoint', 'model', 'system-prompt', 'max-tokens', 'temperature']; - } - - constructor() { - super(); - this.shadow = this.attachShadow({ mode: 'open' }); - } - - connectedCallback(): void { - const style = document.createElement('style'); - style.textContent = chatStyles; - - const header = document.createElement('div'); - header.className = 'header'; - - this.statusEl = document.createElement('div'); - this.statusEl.className = 'header-status'; - - const icon = document.createElement('div'); - icon.className = 'header-icon'; - icon.textContent = 'L'; - - const title = document.createElement('div'); - title.className = 'header-title'; - title.textContent = 'LEM'; - - const modelLabel = document.createElement('div'); - modelLabel.className = 'header-model'; - modelLabel.textContent = this.getAttribute('model') || 'local'; - - header.appendChild(this.statusEl); - header.appendChild(icon); - header.appendChild(title); - header.appendChild(modelLabel); - - this.messages = document.createElement('lem-messages') as LemMessages; - this.input = document.createElement('lem-input') as LemInput; - - this.shadow.appendChild(style); - this.shadow.appendChild(header); - this.shadow.appendChild(this.messages); - this.shadow.appendChild(this.input); - - this.addEventListener('lem-send', ((e: Event) => { - const detail = (e as CustomEvent).detail; - this.handleSend(detail.text); - }) as EventListener); - - const systemPrompt = this.getAttribute('system-prompt'); - if (systemPrompt) { - this.history.push({ role: 'system', content: systemPrompt }); - } - - this.checkConnection(); - requestAnimationFrame(() => this.input.focus()); - } - - disconnectedCallback(): void { - this.abortController?.abort(); - } - - get endpoint(): string { - const attr = this.getAttribute('endpoint'); - if (!attr) return window.location.origin; - return attr; - } - - get model(): string { - return this.getAttribute('model') || ''; - } - - get maxTokens(): number { - const val = this.getAttribute('max-tokens'); - return val ? parseInt(val, 10) : 2048; - } - - get temperature(): number { - const val = this.getAttribute('temperature'); - return val ? parseFloat(val) : 0.7; - } - - private async checkConnection(): Promise { - try { - const resp = await fetch(`${this.endpoint}/v1/models`, { - signal: AbortSignal.timeout(3000), - }); - this.statusEl.classList.toggle('disconnected', !resp.ok); - } catch { - this.statusEl.classList.add('disconnected'); - } - } - - private async handleSend(text: string): Promise { - this.messages.addMessage('user', text); - this.history.push({ role: 'user', content: text }); - - const assistantMsg = this.messages.addMessage('assistant'); - assistantMsg.streaming = true; - this.input.disabled = true; - - this.abortController?.abort(); - this.abortController = new AbortController(); - - let fullResponse = ''; - - try { - const response = await fetch(`${this.endpoint}/v1/chat/completions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - signal: this.abortController.signal, - body: JSON.stringify({ - model: this.model, - messages: this.history, - max_tokens: this.maxTokens, - temperature: this.temperature, - stream: true, - }), - }); - - if (!response.ok) { - throw new Error(`Server error: ${response.status}`); - } - if (!response.body) { - throw new Error('No response body'); - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; - - for (const line of lines) { - if (!line.startsWith('data: ')) continue; - const data = line.slice(6).trim(); - if (data === '[DONE]') continue; - - try { - const chunk: ChatCompletionChunk = JSON.parse(data); - const delta = chunk.choices?.[0]?.delta; - if (delta?.content) { - fullResponse += delta.content; - assistantMsg.appendToken(delta.content); - this.messages.scrollToBottom(); - } - } catch { - // skip malformed chunks - } - } - } - } catch (err) { - if (err instanceof Error && err.name === 'AbortError') { - // user-initiated abort — ignore - } else { - const errorText = - err instanceof Error ? err.message : 'Connection failed'; - if (!fullResponse) { - assistantMsg.text = `\u26A0\uFE0F ${errorText}`; - } - this.statusEl.classList.add('disconnected'); - } - } finally { - assistantMsg.streaming = false; - this.input.disabled = false; - this.input.focus(); - this.abortController = null; - if (fullResponse) { - this.history.push({ role: 'assistant', content: fullResponse }); - } - } - } -} - -customElements.define('lem-chat', LemChat); diff --git a/lem-chat/src/lem-input.ts b/lem-chat/src/lem-input.ts deleted file mode 100644 index c0df959..0000000 --- a/lem-chat/src/lem-input.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { inputStyles } from './styles'; -import type { LemSendDetail } from './types'; - -export class LemInput extends HTMLElement { - private shadow!: ShadowRoot; - private textarea!: HTMLTextAreaElement; - private sendBtn!: HTMLButtonElement; - private _disabled = false; - - constructor() { - super(); - this.shadow = this.attachShadow({ mode: 'open' }); - } - - connectedCallback(): void { - const style = document.createElement('style'); - style.textContent = inputStyles; - - const wrapper = document.createElement('div'); - wrapper.className = 'input-wrapper'; - - this.textarea = document.createElement('textarea'); - this.textarea.rows = 1; - this.textarea.placeholder = 'Message LEM...'; - - this.sendBtn = document.createElement('button'); - this.sendBtn.className = 'send-btn'; - this.sendBtn.type = 'button'; - this.sendBtn.disabled = true; - this.sendBtn.appendChild(this.createSendIcon()); - - wrapper.appendChild(this.textarea); - wrapper.appendChild(this.sendBtn); - this.shadow.appendChild(style); - this.shadow.appendChild(wrapper); - - this.textarea.addEventListener('input', () => { - this.textarea.style.height = 'auto'; - this.textarea.style.height = - Math.min(this.textarea.scrollHeight, 120) + 'px'; - this.sendBtn.disabled = - this._disabled || this.textarea.value.trim() === ''; - }); - - this.textarea.addEventListener('keydown', (e: KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - this.submit(); - } - }); - - this.sendBtn.addEventListener('click', () => this.submit()); - } - - private createSendIcon(): SVGSVGElement { - const ns = 'http://www.w3.org/2000/svg'; - const svg = document.createElementNS(ns, 'svg'); - svg.setAttribute('viewBox', '0 0 24 24'); - svg.setAttribute('fill', 'none'); - svg.setAttribute('stroke', 'currentColor'); - svg.setAttribute('stroke-width', '2'); - svg.setAttribute('stroke-linecap', 'round'); - svg.setAttribute('stroke-linejoin', 'round'); - svg.setAttribute('width', '16'); - svg.setAttribute('height', '16'); - const line = document.createElementNS(ns, 'line'); - line.setAttribute('x1', '22'); - line.setAttribute('y1', '2'); - line.setAttribute('x2', '11'); - line.setAttribute('y2', '13'); - const polygon = document.createElementNS(ns, 'polygon'); - polygon.setAttribute('points', '22 2 15 22 11 13 2 9 22 2'); - svg.appendChild(line); - svg.appendChild(polygon); - return svg; - } - - private submit(): void { - const text = this.textarea.value.trim(); - if (!text || this._disabled) return; - this.dispatchEvent( - new CustomEvent('lem-send', { - bubbles: true, - composed: true, - detail: { text }, - }) - ); - this.textarea.value = ''; - this.textarea.style.height = 'auto'; - this.sendBtn.disabled = true; - this.textarea.focus(); - } - - get disabled(): boolean { - return this._disabled; - } - - set disabled(value: boolean) { - this._disabled = value; - this.textarea.disabled = value; - this.sendBtn.disabled = value || this.textarea.value.trim() === ''; - this.textarea.placeholder = value ? 'LEM is thinking...' : 'Message LEM...'; - } - - override focus(): void { - this.textarea?.focus(); - } -} - -customElements.define('lem-input', LemInput); diff --git a/lem-chat/src/lem-message.ts b/lem-chat/src/lem-message.ts deleted file mode 100644 index e085b8d..0000000 --- a/lem-chat/src/lem-message.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { messageStyles } from './styles'; -import { renderMarkdown } from './markdown'; - -interface ThinkSplit { - think: string | null; - response: string; -} - -export class LemMessage extends HTMLElement { - private shadow!: ShadowRoot; - private thinkPanel!: HTMLDivElement; - private thinkContent!: HTMLDivElement; - private thinkLabel!: HTMLDivElement; - private contentEl!: HTMLDivElement; - private cursorEl: HTMLSpanElement | null = null; - private _text = ''; - private _streaming = false; - private _thinkCollapsed = false; - - constructor() { - super(); - this.shadow = this.attachShadow({ mode: 'open' }); - } - - connectedCallback(): void { - const role = this.getAttribute('role') || 'user'; - - const style = document.createElement('style'); - style.textContent = messageStyles; - - const bubble = document.createElement('div'); - bubble.className = 'bubble'; - - const roleEl = document.createElement('div'); - roleEl.className = 'role'; - roleEl.textContent = role === 'assistant' ? 'LEM' : 'You'; - - this.thinkPanel = document.createElement('div'); - this.thinkPanel.className = 'think-panel'; - this.thinkPanel.style.display = 'none'; - - this.thinkLabel = document.createElement('div'); - this.thinkLabel.className = 'think-label'; - this.thinkLabel.textContent = '\u25BC reasoning'; - this.thinkLabel.addEventListener('click', () => { - this._thinkCollapsed = !this._thinkCollapsed; - this.thinkPanel.classList.toggle('collapsed', this._thinkCollapsed); - this.thinkLabel.textContent = this._thinkCollapsed - ? '\u25B6 reasoning' - : '\u25BC reasoning'; - }); - - this.thinkContent = document.createElement('div'); - this.thinkContent.className = 'think-content'; - this.thinkPanel.appendChild(this.thinkLabel); - this.thinkPanel.appendChild(this.thinkContent); - - this.contentEl = document.createElement('div'); - this.contentEl.className = 'content'; - - bubble.appendChild(roleEl); - if (role === 'assistant') { - bubble.appendChild(this.thinkPanel); - } - bubble.appendChild(this.contentEl); - - this.shadow.appendChild(style); - this.shadow.appendChild(bubble); - - if (this._text) { - this.updateContent(); - } - } - - get text(): string { - return this._text; - } - - set text(value: string) { - this._text = value; - this.updateContent(); - } - - get streaming(): boolean { - return this._streaming; - } - - set streaming(value: boolean) { - this._streaming = value; - this.updateContent(); - } - - appendToken(token: string): void { - this._text += token; - this.updateContent(); - } - - /** - * Splits text into think/response portions and renders each. - * - * Safety: renderMarkdown() escapes all HTML entities (& < > ") before any - * inline formatting is applied. The source is the local MLX model output, - * not arbitrary user HTML. Shadow DOM provides additional isolation. - */ - private updateContent(): void { - if (!this.contentEl) return; - const { think, response } = this.splitThink(this._text); - - if (think !== null && this.thinkPanel) { - this.thinkPanel.style.display = ''; - this.thinkContent.textContent = think; - } - - // renderMarkdown() escapes all HTML before formatting — safe for innerHTML - // within Shadow DOM isolation, sourced from local MLX model only - const responseHtml = renderMarkdown(response); - this.contentEl.innerHTML = responseHtml; - - if (this._streaming) { - if (!this.cursorEl) { - this.cursorEl = document.createElement('span'); - this.cursorEl.className = 'cursor'; - } - if (think !== null && !this._text.includes('')) { - this.thinkContent.appendChild(this.cursorEl); - } else { - const lastChild = this.contentEl.lastElementChild || this.contentEl; - lastChild.appendChild(this.cursorEl); - } - } - } - - private splitThink(text: string): ThinkSplit { - const thinkStart = text.indexOf(''); - if (thinkStart === -1) { - return { think: null, response: text }; - } - const afterOpen = thinkStart + ''.length; - const thinkEnd = text.indexOf('', afterOpen); - if (thinkEnd === -1) { - return { - think: text.slice(afterOpen).trim(), - response: text.slice(0, thinkStart).trim(), - }; - } - const thinkText = text.slice(afterOpen, thinkEnd).trim(); - const beforeThink = text.slice(0, thinkStart).trim(); - const afterThink = text.slice(thinkEnd + ''.length).trim(); - const response = [beforeThink, afterThink].filter(Boolean).join('\n'); - return { think: thinkText, response }; - } -} - -customElements.define('lem-message', LemMessage); diff --git a/lem-chat/src/lem-messages.ts b/lem-chat/src/lem-messages.ts deleted file mode 100644 index 1ec0f61..0000000 --- a/lem-chat/src/lem-messages.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { messagesStyles } from './styles'; -import type { LemMessage } from './lem-message'; - -export class LemMessages extends HTMLElement { - private shadow!: ShadowRoot; - private container!: HTMLDivElement; - private emptyEl!: HTMLDivElement; - private shouldAutoScroll = true; - - constructor() { - super(); - this.shadow = this.attachShadow({ mode: 'open' }); - } - - connectedCallback(): void { - const style = document.createElement('style'); - style.textContent = messagesStyles; - - this.container = document.createElement('div'); - - this.emptyEl = document.createElement('div'); - this.emptyEl.className = 'empty'; - const emptyIcon = document.createElement('div'); - emptyIcon.className = 'empty-icon'; - emptyIcon.textContent = '\u2728'; - const emptyText = document.createElement('div'); - emptyText.className = 'empty-text'; - emptyText.textContent = 'Start a conversation'; - this.emptyEl.appendChild(emptyIcon); - this.emptyEl.appendChild(emptyText); - - this.shadow.appendChild(style); - this.shadow.appendChild(this.emptyEl); - this.shadow.appendChild(this.container); - - this.addEventListener('scroll', () => { - const threshold = 60; - this.shouldAutoScroll = - this.scrollHeight - this.scrollTop - this.clientHeight < threshold; - }); - } - - addMessage(role: string, text?: string): LemMessage { - this.emptyEl.style.display = 'none'; - const msg = document.createElement('lem-message') as LemMessage; - msg.setAttribute('role', role); - this.container.appendChild(msg); - if (text) { - msg.text = text; - } - this.scrollToBottom(); - return msg; - } - - scrollToBottom(): void { - if (this.shouldAutoScroll) { - requestAnimationFrame(() => { - this.scrollTop = this.scrollHeight; - }); - } - } - - clear(): void { - this.container.replaceChildren(); - this.emptyEl.style.display = ''; - this.shouldAutoScroll = true; - } -} - -customElements.define('lem-messages', LemMessages); diff --git a/lem-chat/src/markdown.ts b/lem-chat/src/markdown.ts deleted file mode 100644 index e30a79e..0000000 --- a/lem-chat/src/markdown.ts +++ /dev/null @@ -1,80 +0,0 @@ -function escapeHtml(text: string): string { - return text - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); -} - -function parseInline(text: string): string { - let result = escapeHtml(text); - result = result.replace(/`([^`]+)`/g, '$1'); - result = result.replace(/\*\*(.+?)\*\*/g, '$1'); - result = result.replace(/__(.+?)__/g, '$1'); - result = result.replace(/(?$1'); - result = result.replace(/(?$1'); - return result; -} - -function wrapParagraph(lines: string[]): string { - const joined = lines.join('
'); - if (joined.startsWith('${joined}

`; -} - -export function renderMarkdown(text: string): string { - const lines = text.split('\n'); - const output: string[] = []; - let inCodeBlock = false; - let codeLines: string[] = []; - let codeLang = ''; - - for (const line of lines) { - if (line.trimStart().startsWith('```')) { - if (!inCodeBlock) { - inCodeBlock = true; - codeLang = line.trimStart().slice(3).trim(); - codeLines = []; - } else { - const langAttr = codeLang ? ` data-lang="${escapeHtml(codeLang)}"` : ''; - output.push(`${escapeHtml(codeLines.join('\n'))}`); - inCodeBlock = false; - codeLines = []; - codeLang = ''; - } - continue; - } - if (inCodeBlock) { - codeLines.push(line); - continue; - } - if (line.trim() === '') { - output.push(''); - continue; - } - output.push(parseInline(line)); - } - - if (inCodeBlock) { - const langAttr = codeLang ? ` data-lang="${escapeHtml(codeLang)}"` : ''; - output.push(`${escapeHtml(codeLines.join('\n'))}`); - } - - const paragraphs: string[] = []; - let current: string[] = []; - for (const line of output) { - if (line === '') { - if (current.length > 0) { - paragraphs.push(wrapParagraph(current)); - current = []; - } - } else { - current.push(line); - } - } - if (current.length > 0) { - paragraphs.push(wrapParagraph(current)); - } - - return paragraphs.join(''); -} diff --git a/lem-chat/src/styles.ts b/lem-chat/src/styles.ts deleted file mode 100644 index 5b26693..0000000 --- a/lem-chat/src/styles.ts +++ /dev/null @@ -1,325 +0,0 @@ -export const chatStyles = ` - :host { - display: flex; - flex-direction: column; - background: var(--lem-bg, #1a1a1e); - color: var(--lem-text, #e0e0e0); - font-family: var(--lem-font, system-ui, -apple-system, sans-serif); - font-size: 14px; - line-height: 1.5; - border-radius: 12px; - overflow: hidden; - border: 1px solid rgba(255, 255, 255, 0.08); - } - - .header { - display: flex; - align-items: center; - gap: 10px; - padding: 14px 18px; - background: rgba(255, 255, 255, 0.03); - border-bottom: 1px solid rgba(255, 255, 255, 0.06); - flex-shrink: 0; - } - - .header-icon { - width: 28px; - height: 28px; - border-radius: 8px; - background: var(--lem-accent, #5865f2); - display: flex; - align-items: center; - justify-content: center; - font-size: 14px; - font-weight: 700; - color: #fff; - } - - .header-title { - font-size: 15px; - font-weight: 600; - color: var(--lem-text, #e0e0e0); - } - - .header-model { - font-size: 11px; - color: rgba(255, 255, 255, 0.35); - margin-left: auto; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - } - - .header-status { - width: 8px; - height: 8px; - border-radius: 50%; - background: #43b581; - flex-shrink: 0; - } - - .header-status.disconnected { - background: #f04747; - } -`; - -export const messagesStyles = ` - :host { - display: block; - flex: 1; - overflow-y: auto; - overflow-x: hidden; - padding: 16px 0; - scroll-behavior: smooth; - } - - :host::-webkit-scrollbar { - width: 6px; - } - - :host::-webkit-scrollbar-track { - background: transparent; - } - - :host::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.12); - border-radius: 3px; - } - - .empty { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - height: 100%; - gap: 12px; - color: rgba(255, 255, 255, 0.25); - } - - .empty-icon { - font-size: 36px; - opacity: 0.4; - } - - .empty-text { - font-size: 14px; - } -`; - -export const messageStyles = ` - :host { - display: block; - padding: 6px 18px; - } - - :host([role="user"]) .bubble { - background: var(--lem-msg-user, #2a2a3e); - margin-left: 40px; - border-radius: 12px 12px 4px 12px; - } - - :host([role="assistant"]) .bubble { - background: var(--lem-msg-assistant, #1e1e2a); - margin-right: 40px; - border-radius: 12px 12px 12px 4px; - } - - .bubble { - padding: 10px 14px; - word-wrap: break-word; - overflow-wrap: break-word; - } - - .role { - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; - margin-bottom: 4px; - color: rgba(255, 255, 255, 0.35); - } - - :host([role="assistant"]) .role { - color: var(--lem-accent, #5865f2); - } - - .content { - color: var(--lem-text, #e0e0e0); - line-height: 1.6; - } - - .content p { - margin: 0 0 8px 0; - } - - .content p:last-child { - margin-bottom: 0; - } - - .content strong { - font-weight: 600; - color: #fff; - } - - .content em { - font-style: italic; - color: rgba(255, 255, 255, 0.8); - } - - .content code { - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 12px; - background: rgba(0, 0, 0, 0.3); - padding: 2px 5px; - border-radius: 4px; - color: #e8a0bf; - } - - .content pre { - margin: 8px 0; - padding: 12px; - background: rgba(0, 0, 0, 0.35); - border-radius: 8px; - overflow-x: auto; - border: 1px solid rgba(255, 255, 255, 0.06); - } - - .content pre code { - background: none; - padding: 0; - font-size: 12px; - color: #c9d1d9; - line-height: 1.5; - } - - .think-panel { - margin: 6px 0 8px; - padding: 8px 12px; - background: rgba(88, 101, 242, 0.06); - border-left: 2px solid rgba(88, 101, 242, 0.3); - border-radius: 0 6px 6px 0; - font-size: 12px; - color: rgba(255, 255, 255, 0.45); - line-height: 1.5; - max-height: 200px; - overflow-y: auto; - } - - .think-panel::-webkit-scrollbar { - width: 4px; - } - - .think-panel::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.1); - border-radius: 2px; - } - - .think-label { - font-size: 10px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; - color: rgba(88, 101, 242, 0.5); - margin-bottom: 4px; - cursor: pointer; - user-select: none; - } - - .think-label:hover { - color: rgba(88, 101, 242, 0.7); - } - - .think-panel.collapsed .think-content { - display: none; - } - - .cursor { - display: inline-block; - width: 7px; - height: 16px; - background: var(--lem-accent, #5865f2); - border-radius: 1px; - animation: blink 0.8s step-end infinite; - vertical-align: text-bottom; - margin-left: 2px; - } - - @keyframes blink { - 50% { opacity: 0; } - } -`; - -export const inputStyles = ` - :host { - display: block; - padding: 12px 16px 16px; - border-top: 1px solid rgba(255, 255, 255, 0.06); - flex-shrink: 0; - } - - .input-wrapper { - display: flex; - align-items: flex-end; - gap: 10px; - background: rgba(255, 255, 255, 0.05); - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 12px; - padding: 8px 12px; - transition: border-color 0.15s; - } - - .input-wrapper:focus-within { - border-color: var(--lem-accent, #5865f2); - } - - textarea { - flex: 1; - background: none; - border: none; - outline: none; - color: var(--lem-text, #e0e0e0); - font-family: inherit; - font-size: 14px; - line-height: 1.5; - resize: none; - max-height: 120px; - min-height: 22px; - padding: 0; - } - - textarea::placeholder { - color: rgba(255, 255, 255, 0.25); - } - - .send-btn { - background: var(--lem-accent, #5865f2); - border: none; - border-radius: 8px; - color: #fff; - width: 32px; - height: 32px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - transition: opacity 0.15s, transform 0.1s; - } - - .send-btn:hover { - opacity: 0.85; - } - - .send-btn:active { - transform: scale(0.95); - } - - .send-btn:disabled { - opacity: 0.3; - cursor: default; - transform: none; - } - - .send-btn svg { - width: 16px; - height: 16px; - } -`; diff --git a/lem-chat/src/types.ts b/lem-chat/src/types.ts deleted file mode 100644 index 96fbbf4..0000000 --- a/lem-chat/src/types.ts +++ /dev/null @@ -1,31 +0,0 @@ -export interface ChatMessage { - role: 'system' | 'user' | 'assistant'; - content: string; -} - -export interface ChatCompletionRequest { - model: string; - messages: ChatMessage[]; - max_tokens: number; - temperature: number; - stream: boolean; -} - -export interface ChatCompletionChunk { - id: string; - object: string; - created: number; - model: string; - choices: Array<{ - delta: { - role?: string; - content?: string; - }; - index: number; - finish_reason: string | null; - }>; -} - -export interface LemSendDetail { - text: string; -} diff --git a/lem-chat/tsconfig.json b/lem-chat/tsconfig.json deleted file mode 100644 index e8fb428..0000000 --- a/lem-chat/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "bundler", - "strict": true, - "noEmit": true, - "declaration": false, - "isolatedModules": true, - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "skipLibCheck": true - }, - "include": ["src/**/*.ts"] -} diff --git a/mkdocs.yml b/mkdocs.yml deleted file mode 100644 index 09585de..0000000 --- a/mkdocs.yml +++ /dev/null @@ -1,104 +0,0 @@ -site_name: Core Framework -site_url: https://core.help -site_description: 'A Web3 Framework for building Go desktop applications with Wails v3' -site_author: 'Snider' -repo_url: 'https://forge.lthn.ai/core/go' -repo_name: 'host-uk/core' - -theme: - name: material - palette: - - scheme: default - primary: deep purple - accent: purple - toggle: - icon: material/brightness-7 - name: Switch to dark mode - - scheme: slate - primary: deep purple - accent: purple - toggle: - icon: material/brightness-4 - name: Switch to light mode - features: - - navigation.tabs - - navigation.sections - - navigation.expand - - navigation.top - - search.suggest - - search.highlight - - content.tabs.link - - content.code.copy - -markdown_extensions: - - pymdownx.highlight: - anchor_linenums: true - - pymdownx.superfences - - pymdownx.tabbed: - alternate_style: true - - admonition - - pymdownx.details - - attr_list - - md_in_html - -nav: - - Home: index.md - - User Documentation: - - User Guide: user-guide.md - - FAQ: faq.md - - Troubleshooting: troubleshooting.md - - Workflows: workflows.md - - CLI Reference: - - Overview: cmd/index.md - - AI: cmd/ai/index.md - - Build: cmd/build/index.md - - CI: cmd/ci/index.md - - Dev: cmd/dev/index.md - - Go: cmd/go/index.md - - PHP: cmd/php/index.md - - SDK: cmd/sdk/index.md - - Setup: cmd/setup/index.md - - Doctor: cmd/doctor/index.md - - Test: cmd/test/index.md - - VM: cmd/vm/index.md - - Pkg: cmd/pkg/index.md - - Docs: cmd/docs/index.md - - Getting Started: - - Installation: getting-started/installation.md - - Quick Start: getting-started/quickstart.md - - Architecture: getting-started/architecture.md - - Core Framework: - - Overview: core/overview.md - - Services: core/services.md - - Lifecycle: core/lifecycle.md - - IPC & Actions: core/ipc.md - - Services: - - Config: services/config.md - - Display: services/display.md - - WebView: services/webview.md - - MCP: services/mcp.md - - Crypt: services/crypt.md - - I18n: services/i18n.md - - IO: services/io.md - - Workspace: services/workspace.md - - Help: services/help.md - - Extensions: - - Plugin System: extensions/plugins.md - - Module System: extensions/modules.md - - GUI Application: - - Overview: gui/overview.md - - MCP Bridge: gui/mcp-bridge.md - - API Reference: - - Core: api/core.md - - Display: api/display.md - - Development: - - Package Standards: pkg/PACKAGE_STANDARDS.md - - Internationalization: - - Overview: pkg/i18n/README.md - - Grammar: pkg/i18n/GRAMMAR.md - - Extending: pkg/i18n/EXTENDING.md - - Claude Skill: skill/index.md - - Reference: - - Configuration: configuration.md - - Migration: migration.md - - Glossary: glossary.md diff --git a/pkg/cache/cache.go b/pkg/cache/cache.go deleted file mode 100644 index 030fe1b..0000000 --- a/pkg/cache/cache.go +++ /dev/null @@ -1,171 +0,0 @@ -// Package cache provides a file-based cache for GitHub API responses. -package cache - -import ( - "encoding/json" - "errors" - "os" - "path/filepath" - "time" - - "forge.lthn.ai/core/go-io" -) - -// DefaultTTL is the default cache expiry time. -const DefaultTTL = 1 * time.Hour - -// Cache represents a file-based cache. -type Cache struct { - medium io.Medium - baseDir string - ttl time.Duration -} - -// Entry represents a cached item with metadata. -type Entry struct { - Data json.RawMessage `json:"data"` - CachedAt time.Time `json:"cached_at"` - ExpiresAt time.Time `json:"expires_at"` -} - -// New creates a new cache instance. -// If medium is nil, uses io.Local (filesystem). -// If baseDir is empty, uses .core/cache in current directory. -func New(medium io.Medium, baseDir string, ttl time.Duration) (*Cache, error) { - if medium == nil { - medium = io.Local - } - - if baseDir == "" { - // Use .core/cache in current working directory - cwd, err := os.Getwd() - if err != nil { - return nil, err - } - baseDir = filepath.Join(cwd, ".core", "cache") - } - - if ttl == 0 { - ttl = DefaultTTL - } - - // Ensure cache directory exists - if err := medium.EnsureDir(baseDir); err != nil { - return nil, err - } - - return &Cache{ - medium: medium, - baseDir: baseDir, - ttl: ttl, - }, nil -} - -// Path returns the full path for a cache key. -func (c *Cache) Path(key string) string { - return filepath.Join(c.baseDir, key+".json") -} - -// Get retrieves a cached item if it exists and hasn't expired. -func (c *Cache) Get(key string, dest any) (bool, error) { - path := c.Path(key) - - dataStr, err := c.medium.Read(path) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return false, nil - } - return false, err - } - - var entry Entry - if err := json.Unmarshal([]byte(dataStr), &entry); err != nil { - // Invalid cache file, treat as miss - return false, nil - } - - // Check expiry - if time.Now().After(entry.ExpiresAt) { - return false, nil - } - - // Unmarshal the actual data - if err := json.Unmarshal(entry.Data, dest); err != nil { - return false, err - } - - return true, nil -} - -// Set stores an item in the cache. -func (c *Cache) Set(key string, data any) error { - path := c.Path(key) - - // Ensure parent directory exists - if err := c.medium.EnsureDir(filepath.Dir(path)); err != nil { - return err - } - - // Marshal the data - dataBytes, err := json.Marshal(data) - if err != nil { - return err - } - - entry := Entry{ - Data: dataBytes, - CachedAt: time.Now(), - ExpiresAt: time.Now().Add(c.ttl), - } - - entryBytes, err := json.MarshalIndent(entry, "", " ") - if err != nil { - return err - } - - return c.medium.Write(path, string(entryBytes)) -} - -// Delete removes an item from the cache. -func (c *Cache) Delete(key string) error { - path := c.Path(key) - err := c.medium.Delete(path) - if errors.Is(err, os.ErrNotExist) { - return nil - } - return err -} - -// Clear removes all cached items. -func (c *Cache) Clear() error { - return c.medium.DeleteAll(c.baseDir) -} - -// Age returns how old a cached item is, or -1 if not cached. -func (c *Cache) Age(key string) time.Duration { - path := c.Path(key) - - dataStr, err := c.medium.Read(path) - if err != nil { - return -1 - } - - var entry Entry - if err := json.Unmarshal([]byte(dataStr), &entry); err != nil { - return -1 - } - - return time.Since(entry.CachedAt) -} - -// GitHub-specific cache keys - -// GitHubReposKey returns the cache key for an org's repo list. -func GitHubReposKey(org string) string { - return filepath.Join("github", org, "repos") -} - -// GitHubRepoKey returns the cache key for a specific repo's metadata. -func GitHubRepoKey(org, repo string) string { - return filepath.Join("github", org, repo, "meta") -} diff --git a/pkg/cache/cache_test.go b/pkg/cache/cache_test.go deleted file mode 100644 index e59ea10..0000000 --- a/pkg/cache/cache_test.go +++ /dev/null @@ -1,104 +0,0 @@ -package cache_test - -import ( - "testing" - "time" - - "forge.lthn.ai/core/go/pkg/cache" - "forge.lthn.ai/core/go-io" -) - -func TestCache(t *testing.T) { - m := io.NewMockMedium() - // Use a path that MockMedium will understand - baseDir := "/tmp/cache" - c, err := cache.New(m, baseDir, 1*time.Minute) - if err != nil { - t.Fatalf("failed to create cache: %v", err) - } - - key := "test-key" - data := map[string]string{"foo": "bar"} - - // Test Set - if err := c.Set(key, data); err != nil { - t.Errorf("Set failed: %v", err) - } - - // Test Get - var retrieved map[string]string - found, err := c.Get(key, &retrieved) - if err != nil { - t.Errorf("Get failed: %v", err) - } - if !found { - t.Error("expected to find cached item") - } - if retrieved["foo"] != "bar" { - t.Errorf("expected foo=bar, got %v", retrieved["foo"]) - } - - // Test Age - age := c.Age(key) - if age < 0 { - t.Error("expected age >= 0") - } - - // Test Delete - if err := c.Delete(key); err != nil { - t.Errorf("Delete failed: %v", err) - } - found, err = c.Get(key, &retrieved) - if err != nil { - t.Errorf("Get after delete returned an unexpected error: %v", err) - } - if found { - t.Error("expected item to be deleted") - } - - // Test Expiry - cshort, err := cache.New(m, "/tmp/cache-short", 10*time.Millisecond) - if err != nil { - t.Fatalf("failed to create short-lived cache: %v", err) - } - if err := cshort.Set(key, data); err != nil { - t.Fatalf("Set for expiry test failed: %v", err) - } - time.Sleep(50 * time.Millisecond) - found, err = cshort.Get(key, &retrieved) - if err != nil { - t.Errorf("Get for expired item returned an unexpected error: %v", err) - } - if found { - t.Error("expected item to be expired") - } - - // Test Clear - if err := c.Set("key1", data); err != nil { - t.Fatalf("Set for clear test failed for key1: %v", err) - } - if err := c.Set("key2", data); err != nil { - t.Fatalf("Set for clear test failed for key2: %v", err) - } - if err := c.Clear(); err != nil { - t.Errorf("Clear failed: %v", err) - } - found, err = c.Get("key1", &retrieved) - if err != nil { - t.Errorf("Get after clear returned an unexpected error: %v", err) - } - if found { - t.Error("expected key1 to be cleared") - } -} - -func TestCacheDefaults(t *testing.T) { - // Test default Medium (io.Local) and default TTL - c, err := cache.New(nil, "", 0) - if err != nil { - t.Fatalf("failed to create cache with defaults: %v", err) - } - if c == nil { - t.Fatal("expected cache instance") - } -} diff --git a/pkg/config/config.go b/pkg/config/config.go deleted file mode 100644 index 4a6458e..0000000 --- a/pkg/config/config.go +++ /dev/null @@ -1,212 +0,0 @@ -// Package config provides layered configuration management for the Core framework. -// -// Configuration values are resolved in priority order: defaults -> file -> env -> flags. -// Values are stored in a YAML file at ~/.core/config.yaml by default. -// -// Keys use dot notation for nested access: -// -// cfg.Set("dev.editor", "vim") -// var editor string -// cfg.Get("dev.editor", &editor) -package config - -import ( - "fmt" - "os" - "path/filepath" - "strings" - "sync" - - coreerr "forge.lthn.ai/core/go-log" - coreio "forge.lthn.ai/core/go-io" - core "forge.lthn.ai/core/go/pkg/framework/core" - "github.com/spf13/viper" - "gopkg.in/yaml.v3" -) - -// Config implements the core.Config interface with layered resolution. -// It uses viper as the underlying configuration engine. -type Config struct { - mu sync.RWMutex - v *viper.Viper - medium coreio.Medium - path string -} - -// Option is a functional option for configuring a Config instance. -type Option func(*Config) - -// WithMedium sets the storage medium for configuration file operations. -func WithMedium(m coreio.Medium) Option { - return func(c *Config) { - c.medium = m - } -} - -// WithPath sets the path to the configuration file. -func WithPath(path string) Option { - return func(c *Config) { - c.path = path - } -} - -// WithEnvPrefix sets the prefix for environment variables. -func WithEnvPrefix(prefix string) Option { - return func(c *Config) { - c.v.SetEnvPrefix(prefix) - } -} - -// New creates a new Config instance with the given options. -// If no medium is provided, it defaults to io.Local. -// If no path is provided, it defaults to ~/.core/config.yaml. -func New(opts ...Option) (*Config, error) { - c := &Config{ - v: viper.New(), - } - - // Configure viper defaults - c.v.SetEnvPrefix("CORE_CONFIG") - c.v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) - - for _, opt := range opts { - opt(c) - } - - if c.medium == nil { - c.medium = coreio.Local - } - - if c.path == "" { - home, err := os.UserHomeDir() - if err != nil { - return nil, coreerr.E("config.New", "failed to determine home directory", err) - } - c.path = filepath.Join(home, ".core", "config.yaml") - } - - c.v.AutomaticEnv() - - // Load existing config file if it exists - if c.medium.Exists(c.path) { - if err := c.LoadFile(c.medium, c.path); err != nil { - return nil, coreerr.E("config.New", "failed to load config file", err) - } - } - - return c, nil -} - -// LoadFile reads a configuration file from the given medium and path and merges it into the current config. -// It supports YAML and environment files (.env). -func (c *Config) LoadFile(m coreio.Medium, path string) error { - c.mu.Lock() - defer c.mu.Unlock() - - content, err := m.Read(path) - if err != nil { - return coreerr.E("config.LoadFile", "failed to read config file: "+path, err) - } - - ext := filepath.Ext(path) - if ext == "" && filepath.Base(path) == ".env" { - c.v.SetConfigType("env") - } else if ext != "" { - c.v.SetConfigType(strings.TrimPrefix(ext, ".")) - } else { - c.v.SetConfigType("yaml") - } - - if err := c.v.MergeConfig(strings.NewReader(content)); err != nil { - return coreerr.E("config.LoadFile", "failed to parse config file: "+path, err) - } - - return nil -} - -// Get retrieves a configuration value by dot-notation key and stores it in out. -// If key is empty, it unmarshals the entire configuration into out. -// The out parameter must be a pointer to the target type. -func (c *Config) Get(key string, out any) error { - c.mu.RLock() - defer c.mu.RUnlock() - - if key == "" { - return c.v.Unmarshal(out) - } - - if !c.v.IsSet(key) { - return coreerr.E("config.Get", fmt.Sprintf("key not found: %s", key), nil) - } - - return c.v.UnmarshalKey(key, out) -} - -// Set stores a configuration value by dot-notation key and persists to disk. -func (c *Config) Set(key string, v any) error { - c.mu.Lock() - defer c.mu.Unlock() - - c.v.Set(key, v) - - // Persist to disk - if err := Save(c.medium, c.path, c.v.AllSettings()); err != nil { - return coreerr.E("config.Set", "failed to save config", err) - } - - return nil -} - -// All returns a deep copy of all configuration values. -func (c *Config) All() map[string]any { - c.mu.RLock() - defer c.mu.RUnlock() - - return c.v.AllSettings() -} - -// Path returns the path to the configuration file. -func (c *Config) Path() string { - return c.path -} - -// Load reads a YAML configuration file from the given medium and path. -// Returns the parsed data as a map, or an error if the file cannot be read or parsed. -// Deprecated: Use Config.LoadFile instead. -func Load(m coreio.Medium, path string) (map[string]any, error) { - content, err := m.Read(path) - if err != nil { - return nil, coreerr.E("config.Load", "failed to read config file: "+path, err) - } - - v := viper.New() - v.SetConfigType("yaml") - if err := v.ReadConfig(strings.NewReader(content)); err != nil { - return nil, coreerr.E("config.Load", "failed to parse config file: "+path, err) - } - - return v.AllSettings(), nil -} - -// Save writes configuration data to a YAML file at the given path. -// It ensures the parent directory exists before writing. -func Save(m coreio.Medium, path string, data map[string]any) error { - out, err := yaml.Marshal(data) - if err != nil { - return coreerr.E("config.Save", "failed to marshal config", err) - } - - dir := filepath.Dir(path) - if err := m.EnsureDir(dir); err != nil { - return coreerr.E("config.Save", "failed to create config directory: "+dir, err) - } - - if err := m.Write(path, string(out)); err != nil { - return coreerr.E("config.Save", "failed to write config file: "+path, err) - } - - return nil -} - -// Ensure Config implements core.Config at compile time. -var _ core.Config = (*Config)(nil) diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go deleted file mode 100644 index f899b72..0000000 --- a/pkg/config/config_test.go +++ /dev/null @@ -1,277 +0,0 @@ -package config - -import ( - "os" - "testing" - - "forge.lthn.ai/core/go-io" - "github.com/stretchr/testify/assert" -) - -func TestConfig_Get_Good(t *testing.T) { - m := io.NewMockMedium() - - cfg, err := New(WithMedium(m), WithPath("/tmp/test/config.yaml")) - assert.NoError(t, err) - - err = cfg.Set("app.name", "core") - assert.NoError(t, err) - - var name string - err = cfg.Get("app.name", &name) - assert.NoError(t, err) - assert.Equal(t, "core", name) -} - -func TestConfig_Get_Bad(t *testing.T) { - m := io.NewMockMedium() - - cfg, err := New(WithMedium(m), WithPath("/tmp/test/config.yaml")) - assert.NoError(t, err) - - var value string - err = cfg.Get("nonexistent.key", &value) - assert.Error(t, err) - assert.Contains(t, err.Error(), "key not found") -} - -func TestConfig_Set_Good(t *testing.T) { - m := io.NewMockMedium() - - cfg, err := New(WithMedium(m), WithPath("/tmp/test/config.yaml")) - assert.NoError(t, err) - - err = cfg.Set("dev.editor", "vim") - assert.NoError(t, err) - - // Verify the value was saved to the medium - content, readErr := m.Read("/tmp/test/config.yaml") - assert.NoError(t, readErr) - assert.Contains(t, content, "editor: vim") - - // Verify we can read it back - var editor string - err = cfg.Get("dev.editor", &editor) - assert.NoError(t, err) - assert.Equal(t, "vim", editor) -} - -func TestConfig_Set_Nested_Good(t *testing.T) { - m := io.NewMockMedium() - - cfg, err := New(WithMedium(m), WithPath("/tmp/test/config.yaml")) - assert.NoError(t, err) - - err = cfg.Set("a.b.c", "deep") - assert.NoError(t, err) - - var val string - err = cfg.Get("a.b.c", &val) - assert.NoError(t, err) - assert.Equal(t, "deep", val) -} - -func TestConfig_All_Good(t *testing.T) { - m := io.NewMockMedium() - - cfg, err := New(WithMedium(m), WithPath("/tmp/test/config.yaml")) - assert.NoError(t, err) - - _ = cfg.Set("key1", "val1") - _ = cfg.Set("key2", "val2") - - all := cfg.All() - assert.Equal(t, "val1", all["key1"]) - assert.Equal(t, "val2", all["key2"]) -} - -func TestConfig_Path_Good(t *testing.T) { - m := io.NewMockMedium() - - cfg, err := New(WithMedium(m), WithPath("/custom/path/config.yaml")) - assert.NoError(t, err) - - assert.Equal(t, "/custom/path/config.yaml", cfg.Path()) -} - -func TestConfig_Load_Existing_Good(t *testing.T) { - m := io.NewMockMedium() - m.Files["/tmp/test/config.yaml"] = "app:\n name: existing\n" - - cfg, err := New(WithMedium(m), WithPath("/tmp/test/config.yaml")) - assert.NoError(t, err) - - var name string - err = cfg.Get("app.name", &name) - assert.NoError(t, err) - assert.Equal(t, "existing", name) -} - -func TestConfig_Env_Good(t *testing.T) { - // Set environment variable - t.Setenv("CORE_CONFIG_DEV_EDITOR", "nano") - - m := io.NewMockMedium() - cfg, err := New(WithMedium(m), WithPath("/tmp/test/config.yaml")) - assert.NoError(t, err) - - var editor string - err = cfg.Get("dev.editor", &editor) - assert.NoError(t, err) - assert.Equal(t, "nano", editor) -} - -func TestConfig_Env_Overrides_File_Good(t *testing.T) { - // Set file config - m := io.NewMockMedium() - m.Files["/tmp/test/config.yaml"] = "dev:\n editor: vim\n" - - // Set environment override - t.Setenv("CORE_CONFIG_DEV_EDITOR", "nano") - - cfg, err := New(WithMedium(m), WithPath("/tmp/test/config.yaml")) - assert.NoError(t, err) - - var editor string - err = cfg.Get("dev.editor", &editor) - assert.NoError(t, err) - assert.Equal(t, "nano", editor) -} - -func TestConfig_Assign_Types_Good(t *testing.T) { - m := io.NewMockMedium() - m.Files["/tmp/test/config.yaml"] = "count: 42\nenabled: true\nratio: 3.14\n" - - cfg, err := New(WithMedium(m), WithPath("/tmp/test/config.yaml")) - assert.NoError(t, err) - - var count int - err = cfg.Get("count", &count) - assert.NoError(t, err) - assert.Equal(t, 42, count) - - var enabled bool - err = cfg.Get("enabled", &enabled) - assert.NoError(t, err) - assert.True(t, enabled) - - var ratio float64 - err = cfg.Get("ratio", &ratio) - assert.NoError(t, err) - assert.InDelta(t, 3.14, ratio, 0.001) -} - -func TestConfig_Assign_Any_Good(t *testing.T) { - m := io.NewMockMedium() - - cfg, err := New(WithMedium(m), WithPath("/tmp/test/config.yaml")) - assert.NoError(t, err) - - _ = cfg.Set("key", "value") - - var val any - err = cfg.Get("key", &val) - assert.NoError(t, err) - assert.Equal(t, "value", val) -} - -func TestConfig_DefaultPath_Good(t *testing.T) { - m := io.NewMockMedium() - - cfg, err := New(WithMedium(m)) - assert.NoError(t, err) - - home, _ := os.UserHomeDir() - assert.Equal(t, home+"/.core/config.yaml", cfg.Path()) -} - -func TestLoadEnv_Good(t *testing.T) { - t.Setenv("CORE_CONFIG_FOO_BAR", "baz") - t.Setenv("CORE_CONFIG_SIMPLE", "value") - - result := LoadEnv("CORE_CONFIG_") - assert.Equal(t, "baz", result["foo.bar"]) - assert.Equal(t, "value", result["simple"]) -} - -func TestLoad_Bad(t *testing.T) { - m := io.NewMockMedium() - - _, err := Load(m, "/nonexistent/file.yaml") - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to read config file") -} - -func TestLoad_InvalidYAML_Bad(t *testing.T) { - m := io.NewMockMedium() - m.Files["/tmp/test/config.yaml"] = "invalid: yaml: content: [[[[" - - _, err := Load(m, "/tmp/test/config.yaml") - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to parse config file") -} - -func TestSave_Good(t *testing.T) { - m := io.NewMockMedium() - - data := map[string]any{ - "key": "value", - } - - err := Save(m, "/tmp/test/config.yaml", data) - assert.NoError(t, err) - - content, readErr := m.Read("/tmp/test/config.yaml") - assert.NoError(t, readErr) - assert.Contains(t, content, "key: value") -} - -func TestConfig_LoadFile_Env(t *testing.T) { - m := io.NewMockMedium() - m.Files["/.env"] = "FOO=bar\nBAZ=qux" - - cfg, err := New(WithMedium(m), WithPath("/config.yaml")) - assert.NoError(t, err) - - err = cfg.LoadFile(m, "/.env") - assert.NoError(t, err) - - var foo string - err = cfg.Get("foo", &foo) - assert.NoError(t, err) - assert.Equal(t, "bar", foo) -} - -func TestConfig_WithEnvPrefix(t *testing.T) { - t.Setenv("MYAPP_SETTING", "secret") - - m := io.NewMockMedium() - cfg, err := New(WithMedium(m), WithEnvPrefix("MYAPP")) - assert.NoError(t, err) - - var setting string - err = cfg.Get("setting", &setting) - assert.NoError(t, err) - assert.Equal(t, "secret", setting) -} - -func TestConfig_Get_EmptyKey(t *testing.T) { - m := io.NewMockMedium() - m.Files["/config.yaml"] = "app:\n name: test\nversion: 1" - - cfg, err := New(WithMedium(m), WithPath("/config.yaml")) - assert.NoError(t, err) - - type AppConfig struct { - App struct { - Name string `mapstructure:"name"` - } `mapstructure:"app"` - Version int `mapstructure:"version"` - } - - var full AppConfig - err = cfg.Get("", &full) - assert.NoError(t, err) - assert.Equal(t, "test", full.App.Name) - assert.Equal(t, 1, full.Version) -} diff --git a/pkg/config/env.go b/pkg/config/env.go deleted file mode 100644 index 711e3ec..0000000 --- a/pkg/config/env.go +++ /dev/null @@ -1,40 +0,0 @@ -package config - -import ( - "os" - "strings" -) - -// LoadEnv parses environment variables with the given prefix and returns -// them as a flat map with dot-notation keys. -// -// For example, with prefix "CORE_CONFIG_": -// -// CORE_CONFIG_FOO_BAR=baz -> {"foo.bar": "baz"} -// CORE_CONFIG_EDITOR=vim -> {"editor": "vim"} -func LoadEnv(prefix string) map[string]any { - result := make(map[string]any) - - for _, env := range os.Environ() { - if !strings.HasPrefix(env, prefix) { - continue - } - - parts := strings.SplitN(env, "=", 2) - if len(parts) != 2 { - continue - } - - name := parts[0] - value := parts[1] - - // Strip prefix and convert to dot notation - key := strings.TrimPrefix(name, prefix) - key = strings.ToLower(key) - key = strings.ReplaceAll(key, "_", ".") - - result[key] = value - } - - return result -} diff --git a/pkg/config/service.go b/pkg/config/service.go deleted file mode 100644 index 1192d06..0000000 --- a/pkg/config/service.go +++ /dev/null @@ -1,83 +0,0 @@ -package config - -import ( - "context" - - coreerr "forge.lthn.ai/core/go-log" - "forge.lthn.ai/core/go-io" - core "forge.lthn.ai/core/go/pkg/framework/core" -) - -// Service wraps Config as a framework service with lifecycle support. -type Service struct { - *core.ServiceRuntime[ServiceOptions] - config *Config -} - -// ServiceOptions holds configuration for the config service. -type ServiceOptions struct { - // Path overrides the default config file path. - Path string - // Medium overrides the default storage medium. - Medium io.Medium -} - -// NewConfigService creates a new config service factory for the Core framework. -// Register it with core.WithService(config.NewConfigService). -func NewConfigService(c *core.Core) (any, error) { - svc := &Service{ - ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{}), - } - return svc, nil -} - -// OnStartup loads the configuration file during application startup. -func (s *Service) OnStartup(_ context.Context) error { - opts := s.Opts() - - var configOpts []Option - if opts.Path != "" { - configOpts = append(configOpts, WithPath(opts.Path)) - } - if opts.Medium != nil { - configOpts = append(configOpts, WithMedium(opts.Medium)) - } - - cfg, err := New(configOpts...) - if err != nil { - return err - } - - s.config = cfg - return nil -} - -// Get retrieves a configuration value by key. -func (s *Service) Get(key string, out any) error { - if s.config == nil { - return coreerr.E("config.Service.Get", "config not loaded", nil) - } - return s.config.Get(key, out) -} - -// Set stores a configuration value by key. -func (s *Service) Set(key string, v any) error { - if s.config == nil { - return coreerr.E("config.Service.Set", "config not loaded", nil) - } - return s.config.Set(key, v) -} - -// LoadFile merges a configuration file into the central configuration. -func (s *Service) LoadFile(m io.Medium, path string) error { - if s.config == nil { - return coreerr.E("config.Service.LoadFile", "config not loaded", nil) - } - return s.config.LoadFile(m, path) -} - -// Ensure Service implements core.Config and Startable at compile time. -var ( - _ core.Config = (*Service)(nil) - _ core.Startable = (*Service)(nil) -) diff --git a/pkg/lab/collector/collector.go b/pkg/lab/collector/collector.go deleted file mode 100644 index 9796bc4..0000000 --- a/pkg/lab/collector/collector.go +++ /dev/null @@ -1,82 +0,0 @@ -package collector - -import ( - "context" - "log/slog" - "sync" - "time" -) - -type Collector interface { - Name() string - Collect(ctx context.Context) error -} - -type Registry struct { - mu sync.Mutex - entries []entry - logger *slog.Logger -} - -type entry struct { - c Collector - interval time.Duration - cancel context.CancelFunc -} - -func NewRegistry(logger *slog.Logger) *Registry { - return &Registry{logger: logger} -} - -func (r *Registry) Register(c Collector, interval time.Duration) { - r.mu.Lock() - defer r.mu.Unlock() - r.entries = append(r.entries, entry{c: c, interval: interval}) -} - -func (r *Registry) Start(ctx context.Context) { - r.mu.Lock() - defer r.mu.Unlock() - - for i := range r.entries { - e := &r.entries[i] - cctx, cancel := context.WithCancel(ctx) - e.cancel = cancel - go r.run(cctx, e.c, e.interval) - } -} - -func (r *Registry) run(ctx context.Context, c Collector, interval time.Duration) { - r.logger.Info("collector started", "name", c.Name(), "interval", interval) - - // Run immediately on start. - if err := c.Collect(ctx); err != nil { - r.logger.Warn("collector error", "name", c.Name(), "err", err) - } - - ticker := time.NewTicker(interval) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - r.logger.Info("collector stopped", "name", c.Name()) - return - case <-ticker.C: - if err := c.Collect(ctx); err != nil { - r.logger.Warn("collector error", "name", c.Name(), "err", err) - } - } - } -} - -func (r *Registry) Stop() { - r.mu.Lock() - defer r.mu.Unlock() - - for _, e := range r.entries { - if e.cancel != nil { - e.cancel() - } - } -} diff --git a/pkg/lab/collector/docker.go b/pkg/lab/collector/docker.go deleted file mode 100644 index 87f1f05..0000000 --- a/pkg/lab/collector/docker.go +++ /dev/null @@ -1,94 +0,0 @@ -package collector - -import ( - "context" - "encoding/json" - "fmt" - "net" - "net/http" - "time" - - "forge.lthn.ai/core/go/pkg/lab" -) - -type Docker struct { - store *lab.Store -} - -func NewDocker(s *lab.Store) *Docker { - return &Docker{store: s} -} - -func (d *Docker) Name() string { return "docker" } - -func (d *Docker) Collect(ctx context.Context) error { - client := &http.Client{ - Timeout: 5 * time.Second, - Transport: &http.Transport{ - DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { - return net.Dial("unix", "/var/run/docker.sock") - }, - }, - } - - req, err := http.NewRequestWithContext(ctx, "GET", "http://docker/containers/json?all=true", nil) - if err != nil { - return err - } - - resp, err := client.Do(req) - if err != nil { - d.store.SetError("docker", err) - return err - } - defer resp.Body.Close() - - var containers []struct { - Names []string `json:"Names"` - Image string `json:"Image"` - State string `json:"State"` - Status string `json:"Status"` - Created int64 `json:"Created"` - } - - if err := json.NewDecoder(resp.Body).Decode(&containers); err != nil { - d.store.SetError("docker", err) - return err - } - - var result []lab.Container - for _, c := range containers { - name := "" - if len(c.Names) > 0 { - name = c.Names[0] - if len(name) > 0 && name[0] == '/' { - name = name[1:] - } - } - - created := time.Unix(c.Created, 0) - uptime := "" - if c.State == "running" { - d := time.Since(created) - days := int(d.Hours()) / 24 - hours := int(d.Hours()) % 24 - if days > 0 { - uptime = fmt.Sprintf("%dd %dh", days, hours) - } else { - uptime = fmt.Sprintf("%dh %dm", hours, int(d.Minutes())%60) - } - } - - result = append(result, lab.Container{ - Name: name, - Status: c.State, - Image: c.Image, - Uptime: uptime, - Created: created, - }) - } - - d.store.SetContainers(result) - d.store.SetError("docker", nil) - return nil -} diff --git a/pkg/lab/collector/forgejo.go b/pkg/lab/collector/forgejo.go deleted file mode 100644 index b8186ac..0000000 --- a/pkg/lab/collector/forgejo.go +++ /dev/null @@ -1,130 +0,0 @@ -package collector - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "time" - - "forge.lthn.ai/core/go/pkg/lab" -) - -type Forgejo struct { - url string - token string - store *lab.Store -} - -func NewForgejo(forgeURL, token string, s *lab.Store) *Forgejo { - return &Forgejo{url: forgeURL, token: token, store: s} -} - -func (f *Forgejo) Name() string { return "forgejo" } - -func (f *Forgejo) Collect(ctx context.Context) error { - if f.token == "" { - return nil - } - - commits, err := f.recentActivity(ctx) - if err != nil { - f.store.SetError("forgejo", err) - return err - } - - f.store.SetCommits(commits) - f.store.SetError("forgejo", nil) - return nil -} - -type forgeRepo struct { - FullName string `json:"full_name"` - UpdatedAt time.Time `json:"updated_at"` -} - -type forgeCommit struct { - SHA string `json:"sha"` - Commit struct { - Message string `json:"message"` - Author struct { - Name string `json:"name"` - Date time.Time `json:"date"` - } `json:"author"` - } `json:"commit"` -} - -func (f *Forgejo) recentActivity(ctx context.Context) ([]lab.Commit, error) { - // Get recently updated repos - repos, err := f.apiGet(ctx, "/api/v1/repos/search?sort=updated&order=desc&limit=5") - if err != nil { - return nil, err - } - - var repoList []forgeRepo - if err := json.Unmarshal(repos, &repoList); err != nil { - // The search API wraps in {"data": [...], "ok": true} - var wrapped struct { - Data []forgeRepo `json:"data"` - } - if err2 := json.Unmarshal(repos, &wrapped); err2 != nil { - return nil, err - } - repoList = wrapped.Data - } - - var commits []lab.Commit - for _, repo := range repoList { - if len(commits) >= 10 { - break - } - data, err := f.apiGet(ctx, fmt.Sprintf("/api/v1/repos/%s/commits?limit=2", repo.FullName)) - if err != nil { - continue - } - var fc []forgeCommit - if err := json.Unmarshal(data, &fc); err != nil { - continue - } - for _, c := range fc { - msg := c.Commit.Message - if len(msg) > 80 { - msg = msg[:77] + "..." - } - commits = append(commits, lab.Commit{ - SHA: c.SHA[:8], - Message: msg, - Author: c.Commit.Author.Name, - Repo: repo.FullName, - Timestamp: c.Commit.Author.Date, - }) - } - } - - return commits, nil -} - -func (f *Forgejo) apiGet(ctx context.Context, path string) (json.RawMessage, error) { - req, err := http.NewRequestWithContext(ctx, "GET", f.url+path, nil) - if err != nil { - return nil, err - } - req.Header.Set("Authorization", "token "+f.token) - - client := &http.Client{Timeout: 10 * time.Second} - resp, err := client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return nil, fmt.Errorf("forgejo %s returned %d", path, resp.StatusCode) - } - - var raw json.RawMessage - if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { - return nil, err - } - return raw, nil -} diff --git a/pkg/lab/collector/huggingface.go b/pkg/lab/collector/huggingface.go deleted file mode 100644 index 6d65f07..0000000 --- a/pkg/lab/collector/huggingface.go +++ /dev/null @@ -1,55 +0,0 @@ -package collector - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "time" - - "forge.lthn.ai/core/go/pkg/lab" -) - -type HuggingFace struct { - author string - store *lab.Store -} - -func NewHuggingFace(author string, s *lab.Store) *HuggingFace { - return &HuggingFace{author: author, store: s} -} - -func (h *HuggingFace) Name() string { return "huggingface" } - -func (h *HuggingFace) Collect(ctx context.Context) error { - u := fmt.Sprintf("https://huggingface.co/api/models?author=%s&sort=downloads&direction=-1&limit=20", h.author) - - req, err := http.NewRequestWithContext(ctx, "GET", u, nil) - if err != nil { - return err - } - - client := &http.Client{Timeout: 10 * time.Second} - resp, err := client.Do(req) - if err != nil { - h.store.SetError("huggingface", err) - return err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - err := fmt.Errorf("HuggingFace API returned %d", resp.StatusCode) - h.store.SetError("huggingface", err) - return err - } - - var models []lab.HFModel - if err := json.NewDecoder(resp.Body).Decode(&models); err != nil { - h.store.SetError("huggingface", err) - return err - } - - h.store.SetModels(models) - h.store.SetError("huggingface", nil) - return nil -} diff --git a/pkg/lab/collector/influxdb.go b/pkg/lab/collector/influxdb.go deleted file mode 100644 index 950c80c..0000000 --- a/pkg/lab/collector/influxdb.go +++ /dev/null @@ -1,358 +0,0 @@ -package collector - -import ( - "cmp" - "context" - "encoding/json" - "fmt" - "net/http" - "slices" - "strings" - "time" - - "forge.lthn.ai/core/go/pkg/lab" -) - -type InfluxDB struct { - cfg *lab.Config - store *lab.Store -} - -func NewInfluxDB(cfg *lab.Config, s *lab.Store) *InfluxDB { - return &InfluxDB{cfg: cfg, store: s} -} - -func (i *InfluxDB) Name() string { return "influxdb" } - -func (i *InfluxDB) Collect(ctx context.Context) error { - if i.cfg.InfluxURL == "" || i.cfg.InfluxToken == "" { - return nil - } - - data := lab.BenchmarkData{ - Loss: make(map[string][]lab.LossPoint), - Content: make(map[string][]lab.ContentPoint), - Capability: make(map[string][]lab.CapabilityPoint), - CapabilityJudge: make(map[string][]lab.CapabilityJudgePoint), - UpdatedAt: time.Now(), - } - - // Collect all run identifiers from each measurement. - runSet := map[string]lab.BenchmarkRun{} - - // Training loss data. - if rows, err := i.query(ctx, "SELECT run_id, model, iteration, loss, loss_type, learning_rate, iterations_per_sec, tokens_per_sec FROM training_loss ORDER BY run_id, iteration"); err == nil { - for _, row := range rows { - rid := jsonStr(row["run_id"]) - mdl := jsonStr(row["model"]) - if rid == "" { - continue - } - runSet[rid] = lab.BenchmarkRun{RunID: rid, Model: mdl, Type: "training"} - data.Loss[rid] = append(data.Loss[rid], lab.LossPoint{ - Iteration: jsonInt(row["iteration"]), - Loss: jsonFloat(row["loss"]), - LossType: jsonStr(row["loss_type"]), - LearningRate: jsonFloat(row["learning_rate"]), - TokensPerSec: jsonFloat(row["tokens_per_sec"]), - }) - } - } - - // Content scores. - if rows, err := i.query(ctx, "SELECT run_id, model, label, dimension, score, iteration, has_kernel FROM content_score ORDER BY run_id, iteration, dimension"); err == nil { - for _, row := range rows { - rid := jsonStr(row["run_id"]) - mdl := jsonStr(row["model"]) - if rid == "" { - continue - } - if _, ok := runSet[rid]; !ok { - runSet[rid] = lab.BenchmarkRun{RunID: rid, Model: mdl, Type: "content"} - } - hk := jsonStr(row["has_kernel"]) - data.Content[rid] = append(data.Content[rid], lab.ContentPoint{ - Label: jsonStr(row["label"]), - Dimension: jsonStr(row["dimension"]), - Score: jsonFloat(row["score"]), - Iteration: jsonInt(row["iteration"]), - HasKernel: hk == "true" || hk == "True", - }) - } - } - - // Capability scores. - if rows, err := i.query(ctx, "SELECT run_id, model, label, category, accuracy, correct, total, iteration FROM capability_score ORDER BY run_id, iteration, category"); err == nil { - for _, row := range rows { - rid := jsonStr(row["run_id"]) - mdl := jsonStr(row["model"]) - if rid == "" { - continue - } - if _, ok := runSet[rid]; !ok { - runSet[rid] = lab.BenchmarkRun{RunID: rid, Model: mdl, Type: "capability"} - } - data.Capability[rid] = append(data.Capability[rid], lab.CapabilityPoint{ - Label: jsonStr(row["label"]), - Category: jsonStr(row["category"]), - Accuracy: jsonFloat(row["accuracy"]), - Correct: jsonInt(row["correct"]), - Total: jsonInt(row["total"]), - Iteration: jsonInt(row["iteration"]), - }) - } - } - - // Capability judge scores (0-10 per probe). - if rows, err := i.query(ctx, "SELECT run_id, model, label, probe_id, category, reasoning, correctness, clarity, avg, iteration FROM capability_judge ORDER BY run_id, iteration, probe_id"); err == nil { - for _, row := range rows { - rid := jsonStr(row["run_id"]) - if rid == "" { - continue - } - data.CapabilityJudge[rid] = append(data.CapabilityJudge[rid], lab.CapabilityJudgePoint{ - Label: jsonStr(row["label"]), - ProbeID: jsonStr(row["probe_id"]), - Category: jsonStr(row["category"]), - Reasoning: jsonFloat(row["reasoning"]), - Correctness: jsonFloat(row["correctness"]), - Clarity: jsonFloat(row["clarity"]), - Avg: jsonFloat(row["avg"]), - Iteration: jsonInt(row["iteration"]), - }) - } - } - - // Build sorted runs list. - for _, r := range runSet { - data.Runs = append(data.Runs, r) - } - slices.SortFunc(data.Runs, func(a, b lab.BenchmarkRun) int { - if c := cmp.Compare(a.Model, b.Model); c != 0 { - return c - } - return cmp.Compare(a.RunID, b.RunID) - }) - - i.store.SetBenchmarks(data) - - // Live training run statuses. - var runStatuses []lab.TrainingRunStatus - if rows, err := i.query(ctx, "SELECT model, run_id, status, iteration, total_iters, pct FROM training_status ORDER BY time DESC LIMIT 50"); err == nil { - // Deduplicate: keep only the latest status per run_id. - seen := map[string]bool{} - for _, row := range rows { - rid := jsonStr(row["run_id"]) - if rid == "" || seen[rid] { - continue - } - seen[rid] = true - rs := lab.TrainingRunStatus{ - Model: jsonStr(row["model"]), - RunID: rid, - Status: jsonStr(row["status"]), - Iteration: jsonInt(row["iteration"]), - TotalIters: jsonInt(row["total_iters"]), - Pct: jsonFloat(row["pct"]), - } - // Find latest loss for this run from already-collected data. - if lossPoints, ok := data.Loss[rid]; ok { - for j := len(lossPoints) - 1; j >= 0; j-- { - if lossPoints[j].LossType == "train" && rs.LastLoss == 0 { - rs.LastLoss = lossPoints[j].Loss - rs.TokensSec = lossPoints[j].TokensPerSec - } - if lossPoints[j].LossType == "val" && rs.ValLoss == 0 { - rs.ValLoss = lossPoints[j].Loss - } - if rs.LastLoss > 0 && rs.ValLoss > 0 { - break - } - } - } - runStatuses = append(runStatuses, rs) - } - } - i.store.SetTrainingRuns(runStatuses) - - // Golden set data explorer — query gold_gen (real-time per-generation records). - gs := lab.GoldenSetSummary{TargetTotal: 15000, UpdatedAt: time.Now()} - - // Try real-time gold_gen first (populated by lem_generate.py directly). - if rows, err := i.query(ctx, "SELECT count(DISTINCT i) AS total, count(DISTINCT d) AS domains, count(DISTINCT v) AS voices, avg(gen_time) AS avg_t, avg(chars) AS avg_c FROM gold_gen"); err == nil && len(rows) > 0 { - r := rows[0] - total := jsonInt(r["total"]) - if total > 0 { - gs.Available = true - gs.TotalExamples = total - gs.Domains = jsonInt(r["domains"]) - gs.Voices = jsonInt(r["voices"]) - gs.AvgGenTime = jsonFloat(r["avg_t"]) - gs.AvgResponseChars = jsonFloat(r["avg_c"]) - gs.CompletionPct = float64(total) / float64(gs.TargetTotal) * 100 - } - } - - // Fallback to pipeline.py metrics if gold_gen isn't populated. - if !gs.Available { - if rows, err := i.query(ctx, "SELECT total_examples, domains, voices, avg_gen_time, avg_response_chars, completion_pct FROM golden_set_stats ORDER BY time DESC LIMIT 1"); err == nil && len(rows) > 0 { - r := rows[0] - gs.Available = true - gs.TotalExamples = jsonInt(r["total_examples"]) - gs.Domains = jsonInt(r["domains"]) - gs.Voices = jsonInt(r["voices"]) - gs.AvgGenTime = jsonFloat(r["avg_gen_time"]) - gs.AvgResponseChars = jsonFloat(r["avg_response_chars"]) - gs.CompletionPct = jsonFloat(r["completion_pct"]) - } - } - - if gs.Available { - // Per-domain from gold_gen. - if rows, err := i.query(ctx, "SELECT d, count(DISTINCT i) AS n, avg(gen_time) AS avg_t FROM gold_gen GROUP BY d ORDER BY n DESC"); err == nil && len(rows) > 0 { - for _, r := range rows { - gs.DomainStats = append(gs.DomainStats, lab.DomainStat{ - Domain: jsonStr(r["d"]), - Count: jsonInt(r["n"]), - AvgGenTime: jsonFloat(r["avg_t"]), - }) - } - } - // Fallback to pipeline stats. - if len(gs.DomainStats) == 0 { - if rows, err := i.query(ctx, "SELECT DISTINCT domain, count, avg_gen_time FROM golden_set_domain ORDER BY count DESC"); err == nil { - for _, r := range rows { - gs.DomainStats = append(gs.DomainStats, lab.DomainStat{ - Domain: jsonStr(r["domain"]), - Count: jsonInt(r["count"]), - AvgGenTime: jsonFloat(r["avg_gen_time"]), - }) - } - } - } - - // Per-voice from gold_gen. - if rows, err := i.query(ctx, "SELECT v, count(DISTINCT i) AS n, avg(chars) AS avg_c, avg(gen_time) AS avg_t FROM gold_gen GROUP BY v ORDER BY n DESC"); err == nil && len(rows) > 0 { - for _, r := range rows { - gs.VoiceStats = append(gs.VoiceStats, lab.VoiceStat{ - Voice: jsonStr(r["v"]), - Count: jsonInt(r["n"]), - AvgChars: jsonFloat(r["avg_c"]), - AvgGenTime: jsonFloat(r["avg_t"]), - }) - } - } - // Fallback. - if len(gs.VoiceStats) == 0 { - if rows, err := i.query(ctx, "SELECT DISTINCT voice, count, avg_chars, avg_gen_time FROM golden_set_voice ORDER BY count DESC"); err == nil { - for _, r := range rows { - gs.VoiceStats = append(gs.VoiceStats, lab.VoiceStat{ - Voice: jsonStr(r["voice"]), - Count: jsonInt(r["count"]), - AvgChars: jsonFloat(r["avg_chars"]), - AvgGenTime: jsonFloat(r["avg_gen_time"]), - }) - } - } - } - } - // Worker activity. - if rows, err := i.query(ctx, "SELECT w, count(DISTINCT i) AS n, max(time) AS last_seen FROM gold_gen GROUP BY w ORDER BY n DESC"); err == nil { - for _, r := range rows { - gs.Workers = append(gs.Workers, lab.WorkerStat{ - Worker: jsonStr(r["w"]), - Count: jsonInt(r["n"]), - }) - } - } - - i.store.SetGoldenSet(gs) - - // Dataset stats (from DuckDB, pushed as dataset_stats measurement). - ds := lab.DatasetSummary{UpdatedAt: time.Now()} - if rows, err := i.query(ctx, "SELECT table, rows FROM dataset_stats ORDER BY rows DESC"); err == nil && len(rows) > 0 { - ds.Available = true - for _, r := range rows { - ds.Tables = append(ds.Tables, lab.DatasetTable{ - Name: jsonStr(r["table"]), - Rows: jsonInt(r["rows"]), - }) - } - } - i.store.SetDataset(ds) - - i.store.SetError("influxdb", nil) - return nil -} - -func (i *InfluxDB) query(ctx context.Context, sql string) ([]map[string]any, error) { - ctx, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - - body := fmt.Sprintf(`{"db":%q,"q":%q}`, i.cfg.InfluxDB, sql) - req, err := http.NewRequestWithContext(ctx, "POST", i.cfg.InfluxURL+"/api/v3/query_sql", strings.NewReader(body)) - if err != nil { - return nil, err - } - req.Header.Set("Authorization", "Bearer "+i.cfg.InfluxToken) - req.Header.Set("Content-Type", "application/json") - - resp, err := http.DefaultClient.Do(req) - if err != nil { - i.store.SetError("influxdb", err) - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - err := fmt.Errorf("influxdb query returned %d", resp.StatusCode) - i.store.SetError("influxdb", err) - return nil, err - } - - var rows []map[string]any - if err := json.NewDecoder(resp.Body).Decode(&rows); err != nil { - return nil, err - } - return rows, nil -} - -// JSON value helpers — InfluxDB 3 returns typed JSON values. - -func jsonStr(v any) string { - if v == nil { - return "" - } - if s, ok := v.(string); ok { - return s - } - return fmt.Sprintf("%v", v) -} - -func jsonFloat(v any) float64 { - if v == nil { - return 0 - } - switch n := v.(type) { - case float64: - return n - case json.Number: - f, _ := n.Float64() - return f - } - return 0 -} - -func jsonInt(v any) int { - if v == nil { - return 0 - } - switch n := v.(type) { - case float64: - return int(n) - case json.Number: - i, _ := n.Int64() - return int(i) - } - return 0 -} diff --git a/pkg/lab/collector/prometheus.go b/pkg/lab/collector/prometheus.go deleted file mode 100644 index 319fc6c..0000000 --- a/pkg/lab/collector/prometheus.go +++ /dev/null @@ -1,104 +0,0 @@ -package collector - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/url" - "strconv" - "time" - - "forge.lthn.ai/core/go/pkg/lab" -) - -type Prometheus struct { - url string - store *lab.Store -} - -func NewPrometheus(promURL string, s *lab.Store) *Prometheus { - return &Prometheus{url: promURL, store: s} -} - -func (p *Prometheus) Name() string { return "prometheus" } - -func (p *Prometheus) Collect(ctx context.Context) error { - // Machine stats are handled by the system collector (direct /proc + SSH). - // This collector only queries agent metrics from Prometheus. - agents := lab.AgentSummary{} - if v, err := p.query(ctx, "agents_registered_total"); err == nil && v != nil { - agents.RegisteredTotal = int(*v) - agents.Available = true - } - if v, err := p.query(ctx, "agents_queue_pending"); err == nil && v != nil { - agents.QueuePending = int(*v) - } - if v, err := p.query(ctx, "agents_tasks_completed_total"); err == nil && v != nil { - agents.TasksCompleted = int(*v) - } - if v, err := p.query(ctx, "agents_tasks_failed_total"); err == nil && v != nil { - agents.TasksFailed = int(*v) - } - if v, err := p.query(ctx, "agents_capabilities_count"); err == nil && v != nil { - agents.Capabilities = int(*v) - } - if v, err := p.query(ctx, "agents_heartbeat_age_seconds"); err == nil && v != nil { - agents.HeartbeatAge = *v - } - if v, err := p.query(ctx, "agents_exporter_up"); err == nil && v != nil { - agents.ExporterUp = *v > 0 - } - - p.store.SetAgents(agents) - p.store.SetError("prometheus", nil) - return nil -} - -type promResponse struct { - Status string `json:"status"` - Data struct { - ResultType string `json:"resultType"` - Result []struct { - Value [2]json.RawMessage `json:"value"` - } `json:"result"` - } `json:"data"` -} - -func (p *Prometheus) query(ctx context.Context, promql string) (*float64, error) { - u := fmt.Sprintf("%s/api/v1/query?query=%s", p.url, url.QueryEscape(promql)) - - req, err := http.NewRequestWithContext(ctx, "GET", u, nil) - if err != nil { - return nil, err - } - - client := &http.Client{Timeout: 5 * time.Second} - resp, err := client.Do(req) - if err != nil { - p.store.SetError("prometheus", err) - return nil, err - } - defer resp.Body.Close() - - var pr promResponse - if err := json.NewDecoder(resp.Body).Decode(&pr); err != nil { - return nil, err - } - - if pr.Status != "success" || len(pr.Data.Result) == 0 { - return nil, nil - } - - var valStr string - if err := json.Unmarshal(pr.Data.Result[0].Value[1], &valStr); err != nil { - return nil, err - } - - val, err := strconv.ParseFloat(valStr, 64) - if err != nil { - return nil, err - } - - return &val, nil -} diff --git a/pkg/lab/collector/services.go b/pkg/lab/collector/services.go deleted file mode 100644 index 858f54f..0000000 --- a/pkg/lab/collector/services.go +++ /dev/null @@ -1,107 +0,0 @@ -package collector - -import ( - "context" - "net/http" - "time" - - "forge.lthn.ai/core/go/pkg/lab" -) - -type Services struct { - store *lab.Store - services []lab.Service -} - -func NewServices(s *lab.Store) *Services { - return &Services{ - store: s, - services: []lab.Service{ - // Source Control - {Name: "Forgejo (primary)", URL: "https://forge.lthn.io", Category: "Source Control", Machine: "m3-ultra", Icon: "git"}, - {Name: "Forgejo (dev)", URL: "https://dev.lthn.io", Category: "Source Control", Machine: "snider-linux", Icon: "git"}, - {Name: "Forgejo (QA)", URL: "https://qa.lthn.io", Category: "Source Control", Machine: "gateway", Icon: "git"}, - {Name: "Forgejo (devops)", URL: "https://devops.lthn.io", Category: "Source Control", Machine: "gateway", Icon: "git"}, - {Name: "Forgejo Pages", URL: "https://host-uk.pages.lthn.io", Category: "Source Control", Machine: "snider-linux", Icon: "web"}, - - // CI/CD - {Name: "Woodpecker CI", URL: "https://ci.lthn.io", Category: "CI/CD", Machine: "snider-linux", Icon: "ci"}, - - // Monitoring - {Name: "Grafana", URL: "https://grafana.lthn.io", Category: "Monitoring", Machine: "snider-linux", Icon: "chart"}, - {Name: "Traefik Dashboard", URL: "https://traefik.lthn.io", Category: "Monitoring", Machine: "snider-linux", Icon: "route"}, - {Name: "Portainer", URL: "https://portainer.lthn.io", Category: "Monitoring", Machine: "snider-linux", Icon: "container"}, - {Name: "MantisBT", URL: "https://bugs.lthn.io", Category: "Monitoring", Machine: "snider-linux", Icon: "bug"}, - - // AI & Models - {Name: "Ollama API", URL: "https://ollama.lthn.io", Category: "AI", Machine: "snider-linux", Icon: "ai"}, - {Name: "AnythingLLM", URL: "https://anythingllm.lthn.io", Category: "AI", Machine: "snider-linux", Icon: "ai"}, - {Name: "Argilla", URL: "https://argilla.lthn.io", Category: "AI", Machine: "snider-linux", Icon: "data"}, - {Name: "Lab Helper API", URL: "http://10.69.69.108:9800", Category: "AI", Machine: "m3-ultra", Icon: "api"}, - {Name: "Lab Dashboard", URL: "https://lab.lthn.io", Category: "AI", Machine: "snider-linux", Icon: "web"}, - - // Media & Content - {Name: "Jellyfin", URL: "https://media.lthn.io", Category: "Media", Machine: "m3-ultra", Icon: "media"}, - {Name: "Immich Photos", URL: "https://photos.lthn.io", Category: "Media", Machine: "m3-ultra", Icon: "photo"}, - - // Social - {Name: "Mastodon", URL: "https://fedi.lthn.io", Category: "Social", Machine: "snider-linux", Icon: "social"}, - {Name: "Mixpost", URL: "https://social.lthn.io", Category: "Social", Machine: "snider-linux", Icon: "social"}, - - // i18n - {Name: "Weblate", URL: "https://i18n.lthn.io", Category: "Translation", Machine: "snider-linux", Icon: "i18n"}, - - // Infra - {Name: "dAppCo.re CDN", URL: "https://dappco.re", Category: "Infrastructure", Machine: "snider-linux", Icon: "cdn"}, - {Name: "lthn.ai Landing", URL: "https://lthn.ai", Category: "Infrastructure", Machine: "snider-linux", Icon: "web"}, - }, - } -} - -func (s *Services) Name() string { return "services" } - -func (s *Services) Collect(ctx context.Context) error { - client := &http.Client{ - Timeout: 5 * time.Second, - CheckRedirect: func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse // don't follow redirects - }, - } - - for i := range s.services { - s.services[i].Status = checkHealth(ctx, client, s.services[i].URL) - } - - result := make([]lab.Service, len(s.services)) - copy(result, s.services) - s.store.SetServices(result) - s.store.SetError("services", nil) - return nil -} - -func checkHealth(ctx context.Context, client *http.Client, url string) string { - // Try HEAD first, fall back to GET if HEAD fails. - req, err := http.NewRequestWithContext(ctx, "HEAD", url, nil) - if err != nil { - return "unavailable" - } - - resp, err := client.Do(req) - if err != nil { - // Retry with GET (some servers reject HEAD). - req2, _ := http.NewRequestWithContext(ctx, "GET", url, nil) - if req2 == nil { - return "unavailable" - } - resp, err = client.Do(req2) - if err != nil { - return "unavailable" - } - } - resp.Body.Close() - - if resp.StatusCode < 500 { - return "ok" - } - return "unavailable" -} diff --git a/pkg/lab/collector/system.go b/pkg/lab/collector/system.go deleted file mode 100644 index abfb68f..0000000 --- a/pkg/lab/collector/system.go +++ /dev/null @@ -1,374 +0,0 @@ -package collector - -import ( - "bufio" - "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "runtime" - "strconv" - "strings" - "time" - - "forge.lthn.ai/core/go/pkg/lab" -) - -type System struct { - store *lab.Store - cfg *lab.Config -} - -func NewSystem(cfg *lab.Config, s *lab.Store) *System { - return &System{store: s, cfg: cfg} -} - -func (s *System) Name() string { return "system" } - -func (s *System) Collect(ctx context.Context) error { - var machines []lab.Machine - - // Collect local machine stats. - local := s.collectLocal() - machines = append(machines, local) - - // Collect M3 Ultra stats via SSH. - if s.cfg.M3Host != "" { - m3 := s.collectM3(ctx) - machines = append(machines, m3) - } - - s.store.SetMachines(machines) - s.store.SetError("system", nil) - return nil -} - -// --------------------------------------------------------------------------- -// Local (snider-linux) -// --------------------------------------------------------------------------- - -// procPath returns the path to a proc file, preferring /host/proc (Docker mount) over /proc. -func procPath(name string) string { - hp := "/host/proc/" + name - if _, err := os.Stat(hp); err == nil { - return hp - } - return "/proc/" + name -} - -func (s *System) collectLocal() lab.Machine { - m := lab.Machine{ - Name: "snider-linux", - Host: "localhost", - Status: lab.StatusOK, - CPUCores: runtime.NumCPU(), - } - - // Load average - if data, err := os.ReadFile(procPath("loadavg")); err == nil { - fields := strings.Fields(string(data)) - if len(fields) > 0 { - m.Load1, _ = strconv.ParseFloat(fields[0], 64) - } - } - - // Memory from host /proc/meminfo - if f, err := os.Open(procPath("meminfo")); err == nil { - defer f.Close() - var memTotal, memAvail float64 - scanner := bufio.NewScanner(f) - for scanner.Scan() { - line := scanner.Text() - if strings.HasPrefix(line, "MemTotal:") { - memTotal = parseMemInfoKB(line) - } else if strings.HasPrefix(line, "MemAvailable:") { - memAvail = parseMemInfoKB(line) - } - } - if memTotal > 0 { - m.MemTotalGB = memTotal / 1024 / 1024 - m.MemUsedGB = (memTotal - memAvail) / 1024 / 1024 - m.MemUsedPct = (1.0 - memAvail/memTotal) * 100 - } - } - - // Disk — use host root mount if available - diskTarget := "/" - if _, err := os.Stat("/host/root"); err == nil { - diskTarget = "/host/root" - } - if out, err := exec.Command("df", "-BG", diskTarget).Output(); err == nil { - lines := strings.Split(strings.TrimSpace(string(out)), "\n") - if len(lines) >= 2 { - fields := strings.Fields(lines[1]) - if len(fields) >= 5 { - m.DiskTotalGB = parseGB(fields[1]) - m.DiskUsedGB = parseGB(fields[2]) - pct := strings.TrimSuffix(fields[4], "%") - m.DiskUsedPct, _ = strconv.ParseFloat(pct, 64) - } - } - } - - // GPU via sysfs (works inside Docker with /host/drm mount) - s.collectGPUSysfs(&m) - - // Uptime - if data, err := os.ReadFile(procPath("uptime")); err == nil { - fields := strings.Fields(string(data)) - if len(fields) > 0 { - if secs, err := strconv.ParseFloat(fields[0], 64); err == nil { - m.Uptime = formatDuration(time.Duration(secs * float64(time.Second))) - } - } - } - - return m -} - -func (s *System) collectGPUSysfs(m *lab.Machine) { - // Try sysfs paths: /host/sys (Docker mount of /sys) or /sys (native) - drmBase := "/host/sys/class/drm" - if _, err := os.Stat(drmBase); err != nil { - drmBase = "/sys/class/drm" - } - - // Find the discrete GPU (largest VRAM) — card0 may be integrated - gpuDev := "" - var bestTotal float64 - for _, card := range []string{"card0", "card1", "card2"} { - p := fmt.Sprintf("%s/%s/device/mem_info_vram_total", drmBase, card) - if data, err := os.ReadFile(p); err == nil { - val, _ := strconv.ParseFloat(strings.TrimSpace(string(data)), 64) - if val > bestTotal { - bestTotal = val - gpuDev = fmt.Sprintf("%s/%s/device", drmBase, card) - } - } - } - if gpuDev == "" { - return - } - - m.GPUName = "AMD Radeon RX 7800 XT" - m.GPUVRAMTotal = bestTotal / 1024 / 1024 / 1024 - - if data, err := os.ReadFile(gpuDev + "/mem_info_vram_used"); err == nil { - val, _ := strconv.ParseFloat(strings.TrimSpace(string(data)), 64) - m.GPUVRAMUsed = val / 1024 / 1024 / 1024 - } - if m.GPUVRAMTotal > 0 { - m.GPUVRAMPct = m.GPUVRAMUsed / m.GPUVRAMTotal * 100 - } - - // Temperature — find hwmon under the device - matches, _ := filepath.Glob(gpuDev + "/hwmon/hwmon*/temp1_input") - if len(matches) > 0 { - if data, err := os.ReadFile(matches[0]); err == nil { - val, _ := strconv.ParseFloat(strings.TrimSpace(string(data)), 64) - m.GPUTemp = int(val / 1000) // millidegrees to degrees - } - } -} - -// --------------------------------------------------------------------------- -// M3 Ultra (via SSH) -// --------------------------------------------------------------------------- - -func (s *System) collectM3(ctx context.Context) lab.Machine { - m := lab.Machine{ - Name: "m3-ultra", - Host: s.cfg.M3Host, - Status: lab.StatusUnavailable, - GPUName: "Apple M3 Ultra (80 cores)", - } - - cmd := exec.CommandContext(ctx, "ssh", - "-o", "ConnectTimeout=5", - "-o", "BatchMode=yes", - "-i", s.cfg.M3SSHKey, - fmt.Sprintf("%s@%s", s.cfg.M3User, s.cfg.M3Host), - "printf '===CPU===\\n'; sysctl -n hw.ncpu; sysctl -n vm.loadavg; printf '===MEM===\\n'; sysctl -n hw.memsize; vm_stat; printf '===DISK===\\n'; df -k /; printf '===UPTIME===\\n'; uptime", - ) - - out, err := cmd.Output() - if err != nil { - return m - } - - m.Status = lab.StatusOK - s.parseM3Output(&m, string(out)) - return m -} - -func (s *System) parseM3Output(m *lab.Machine, output string) { - sections := splitSections(output) - - // CPU - if cpu, ok := sections["CPU"]; ok { - lines := strings.Split(strings.TrimSpace(cpu), "\n") - if len(lines) >= 1 { - m.CPUCores, _ = strconv.Atoi(strings.TrimSpace(lines[0])) - } - if len(lines) >= 2 { - // "{ 8.22 4.56 4.00 }" - loadStr := strings.Trim(strings.TrimSpace(lines[1]), "{ }") - fields := strings.Fields(loadStr) - if len(fields) >= 1 { - m.Load1, _ = strconv.ParseFloat(fields[0], 64) - } - } - } - - // Memory - if mem, ok := sections["MEM"]; ok { - lines := strings.Split(strings.TrimSpace(mem), "\n") - if len(lines) >= 1 { - bytes, _ := strconv.ParseFloat(strings.TrimSpace(lines[0]), 64) - m.MemTotalGB = bytes / 1024 / 1024 / 1024 - } - // Parse vm_stat: page size 16384, look for free/active/inactive/wired/speculative/compressor - var pageSize float64 = 16384 - var free, active, inactive, speculative, wired, compressor float64 - for _, line := range lines[1:] { - if strings.Contains(line, "page size of") { - // "Mach Virtual Memory Statistics: (page size of 16384 bytes)" - for _, word := range strings.Fields(line) { - if v, err := strconv.ParseFloat(word, 64); err == nil && v > 1000 { - pageSize = v - break - } - } - } - val := parseVMStatLine(line) - switch { - case strings.HasPrefix(line, "Pages free:"): - free = val - case strings.HasPrefix(line, "Pages active:"): - active = val - case strings.HasPrefix(line, "Pages inactive:"): - inactive = val - case strings.HasPrefix(line, "Pages speculative:"): - speculative = val - case strings.HasPrefix(line, "Pages wired"): - wired = val - case strings.HasPrefix(line, "Pages occupied by compressor:"): - compressor = val - } - } - usedPages := active + wired + compressor - totalPages := free + active + inactive + speculative + wired + compressor - if totalPages > 0 && m.MemTotalGB > 0 { - m.MemUsedGB = usedPages * pageSize / 1024 / 1024 / 1024 - m.MemUsedPct = m.MemUsedGB / m.MemTotalGB * 100 - } - } - - // Disk - if disk, ok := sections["DISK"]; ok { - lines := strings.Split(strings.TrimSpace(disk), "\n") - if len(lines) >= 2 { - fields := strings.Fields(lines[1]) - if len(fields) >= 5 { - totalKB, _ := strconv.ParseFloat(fields[1], 64) - usedKB, _ := strconv.ParseFloat(fields[2], 64) - m.DiskTotalGB = totalKB / 1024 / 1024 - m.DiskUsedGB = usedKB / 1024 / 1024 - if m.DiskTotalGB > 0 { - m.DiskUsedPct = m.DiskUsedGB / m.DiskTotalGB * 100 - } - } - } - } - - // Uptime — "13:20 up 3 days, 1:09, 3 users, load averages: ..." - if up, ok := sections["UPTIME"]; ok { - line := strings.TrimSpace(up) - if idx := strings.Index(line, "up "); idx >= 0 { - rest := line[idx+3:] - // Split on ", " and take parts until we hit one containing "user" - parts := strings.Split(rest, ", ") - var uptimeParts []string - for _, p := range parts { - if strings.Contains(p, "user") || strings.Contains(p, "load") { - break - } - uptimeParts = append(uptimeParts, p) - } - m.Uptime = strings.TrimSpace(strings.Join(uptimeParts, ", ")) - } - } -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -func splitSections(output string) map[string]string { - sections := make(map[string]string) - var current string - var buf strings.Builder - for _, line := range strings.Split(output, "\n") { - if strings.HasPrefix(line, "===") && strings.HasSuffix(line, "===") { - if current != "" { - sections[current] = buf.String() - buf.Reset() - } - current = strings.Trim(line, "=") - } else if current != "" { - buf.WriteString(line) - buf.WriteByte('\n') - } - } - if current != "" { - sections[current] = buf.String() - } - return sections -} - -func parseVMStatLine(line string) float64 { - // "Pages free: 2266867." - parts := strings.SplitN(line, ":", 2) - if len(parts) < 2 { - return 0 - } - val := strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(parts[1]), ".")) - f, _ := strconv.ParseFloat(val, 64) - return f -} - -func parseMemInfoKB(line string) float64 { - fields := strings.Fields(line) - if len(fields) < 2 { - return 0 - } - v, _ := strconv.ParseFloat(fields[1], 64) - return v -} - -func parseGB(s string) float64 { - s = strings.TrimSuffix(s, "G") - v, _ := strconv.ParseFloat(s, 64) - return v -} - -func parseBytesGB(line string) float64 { - // "GPU[0] : VRAM Total Memory (B): 17163091968" - parts := strings.Split(line, ":") - if len(parts) < 3 { - return 0 - } - val := strings.TrimSpace(parts[len(parts)-1]) - bytes, _ := strconv.ParseFloat(val, 64) - return bytes / 1024 / 1024 / 1024 -} - -func formatDuration(d time.Duration) string { - days := int(d.Hours()) / 24 - hours := int(d.Hours()) % 24 - if days > 0 { - return fmt.Sprintf("%dd %dh", days, hours) - } - return fmt.Sprintf("%dh %dm", hours, int(d.Minutes())%60) -} diff --git a/pkg/lab/collector/training.go b/pkg/lab/collector/training.go deleted file mode 100644 index e7ea22e..0000000 --- a/pkg/lab/collector/training.go +++ /dev/null @@ -1,123 +0,0 @@ -package collector - -import ( - "bufio" - "context" - "encoding/json" - "net/http" - "os" - "path/filepath" - "time" - - "forge.lthn.ai/core/go/pkg/lab" -) - -type Training struct { - cfg *lab.Config - store *lab.Store -} - -func NewTraining(cfg *lab.Config, s *lab.Store) *Training { - return &Training{cfg: cfg, store: s} -} - -func (t *Training) Name() string { return "training" } - -func (t *Training) Collect(ctx context.Context) error { - summary := lab.TrainingSummary{ - GoldTarget: 15000, - } - - // Fetch from M3 lab-helper API - if t.cfg.M3APIURL != "" { - t.fetchM3API(ctx, &summary) - } - - // Parse local intercept JSONL files - interceptDir := t.cfg.TrainingDataDir - if interceptDir != "" { - count, lastTime := countJSONLFiles(filepath.Join(interceptDir, "command-intercepts")) - summary.InterceptCount = count - summary.LastIntercept = lastTime - } - - // Count QA sessions - sessDir := filepath.Join(t.cfg.TrainingDataDir, "qa-epic-verification", "sessions") - if entries, err := os.ReadDir(sessDir); err == nil { - summary.SessionCount = len(entries) - } - - t.store.SetTraining(summary) - t.store.SetError("training", nil) - return nil -} - -type m3TrainingResponse struct { - GoldGenerated int `json:"gold_generated"` - GoldTarget int `json:"gold_target"` - GoldPercent float64 `json:"gold_percent"` - SeedsComplete int `json:"seeds_complete"` - GGUFCount int `json:"gguf_count"` - GGUFFiles []string `json:"gguf_files"` - AdapterCount int `json:"adapter_count"` -} - -func (t *Training) fetchM3API(ctx context.Context, summary *lab.TrainingSummary) { - ctx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - - req, err := http.NewRequestWithContext(ctx, "GET", t.cfg.M3APIURL+"/api/training", nil) - if err != nil { - return - } - - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.store.SetError("m3-api", err) - return - } - defer resp.Body.Close() - - var data m3TrainingResponse - if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { - return - } - - summary.GoldGenerated = data.GoldGenerated - summary.GoldAvailable = true - summary.GoldPercent = data.GoldPercent - summary.GGUFCount = data.GGUFCount - summary.GGUFFiles = data.GGUFFiles - summary.AdapterCount = data.AdapterCount - t.store.SetError("m3-api", nil) -} - -func countJSONLFiles(dir string) (int, time.Time) { - var total int - var lastTime time.Time - - files, err := filepath.Glob(filepath.Join(dir, "*.jsonl")) - if err != nil { - return 0, lastTime - } - - for _, f := range files { - file, err := os.Open(f) - if err != nil { - continue - } - scanner := bufio.NewScanner(file) - for scanner.Scan() { - total++ - var ev struct { - Timestamp time.Time `json:"timestamp"` - } - if json.Unmarshal(scanner.Bytes(), &ev) == nil && ev.Timestamp.After(lastTime) { - lastTime = ev.Timestamp - } - } - file.Close() - } - - return total, lastTime -} diff --git a/pkg/lab/config.go b/pkg/lab/config.go deleted file mode 100644 index 4f3dcbf..0000000 --- a/pkg/lab/config.go +++ /dev/null @@ -1,84 +0,0 @@ -package lab - -import ( - "os" - "strconv" -) - -type Config struct { - Addr string - - PrometheusURL string - PrometheusInterval int - - ForgeURL string - ForgeToken string - ForgeInterval int - - HFAuthor string - HFInterval int - - M3Host string - M3User string - M3SSHKey string - M3APIURL string - M3Interval int - - TrainingDataDir string - TrainingInterval int - - DockerInterval int - - InfluxURL string - InfluxToken string - InfluxDB string - InfluxInterval int -} - -func LoadConfig() *Config { - return &Config{ - Addr: env("ADDR", ":8080"), - - PrometheusURL: env("PROMETHEUS_URL", "http://prometheus:9090"), - PrometheusInterval: envInt("PROMETHEUS_INTERVAL", 15), - - ForgeURL: env("FORGE_URL", "https://forge.lthn.io"), - ForgeToken: env("FORGE_TOKEN", ""), - ForgeInterval: envInt("FORGE_INTERVAL", 60), - - HFAuthor: env("HF_AUTHOR", "lthn"), - HFInterval: envInt("HF_INTERVAL", 300), - - M3Host: env("M3_HOST", "10.69.69.108"), - M3User: env("M3_USER", "claude"), - M3SSHKey: env("M3_SSH_KEY", "/root/.ssh/id_ed25519"), - M3APIURL: env("M3_API_URL", "http://10.69.69.108:9800"), - M3Interval: envInt("M3_INTERVAL", 30), - - TrainingDataDir: env("TRAINING_DATA_DIR", "/data/training"), - TrainingInterval: envInt("TRAINING_INTERVAL", 60), - - DockerInterval: envInt("DOCKER_INTERVAL", 30), - - InfluxURL: env("INFLUX_URL", "http://localhost:8181"), - InfluxToken: env("INFLUX_TOKEN", ""), - InfluxDB: env("INFLUX_DB", "training"), - InfluxInterval: envInt("INFLUX_INTERVAL", 60), - } -} - -func env(key, fallback string) string { - if v := os.Getenv(key); v != "" { - return v - } - return fallback -} - -func envInt(key string, fallback int) int { - if v := os.Getenv(key); v != "" { - if n, err := strconv.Atoi(v); err == nil { - return n - } - } - return fallback -} diff --git a/pkg/lab/config_test.go b/pkg/lab/config_test.go deleted file mode 100644 index b4cc809..0000000 --- a/pkg/lab/config_test.go +++ /dev/null @@ -1,129 +0,0 @@ -package lab - -import ( - "os" - "testing" -) - -// ── LoadConfig defaults ──────────────────────────────────────────── - -func TestLoadConfig_Good_Defaults(t *testing.T) { - cfg := LoadConfig() - - if cfg.Addr != ":8080" { - t.Fatalf("expected :8080, got %s", cfg.Addr) - } - if cfg.PrometheusURL != "http://prometheus:9090" { - t.Fatalf("unexpected PrometheusURL: %s", cfg.PrometheusURL) - } - if cfg.PrometheusInterval != 15 { - t.Fatalf("expected 15, got %d", cfg.PrometheusInterval) - } - if cfg.ForgeURL != "https://forge.lthn.io" { - t.Fatalf("unexpected ForgeURL: %s", cfg.ForgeURL) - } - if cfg.ForgeInterval != 60 { - t.Fatalf("expected 60, got %d", cfg.ForgeInterval) - } - if cfg.HFAuthor != "lthn" { - t.Fatalf("expected lthn, got %s", cfg.HFAuthor) - } - if cfg.HFInterval != 300 { - t.Fatalf("expected 300, got %d", cfg.HFInterval) - } - if cfg.TrainingDataDir != "/data/training" { - t.Fatalf("unexpected TrainingDataDir: %s", cfg.TrainingDataDir) - } - if cfg.InfluxDB != "training" { - t.Fatalf("expected training, got %s", cfg.InfluxDB) - } -} - -// ── env override ─────────────────────────────────────────────────── - -func TestLoadConfig_Good_EnvOverride(t *testing.T) { - os.Setenv("ADDR", ":9090") - os.Setenv("FORGE_URL", "https://forge.lthn.ai") - os.Setenv("HF_AUTHOR", "snider") - defer func() { - os.Unsetenv("ADDR") - os.Unsetenv("FORGE_URL") - os.Unsetenv("HF_AUTHOR") - }() - - cfg := LoadConfig() - if cfg.Addr != ":9090" { - t.Fatalf("expected :9090, got %s", cfg.Addr) - } - if cfg.ForgeURL != "https://forge.lthn.ai" { - t.Fatalf("expected forge.lthn.ai, got %s", cfg.ForgeURL) - } - if cfg.HFAuthor != "snider" { - t.Fatalf("expected snider, got %s", cfg.HFAuthor) - } -} - -// ── envInt ───────────────────────────────────────────────────────── - -func TestLoadConfig_Good_IntEnvOverride(t *testing.T) { - os.Setenv("PROMETHEUS_INTERVAL", "30") - defer os.Unsetenv("PROMETHEUS_INTERVAL") - - cfg := LoadConfig() - if cfg.PrometheusInterval != 30 { - t.Fatalf("expected 30, got %d", cfg.PrometheusInterval) - } -} - -func TestLoadConfig_Bad_InvalidIntFallsBack(t *testing.T) { - os.Setenv("PROMETHEUS_INTERVAL", "not-a-number") - defer os.Unsetenv("PROMETHEUS_INTERVAL") - - cfg := LoadConfig() - if cfg.PrometheusInterval != 15 { - t.Fatalf("expected fallback 15, got %d", cfg.PrometheusInterval) - } -} - -// ── env / envInt helpers directly ────────────────────────────────── - -func TestEnv_Good(t *testing.T) { - os.Setenv("TEST_LAB_KEY", "hello") - defer os.Unsetenv("TEST_LAB_KEY") - - if got := env("TEST_LAB_KEY", "default"); got != "hello" { - t.Fatalf("expected hello, got %s", got) - } -} - -func TestEnv_Good_Fallback(t *testing.T) { - os.Unsetenv("TEST_LAB_MISSING") - if got := env("TEST_LAB_MISSING", "fallback"); got != "fallback" { - t.Fatalf("expected fallback, got %s", got) - } -} - -func TestEnvInt_Good(t *testing.T) { - os.Setenv("TEST_LAB_INT", "42") - defer os.Unsetenv("TEST_LAB_INT") - - if got := envInt("TEST_LAB_INT", 0); got != 42 { - t.Fatalf("expected 42, got %d", got) - } -} - -func TestEnvInt_Bad_Fallback(t *testing.T) { - os.Unsetenv("TEST_LAB_INT_MISSING") - if got := envInt("TEST_LAB_INT_MISSING", 99); got != 99 { - t.Fatalf("expected 99, got %d", got) - } -} - -func TestEnvInt_Bad_InvalidString(t *testing.T) { - os.Setenv("TEST_LAB_INT_BAD", "xyz") - defer os.Unsetenv("TEST_LAB_INT_BAD") - - if got := envInt("TEST_LAB_INT_BAD", 7); got != 7 { - t.Fatalf("expected fallback 7, got %d", got) - } -} diff --git a/pkg/lab/handler/api.go b/pkg/lab/handler/api.go deleted file mode 100644 index 94a919c..0000000 --- a/pkg/lab/handler/api.go +++ /dev/null @@ -1,65 +0,0 @@ -package handler - -import ( - "encoding/json" - "net/http" - "time" - - "forge.lthn.ai/core/go/pkg/lab" -) - -type APIHandler struct { - store *lab.Store -} - -func NewAPIHandler(s *lab.Store) *APIHandler { - return &APIHandler{store: s} -} - -type apiResponse struct { - Status string `json:"status"` - UpdatedAt time.Time `json:"updated_at"` - Data any `json:"data"` -} - -func (h *APIHandler) writeJSON(w http.ResponseWriter, data any) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(apiResponse{ - Status: "ok", - UpdatedAt: time.Now(), - Data: data, - }) -} - -func (h *APIHandler) Status(w http.ResponseWriter, r *http.Request) { - h.writeJSON(w, h.store.Overview()) -} - -func (h *APIHandler) Models(w http.ResponseWriter, r *http.Request) { - h.writeJSON(w, h.store.GetModels()) -} - -func (h *APIHandler) Training(w http.ResponseWriter, r *http.Request) { - h.writeJSON(w, h.store.GetTraining()) -} - -func (h *APIHandler) Agents(w http.ResponseWriter, r *http.Request) { - h.writeJSON(w, h.store.GetAgents()) -} - -func (h *APIHandler) Services(w http.ResponseWriter, r *http.Request) { - h.writeJSON(w, h.store.GetServices()) -} - -func (h *APIHandler) GoldenSet(w http.ResponseWriter, r *http.Request) { - h.writeJSON(w, h.store.GetGoldenSet()) -} - -func (h *APIHandler) Runs(w http.ResponseWriter, r *http.Request) { - h.writeJSON(w, h.store.GetBenchmarks()) -} - -func (h *APIHandler) Health(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) -} diff --git a/pkg/lab/handler/chart.go b/pkg/lab/handler/chart.go deleted file mode 100644 index abda97f..0000000 --- a/pkg/lab/handler/chart.go +++ /dev/null @@ -1,595 +0,0 @@ -package handler - -import ( - "cmp" - "fmt" - "html/template" - "math" - "slices" - "sort" - "strings" - - "forge.lthn.ai/core/go/pkg/lab" -) - -const ( - chartW = 760 - chartH = 280 - marginTop = 25 - marginRight = 20 - marginBot = 35 - marginLeft = 55 - plotW = chartW - marginLeft - marginRight - plotH = chartH - marginTop - marginBot -) - -var dimensionColors = map[string]string{ - "ccp_compliance": "#f87171", - "truth_telling": "#4ade80", - "engagement": "#fbbf24", - "axiom_integration": "#60a5fa", - "sovereignty_reasoning": "#c084fc", - "emotional_register": "#fb923c", -} - -func getDimColor(dim string) string { - if c, ok := dimensionColors[dim]; ok { - return c - } - return "#8888a0" -} - -// LossChart generates an SVG line chart for training loss data. -func LossChart(points []lab.LossPoint) template.HTML { - if len(points) == 0 { - return template.HTML(`
No training loss data
`) - } - - // Separate val and train loss. - var valPts, trainPts []lab.LossPoint - for _, p := range points { - switch p.LossType { - case "val": - valPts = append(valPts, p) - case "train": - trainPts = append(trainPts, p) - } - } - - // Find data bounds. - allPts := append(valPts, trainPts...) - xMin, xMax := float64(allPts[0].Iteration), float64(allPts[0].Iteration) - yMin, yMax := allPts[0].Loss, allPts[0].Loss - for _, p := range allPts { - x := float64(p.Iteration) - xMin = min(xMin, x) - xMax = max(xMax, x) - yMin = min(yMin, p.Loss) - yMax = max(yMax, p.Loss) - } - - // Add padding to Y range. - yRange := yMax - yMin - yRange = max(yRange, 0.1) - yMin = yMin - yRange*0.1 - yMax = yMax + yRange*0.1 - if xMax == xMin { - xMax = xMin + 1 - } - - scaleX := func(v float64) float64 { return marginLeft + (v-xMin)/(xMax-xMin)*plotW } - scaleY := func(v float64) float64 { return marginTop + (1-(v-yMin)/(yMax-yMin))*plotH } - - var sb strings.Builder - sb.WriteString(fmt.Sprintf(``, chartW, chartH, chartW)) - sb.WriteString(fmt.Sprintf(``, chartW, chartH)) - - // Grid lines. - nGridY := 5 - for i := 0; i <= nGridY; i++ { - y := marginTop + float64(i)*plotH/float64(nGridY) - val := yMax - float64(i)*(yMax-yMin)/float64(nGridY) - sb.WriteString(fmt.Sprintf(``, marginLeft, y, chartW-marginRight, y)) - sb.WriteString(fmt.Sprintf(`%.2f`, marginLeft-6, y, val)) - } - - // X axis labels. - nGridX := max(min(6, int(xMax-xMin)), 1) - for i := 0; i <= nGridX; i++ { - xVal := xMin + float64(i)*(xMax-xMin)/float64(nGridX) - x := scaleX(xVal) - sb.WriteString(fmt.Sprintf(``, x, marginTop, x, marginTop+plotH)) - sb.WriteString(fmt.Sprintf(`%d`, x, chartH-8, int(xVal))) - } - - // Draw train loss line (dimmed). - if len(trainPts) > 1 { - slices.SortFunc(trainPts, func(a, b lab.LossPoint) int { return cmp.Compare(a.Iteration, b.Iteration) }) - sb.WriteString(``) - for _, p := range trainPts { - sb.WriteString(fmt.Sprintf(``, scaleX(float64(p.Iteration)), scaleY(p.Loss))) - } - } - - // Draw val loss line (accent). - if len(valPts) > 1 { - slices.SortFunc(valPts, func(a, b lab.LossPoint) int { return cmp.Compare(a.Iteration, b.Iteration) }) - sb.WriteString(``) - for _, p := range valPts { - sb.WriteString(fmt.Sprintf(``, scaleX(float64(p.Iteration)), scaleY(p.Loss))) - sb.WriteString(fmt.Sprintf(`%.2f`, scaleX(float64(p.Iteration)), scaleY(p.Loss)-8, p.Loss)) - } - } - - // Legend. - sb.WriteString(fmt.Sprintf(``, marginLeft+10)) - sb.WriteString(fmt.Sprintf(`Val Loss`, marginLeft+18)) - sb.WriteString(fmt.Sprintf(``, marginLeft+85)) - sb.WriteString(fmt.Sprintf(`Train Loss`, marginLeft+93)) - - sb.WriteString("") - return template.HTML(sb.String()) -} - -// ContentChart generates an SVG multi-line chart for content scores by dimension. -func ContentChart(points []lab.ContentPoint) template.HTML { - if len(points) == 0 { - return template.HTML(`
No content score data
`) - } - - // Group by dimension, sorted by iteration. Only use kernel points for cleaner view. - dims := map[string][]lab.ContentPoint{} - for _, p := range points { - if !p.HasKernel && !strings.Contains(p.Label, "naked") { - continue - } - dims[p.Dimension] = append(dims[p.Dimension], p) - } - // If no kernel points, use all. - if len(dims) == 0 { - for _, p := range points { - dims[p.Dimension] = append(dims[p.Dimension], p) - } - } - - // Find unique iterations for X axis. - iterSet := map[int]bool{} - for _, pts := range dims { - for _, p := range pts { - iterSet[p.Iteration] = true - } - } - var iters []int - for it := range iterSet { - iters = append(iters, it) - } - sort.Ints(iters) - - if len(iters) == 0 { - return template.HTML(`
No iteration data
`) - } - - xMin, xMax := float64(iters[0]), float64(iters[len(iters)-1]) - if xMax == xMin { - xMax = xMin + 1 - } - yMin, yMax := 0.0, 10.0 // Content scores are 0-10. - - scaleX := func(v float64) float64 { return marginLeft + (v-xMin)/(xMax-xMin)*plotW } - scaleY := func(v float64) float64 { return marginTop + (1-(v-yMin)/(yMax-yMin))*plotH } - - var sb strings.Builder - sb.WriteString(fmt.Sprintf(``, chartW, chartH, chartW)) - sb.WriteString(fmt.Sprintf(``, chartW, chartH)) - - // Grid. - for i := 0; i <= 5; i++ { - y := marginTop + float64(i)*plotH/5 - val := yMax - float64(i)*(yMax-yMin)/5 - sb.WriteString(fmt.Sprintf(``, marginLeft, y, chartW-marginRight, y)) - sb.WriteString(fmt.Sprintf(`%.0f`, marginLeft-6, y, val)) - } - - // X axis. - for _, it := range iters { - x := scaleX(float64(it)) - sb.WriteString(fmt.Sprintf(``, x, marginTop, x, marginTop+plotH)) - sb.WriteString(fmt.Sprintf(`@%d`, x, chartH-8, it)) - } - - // Draw a line per dimension. - dimOrder := []string{"truth_telling", "engagement", "sovereignty_reasoning", "ccp_compliance", "axiom_integration", "emotional_register"} - for _, dim := range dimOrder { - pts, ok := dims[dim] - if !ok || len(pts) < 2 { - continue - } - slices.SortFunc(pts, func(a, b lab.ContentPoint) int { return cmp.Compare(a.Iteration, b.Iteration) }) - - // Average duplicate iterations. - averaged := averageByIteration(pts) - color := getDimColor(dim) - - sb.WriteString(fmt.Sprintf(``, color)) - - for _, p := range averaged { - cx := scaleX(float64(p.Iteration)) - cy := scaleY(p.Score) - sb.WriteString(fmt.Sprintf(``, cx, cy, color)) - sb.WriteString(fmt.Sprintf(`%.1f`, cx, cy-6, color, p.Score)) - } - } - - // Legend at top. - lx := marginLeft + 5 - for _, dim := range dimOrder { - if _, ok := dims[dim]; !ok { - continue - } - color := getDimColor(dim) - label := strings.ReplaceAll(dim, "_", " ") - sb.WriteString(fmt.Sprintf(``, lx, color)) - sb.WriteString(fmt.Sprintf(`%s`, lx+7, label)) - lx += len(label)*6 + 20 - } - - sb.WriteString("") - return template.HTML(sb.String()) -} - -// CapabilityChart generates an SVG horizontal bar chart for capability scores. -func CapabilityChart(points []lab.CapabilityPoint) template.HTML { - if len(points) == 0 { - return template.HTML(`
No capability score data
`) - } - - // Get overall scores only, sorted by iteration. - var overall []lab.CapabilityPoint - for _, p := range points { - if p.Category == "overall" { - overall = append(overall, p) - } - } - slices.SortFunc(overall, func(a, b lab.CapabilityPoint) int { return cmp.Compare(a.Iteration, b.Iteration) }) - - if len(overall) == 0 { - return template.HTML(`
No overall capability data
`) - } - - barH := 32 - gap := 8 - labelW := 120 - svgH := len(overall)*(barH+gap) + 40 - barMaxW := chartW - labelW - 80 - - var sb strings.Builder - sb.WriteString(fmt.Sprintf(``, chartW, svgH, chartW)) - sb.WriteString(fmt.Sprintf(``, chartW, svgH)) - - for i, p := range overall { - y := 20 + i*(barH+gap) - barW := p.Accuracy / 100.0 * float64(barMaxW) - - // Color based on accuracy. - color := "#f87171" // red - if p.Accuracy >= 80 { - color = "#4ade80" // green - } else if p.Accuracy >= 65 { - color = "#fbbf24" // yellow - } - - // Label. - label := shortLabel(p.Label) - sb.WriteString(fmt.Sprintf(`%s`, y+barH/2, label)) - - // Bar background. - sb.WriteString(fmt.Sprintf(``, labelW, y, barMaxW, barH)) - - // Bar fill. - sb.WriteString(fmt.Sprintf(``, labelW, y, barW, barH, color)) - - // Score label. - sb.WriteString(fmt.Sprintf(`%.1f%%`, float64(labelW)+barW+8, y+barH/2, p.Accuracy)) - - // Correct/total. - sb.WriteString(fmt.Sprintf(`%d/%d`, chartW-10, y+barH/2, p.Correct, p.Total)) - } - - sb.WriteString("") - return template.HTML(sb.String()) -} - -// CategoryBreakdownWithJudge generates an HTML table showing per-category capability scores. -// When judge data is available, shows 0-10 float averages. Falls back to binary correct/total. -func CategoryBreakdownWithJudge(points []lab.CapabilityPoint, judgePoints []lab.CapabilityJudgePoint) template.HTML { - if len(points) == 0 { - return "" - } - - type key struct{ cat, label string } - - // Binary data (always available). - type binaryCell struct { - correct, total int - accuracy float64 - } - binaryCells := map[key]binaryCell{} - catSet := map[string]bool{} - var labels []string - labelSeen := map[string]bool{} - - for _, p := range points { - if p.Category == "overall" { - continue - } - k := key{p.Category, p.Label} - c := binaryCells[k] - c.correct += p.Correct - c.total += p.Total - binaryCells[k] = c - catSet[p.Category] = true - if !labelSeen[p.Label] { - labelSeen[p.Label] = true - labels = append(labels, p.Label) - } - } - for k, c := range binaryCells { - if c.total > 0 { - c.accuracy = float64(c.correct) / float64(c.total) * 100 - } - binaryCells[k] = c - } - - // Judge data (may be empty -- falls back to binary). - type judgeCell struct { - sum float64 - count int - } - judgeCells := map[key]judgeCell{} - hasJudge := len(judgePoints) > 0 - - for _, jp := range judgePoints { - k := key{jp.Category, jp.Label} - c := judgeCells[k] - c.sum += jp.Avg - c.count++ - judgeCells[k] = c - } - - var cats []string - for c := range catSet { - cats = append(cats, c) - } - sort.Strings(cats) - - if len(cats) == 0 || len(labels) == 0 { - return "" - } - - var sb strings.Builder - sb.WriteString(``) - for _, cat := range cats { - icon := catIcon(cat) - sb.WriteString(fmt.Sprintf(``, cat, icon)) - } - sb.WriteString(``) - - for _, l := range labels { - short := shortLabel(l) - sb.WriteString(fmt.Sprintf(``, short)) - for _, cat := range cats { - jc, jok := judgeCells[key{cat, l}] - bc, bok := binaryCells[key{cat, l}] - - if hasJudge && jok && jc.count > 0 { - // Show judge score (0-10 average). - avg := jc.sum / float64(jc.count) - color := "var(--red)" - if avg >= 7.0 { - color = "var(--green)" - } else if avg >= 4.0 { - color = "var(--yellow)" - } - passInfo := "" - if bok { - passInfo = fmt.Sprintf(" (%d/%d pass)", bc.correct, bc.total) - } - sb.WriteString(fmt.Sprintf(``, - color, cat, avg, passInfo, avg)) - } else if bok { - // Fall back to binary. - icon := "fa-circle-xmark" - color := "var(--red)" - if bc.accuracy >= 80 { - icon = "fa-circle-check" - color = "var(--green)" - } else if bc.accuracy >= 50 { - icon = "fa-triangle-exclamation" - color = "var(--yellow)" - } - sb.WriteString(fmt.Sprintf(``, - color, cat, bc.correct, bc.total, bc.accuracy, icon, bc.correct, bc.total)) - } else { - sb.WriteString(``) - } - } - sb.WriteString(``) - } - sb.WriteString(`
Run
%s%.1f %d/%d
`) - return template.HTML(sb.String()) -} - -// catIcon maps capability category names to Font Awesome icons. -func catIcon(cat string) string { - icons := map[string]string{ - "algebra": "fa-square-root-variable", - "analogy": "fa-right-left", - "arithmetic": "fa-calculator", - "causal": "fa-diagram-project", - "code": "fa-code", - "deduction": "fa-magnifying-glass", - "geometry": "fa-shapes", - "pattern": "fa-grip", - "percentages": "fa-percent", - "probability": "fa-dice", - "puzzles": "fa-puzzle-piece", - "sequences": "fa-list-ol", - "sets": "fa-circle-nodes", - "spatial": "fa-cube", - "temporal": "fa-clock", - "word": "fa-font", - } - if ic, ok := icons[cat]; ok { - return ic - } - return "fa-question" -} - -// shortLabel compresses run labels for table display. -// "base-gemma-3-27b" -> "base-27b", "G12 @0000100" -> "G12 @100" -func shortLabel(s string) string { - // Strip "gemma-3-" prefix pattern from compound labels - s = strings.ReplaceAll(s, "gemma-3-", "") - // Collapse leading zeros in iteration numbers: @0000100 -> @100 - if idx := strings.Index(s, "@"); idx >= 0 { - prefix := s[:idx+1] - num := strings.TrimLeft(s[idx+1:], "0") - if num == "" { - num = "0" - } - s = prefix + num - } - if len(s) > 18 { - s = s[:18] - } - return s -} - -func averageByIteration(pts []lab.ContentPoint) []lab.ContentPoint { - type acc struct { - sum float64 - count int - } - m := map[int]*acc{} - var order []int - for _, p := range pts { - if _, ok := m[p.Iteration]; !ok { - m[p.Iteration] = &acc{} - order = append(order, p.Iteration) - } - m[p.Iteration].sum += p.Score - m[p.Iteration].count++ - } - sort.Ints(order) - var result []lab.ContentPoint - for _, it := range order { - a := m[it] - result = append(result, lab.ContentPoint{ - Iteration: it, - Score: math.Round(a.sum/float64(a.count)*10) / 10, - }) - } - return result -} - -// DomainChart renders a horizontal bar chart of domain counts (top 25). -func DomainChart(stats []lab.DomainStat) template.HTML { - if len(stats) == 0 { - return "" - } - limit := min(25, len(stats)) - items := stats[:limit] - - maxCount := 0 - for _, d := range items { - maxCount = max(maxCount, d.Count) - } - maxCount = max(maxCount, 1) - - barH := 18 - gap := 4 - labelW := 180 - barAreaW := 540 - h := len(items)*(barH+gap) + 10 - w := labelW + barAreaW + 60 - - var b strings.Builder - fmt.Fprintf(&b, ``, w, h) - fmt.Fprintf(&b, ``, w, h) - - for i, d := range items { - y := i*(barH+gap) + 5 - barW := max(int(float64(d.Count)/float64(maxCount)*float64(barAreaW)), 2) - fmt.Fprintf(&b, `%s`, - labelW-8, y+barH/2, template.HTMLEscapeString(d.Domain)) - fmt.Fprintf(&b, ``, - labelW, y, barW, barH) - fmt.Fprintf(&b, `%d`, - labelW+barW+4, y+barH/2, d.Count) - } - - b.WriteString(``) - return template.HTML(b.String()) -} - -// VoiceChart renders a vertical bar chart of voice distribution. -func VoiceChart(stats []lab.VoiceStat) template.HTML { - if len(stats) == 0 { - return "" - } - - maxCount := 0 - for _, v := range stats { - maxCount = max(maxCount, v.Count) - } - maxCount = max(maxCount, 1) - - barW := 50 - gap := 8 - chartHeight := 200 - labelH := 60 - topPad := 20 - w := len(stats)*(barW+gap) + gap + 10 - h := chartHeight + labelH + topPad - - var b strings.Builder - fmt.Fprintf(&b, ``, w, h) - fmt.Fprintf(&b, ``, w, h) - - for i, v := range stats { - x := i*(barW+gap) + gap + 5 - barH := max(int(float64(v.Count)/float64(maxCount)*float64(chartHeight)), 2) - y := topPad + chartHeight - barH - - fmt.Fprintf(&b, ``, - x, y, barW, barH) - fmt.Fprintf(&b, `%d`, - x+barW/2, y-4, v.Count) - fmt.Fprintf(&b, `%s`, - x+barW/2, topPad+chartHeight+12, x+barW/2, topPad+chartHeight+12, template.HTMLEscapeString(v.Voice)) - } - - b.WriteString(``) - return template.HTML(b.String()) -} diff --git a/pkg/lab/handler/static/.gitkeep b/pkg/lab/handler/static/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/pkg/lab/handler/templates/agents.html b/pkg/lab/handler/templates/agents.html deleted file mode 100644 index d59c273..0000000 --- a/pkg/lab/handler/templates/agents.html +++ /dev/null @@ -1,56 +0,0 @@ -{{template "head" "Agents"}} -{{template "nav" "agents"}} - -

Agent Metrics

- -{{if .Agents.Available}} -
-
-

Registered Agents

-
{{.Agents.RegisteredTotal}}
-
- {{if .Agents.ExporterUp}}exporter up - {{else}}exporter down{{end}} -
-
- -
-

Queue Pending

-
{{.Agents.QueuePending}}
-
Tasks waiting for agents
-
- -
-

Tasks Completed

-
{{.Agents.TasksCompleted}}
-
Total successful
-
- -
-

Tasks Failed

-
{{.Agents.TasksFailed}}
-
Total failures
-
-
- -
-
-

Capabilities

-
{{.Agents.Capabilities}}
-
Registered capabilities
-
- -
-

Heartbeat Age

-
{{pct .Agents.HeartbeatAge}}s
-
Time since last heartbeat
-
-
-{{else}} -
-

Agent metrics not available. The Prometheus agent exporter may be offline.

-

Expected at: localhost:9402/metrics

-
-{{end}} - -{{template "footer"}} diff --git a/pkg/lab/handler/templates/dashboard.html b/pkg/lab/handler/templates/dashboard.html deleted file mode 100644 index 87985b6..0000000 --- a/pkg/lab/handler/templates/dashboard.html +++ /dev/null @@ -1,115 +0,0 @@ -{{template "head" "Dashboard"}} -{{template "nav" "dashboard"}} - - - -
- {{range .Machines}} -
-

{{.Name}}

-
- - {{.Status}} -
- {{if eq (printf "%s" .Status) "ok"}} -
- CPU -
- {{pct .Load1}}/{{.CPUCores}} -
-
- RAM -
- {{printf "%.0f" .MemUsedGB}}/{{fmtGB .MemTotalGB}} -
-
- Disk -
- {{fmtGB .DiskUsedGB}}/{{fmtGB .DiskTotalGB}} -
- {{if .GPUName}} -
- GPU - {{if gt .GPUVRAMTotal 0.0}} -
- {{printf "%.1f" .GPUVRAMUsed}}/{{printf "%.0f" .GPUVRAMTotal}}G - {{else}} - {{.GPUName}} - {{end}} -
- {{end}} -
{{.Uptime}}{{if gt .GPUTemp 0}} · GPU {{.GPUTemp}}°C{{end}}
- {{end}} -
- {{else}} -
-

Machines

-
Waiting for data...
-
- {{end}} - -
-

LEK Models

-
{{len .Models}}
- -
- -
-

Benchmark Runs

- {{$b := .Benchmarks}} -
{{benchmarkCount $b}}
-
{{dataPoints $b}} data points · View runs
-
- -
-

Gold Generation

- {{if .Training.GoldAvailable}} -
{{pct .Training.GoldPercent}}%
-
-
{{.Training.GoldGenerated}} / {{.Training.GoldTarget}}
- {{else}} -
Unavailable
-
M3 Ultra unreachable
- {{end}} -
-
- -{{if .Commits}} -

Recent Activity

-
- - - - {{range .Commits}} - - - - - - - {{end}} - -
RepoMessageAuthorTime
{{.Repo}}{{shortMsg .Message}}{{.Author}}{{timeAgo .Timestamp}}
-
-{{end}} - -{{if .Errors}} -
- {{range $k, $v := .Errors}} -
- {{$k}} {{$v}} -
- {{end}} -
-{{end}} - -{{template "footer"}} diff --git a/pkg/lab/handler/templates/dataset.html b/pkg/lab/handler/templates/dataset.html deleted file mode 100644 index 7fe694c..0000000 --- a/pkg/lab/handler/templates/dataset.html +++ /dev/null @@ -1,392 +0,0 @@ -{{template "head" "Dataset"}} -{{template "nav" "dataset"}} - - - -
- -{{/* -- Sidebar -- */}} - - -{{/* -- Main content -- */}} -
- -{{if not .SelectedView}} -{{/* -- Overview -- */}} -

LEM Dataset

- -
- {{if .GoldenSet.Available}} - -
-

Golden Set

-
{{fmtInt .GoldenSet.TotalExamples}}
-
-
{{pct .GoldenSet.CompletionPct}}% of {{fmtInt .GoldenSet.TargetTotal}} target
-
-
- {{end}} - - {{if .Dataset.Available}} - -
-

Seeds

-
{{fmtInt (tableRows .Dataset.Tables "seeds")}}
-
Source prompts for generation
-
-
- - -
-

Expansion Prompts

-
{{fmtInt (tableRows .Dataset.Tables "expansion_prompts")}}
-
Ready for model expansion
-
-
- -
-

Training Examples

-
{{fmtInt (tableRows .Dataset.Tables "training_examples")}}
-
Chat-format JSONL splits
-
- {{end}} - - {{if .GoldenSet.Available}} - -
-

Domains

-
{{.GoldenSet.Domains}}
-
Topic categories
-
-
- - -
-

Voices

-
{{.GoldenSet.Voices}}
-
Persona types
-
-
- -
-

Avg Generation

-
{{pct .GoldenSet.AvgGenTime}}s
-
{{pct .GoldenSet.AvgResponseChars}} avg chars
-
- {{end}} -
- -{{if .Dataset.Available}} -

DuckDB Tables

-
- - - - {{$total := totalRows .Dataset.Tables}} - {{range .Dataset.Tables}} - - - - - - {{end}} - -
TableRowsSize
{{.Name}}{{fmtInt .Rows}} -
-
-
-{{end}} - -{{else if eq .SelectedView "golden"}} -{{/* -- Golden Set detail -- */}} -

Golden Set

- -{{if not .GoldenSet.Available}} -

No golden set data available.

-{{else}} -
-
-

Total Examples

-
{{fmtInt .GoldenSet.TotalExamples}}
-
-
{{pct .GoldenSet.CompletionPct}}% of {{fmtInt .GoldenSet.TargetTotal}}
-
-
-

Domains

-
{{.GoldenSet.Domains}}
-
Unique topic domains
-
-
-

Voices

-
{{.GoldenSet.Voices}}
-
Persona voice types
-
-
-

Avg Generation

-
{{pct .GoldenSet.AvgGenTime}}s
-
{{pct .GoldenSet.AvgResponseChars}} avg chars
-
-
- -{{if .GoldenSet.Workers}} -
-

Workers

-
- - - - {{range .GoldenSet.Workers}} - - - - - {{end}} - -
WorkerGenerations
{{.Worker}}{{fmtInt .Count}}
-
-
-{{end}} -{{end}} - -{{else if eq .SelectedView "seeds"}} -{{/* -- Seeds -- */}} -

Seeds

-
- {{if .Dataset.Available}} -
-

Total Seeds

-
{{fmtInt (tableRows .Dataset.Tables "seeds")}}
-
Source prompts in DuckDB
-
-
-

Prompts Generated

-
{{fmtInt (tableRows .Dataset.Tables "prompts")}}
-
Processed from seeds
-
- {{else}} -
-

Seeds

-
87,338
-
Push stats via dataset_stats
-
- {{end}} -
-
-

Seed browser coming soon. Use lem export --seeds to explore locally.

-
- -{{else if eq .SelectedView "domains"}} -{{/* -- Domains -- */}} -

Domains

- -{{if and .GoldenSet.Available .GoldenSet.DomainStats}} -
-
-

Total Domains

-
{{.GoldenSet.Domains}}
-
Unique topic categories
-
-
-

Total Examples

-
{{fmtInt .GoldenSet.TotalExamples}}
-
Across all domains
-
-
- -
-

Distribution (top 25)

-
- {{domainChart .GoldenSet.DomainStats}} -
-
- -
-

All Domains

-
- - - - {{range .GoldenSet.DomainStats}} - - - - - - - {{end}} - -
DomainCountAvg Gen TimeCoverage
{{.Domain}}{{.Count}}{{pct .AvgGenTime}}s -
-
-
-
-{{else}} -

No domain data available.

-{{end}} - -{{else if eq .SelectedView "voices"}} -{{/* -- Voices -- */}} -

Voices

- -{{if and .GoldenSet.Available .GoldenSet.VoiceStats}} -
-
-

Total Voices

-
{{.GoldenSet.Voices}}
-
Persona types
-
-
-

Total Examples

-
{{fmtInt .GoldenSet.TotalExamples}}
-
Across all voices
-
-
- -
-

Distribution

-
- {{voiceChart .GoldenSet.VoiceStats}} -
-
- -
-

Voice Details

-
- - - - {{range .GoldenSet.VoiceStats}} - - - - - - - {{end}} - -
VoiceCountAvg CharsAvg Gen Time
{{.Voice}}{{.Count}}{{pct .AvgChars}}{{pct .AvgGenTime}}s
-
-
-{{else}} -

No voice data available.

-{{end}} - -{{else if eq .SelectedView "expansion"}} -{{/* -- Expansion -- */}} -

Expansion

-
- {{if .Dataset.Available}} -
-

Expansion Prompts

-
{{fmtInt (tableRows .Dataset.Tables "expansion_prompts")}}
-
Deduped, ready for generation
-
-
-

Gemini Responses

-
{{fmtInt (tableRows .Dataset.Tables "gemini_responses")}}
-
Reference responses for scoring
-
-
-

Benchmark Questions

-
{{fmtInt (tableRows .Dataset.Tables "benchmark_questions")}}
-
Capability test set
-
-
-

Benchmark Results

-
{{fmtInt (tableRows .Dataset.Tables "benchmark_results")}}
-
Scored responses
-
- {{else}} -
-

Expansion Prompts

-
46,331
-
Push stats via dataset_stats
-
- {{end}} -
-
-

Expansion pipeline: use lem expand to generate responses from trained models, then lem score to filter by quality.

-
- -{{else if eq .SelectedView "export"}} -{{/* -- Export -- */}} -

Export

-
- {{if .Dataset.Available}} -
-

Training Examples

-
{{fmtInt (tableRows .Dataset.Tables "training_examples")}}
-
Chat-format JSONL
-
-
-

Validations

-
{{fmtInt (tableRows .Dataset.Tables "validations")}}
-
Quality checks
-
- {{end}} -
-
-

Export formats:

- - - - - - - - - - - - - - - - - - - -
FormatCommandUse
JSONL (MLX)lem export --format jsonlMLX LoRA training (train/valid/test splits)
Parquetlem export --format parquetHuggingFace dataset upload
CSVlem export --format csvSpreadsheet analysis
-
- -{{end}} - -
-
- -{{template "footer"}} diff --git a/pkg/lab/handler/templates/golden-set.html b/pkg/lab/handler/templates/golden-set.html deleted file mode 100644 index 8f1bb3d..0000000 --- a/pkg/lab/handler/templates/golden-set.html +++ /dev/null @@ -1,108 +0,0 @@ -{{template "head" "Golden Set"}} -{{template "nav" "golden-set"}} - -

LEM Golden Set Explorer

- -{{if not .GoldenSet.Available}} -
No golden set data available. Run pipeline.py metrics to push stats to InfluxDB.
-{{else}} - -
-
-

Progress

-
{{fmtInt .GoldenSet.TotalExamples}} / {{fmtInt .GoldenSet.TargetTotal}}
-
-
{{pct .GoldenSet.CompletionPct}}% complete
-
- -
-

Domains

-
{{.GoldenSet.Domains}}
-
Unique topic domains
-
- -
-

Voices

-
{{.GoldenSet.Voices}}
-
Persona voice types
-
- -
-

Avg Generation

-
{{pct .GoldenSet.AvgGenTime}}s
-
{{pct .GoldenSet.AvgResponseChars}} avg chars per response
-
-
- -{{if .GoldenSet.Workers}} -

Workers

-
- - - - {{range .GoldenSet.Workers}} - - - - - {{end}} - -
WorkerGenerations
{{.Worker}}{{.Count}}
-
-{{end}} - -{{if .GoldenSet.VoiceStats}} -

Voice Distribution

-
- {{voiceChart .GoldenSet.VoiceStats}} -
-{{end}} - -{{if .GoldenSet.DomainStats}} -

Domain Breakdown (top 25)

-
- {{domainChart .GoldenSet.DomainStats}} -
- -

All Domains

-
- - - - {{range .GoldenSet.DomainStats}} - - - - - - - {{end}} - -
DomainCountAvg Gen TimeCoverage
{{.Domain}}{{.Count}}{{pct .AvgGenTime}}s -
-
-
-{{end}} - -{{if .GoldenSet.VoiceStats}} -

Voice Details

-
- - - - {{range .GoldenSet.VoiceStats}} - - - - - - - {{end}} - -
VoiceCountAvg CharsAvg Gen Time
{{.Voice}}{{.Count}}{{pct .AvgChars}}{{pct .AvgGenTime}}s
-
-{{end}} - -{{end}} - -{{template "footer"}} diff --git a/pkg/lab/handler/templates/layout.html b/pkg/lab/handler/templates/layout.html deleted file mode 100644 index 54953df..0000000 --- a/pkg/lab/handler/templates/layout.html +++ /dev/null @@ -1,103 +0,0 @@ -{{define "head"}} - - - - -{{.}} - LEM.Lab - - - -{{end}} - -{{define "nav"}} - -
{{end}} - -{{define "footer"}} -
- - -{{end}} diff --git a/pkg/lab/handler/templates/models.html b/pkg/lab/handler/templates/models.html deleted file mode 100644 index 227f5d2..0000000 --- a/pkg/lab/handler/templates/models.html +++ /dev/null @@ -1,29 +0,0 @@ -{{template "head" "Models"}} -{{template "nav" "models"}} - -

LEK Models on HuggingFace

- -{{if .Models}} -
- - - - {{range .Models}} - - - - - - - - {{end}} - -
ModelDownloadsLikesPipelineUpdated
{{.ModelID}}{{.Downloads}}{{.Likes}}{{if .PipelineTag}}{{.PipelineTag}}{{else}}-{{end}}{{timeAgo .LastModified}}
-
-{{else}} -
-

No models loaded yet. HuggingFace data refreshes every 5 minutes.

-
-{{end}} - -{{template "footer"}} diff --git a/pkg/lab/handler/templates/runs.html b/pkg/lab/handler/templates/runs.html deleted file mode 100644 index 79b78c0..0000000 --- a/pkg/lab/handler/templates/runs.html +++ /dev/null @@ -1,113 +0,0 @@ -{{template "head" "Runs"}} -{{template "nav" "runs"}} - - - -

Training Runs

- -{{$b := .Benchmarks}} - -{{if not $b.Runs}} -
-

No benchmark data available. InfluxDB data refreshes every 60 seconds.

-
-{{else}} - -{{range $b.Runs}} -{{$rid := .RunID}} -{{$mdl := .Model}} - -
-
-

{{$mdl}}

- {{.Type}} - {{$rid}} -
- - {{/* Summary stats */}} -
- {{if hasKey $b.Loss $rid}} - {{$loss := getLoss $b.Loss $rid}} -
-
Loss Points
-
{{len $loss}}
-
val + train
-
- {{end}} - - {{if hasContentKey $b.Content $rid}} - {{$content := getContent $b.Content $rid}} -
-
Content Scores
-
{{len $content}}
-
dimension scores
-
- {{end}} - - {{if hasCapKey $b.Capability $rid}} - {{$cap := getCap $b.Capability $rid}} -
-
Capability Tests
-
{{len $cap}}
-
benchmark points
-
- {{end}} -
- - {{/* Training Loss Chart */}} - {{if hasKey $b.Loss $rid}} -
-

Training Loss Curve

-
- {{lossChart (getLoss $b.Loss $rid)}} -
-
- {{end}} - - {{/* Content Score Chart */}} - {{if hasContentKey $b.Content $rid}} -
-

Content Scores by Dimension

-
- {{contentChart (getContent $b.Content $rid)}} -
-
- {{end}} - - {{/* Capability Chart */}} - {{if hasCapKey $b.Capability $rid}} -
-

Capability Benchmark

-
- {{capabilityChart (getCap $b.Capability $rid)}} -
-
- -
-

Category Breakdown

-
- {{categoryBreakdown (getCap $b.Capability $rid) (getCapJudge $b.CapabilityJudge $rid)}} -
-
- {{end}} - -
-{{end}} - -{{end}} - -{{template "footer"}} diff --git a/pkg/lab/handler/templates/services.html b/pkg/lab/handler/templates/services.html deleted file mode 100644 index 8229ada..0000000 --- a/pkg/lab/handler/templates/services.html +++ /dev/null @@ -1,65 +0,0 @@ -{{template "head" "Services"}} -{{template "nav" "services"}} - -

Internal Services

- - - -{{$services := .Services}} - -
-
- {{len $services}} - Total Services -
-
- {{countStatus $services "ok"}} - Online -
-
- {{countStatus $services "degraded"}} - Degraded -
-
- {{countStatus $services "unavailable"}} - Offline -
-
- -{{range categories $services}} -
-
{{.}}
-
- {{range filterCat $services .}} -
-
-
- -
{{.Machine}} · {{.URL}}
-
-
- {{end}} -
-
-{{end}} - -{{template "footer"}} diff --git a/pkg/lab/handler/templates/training.html b/pkg/lab/handler/templates/training.html deleted file mode 100644 index 93872c2..0000000 --- a/pkg/lab/handler/templates/training.html +++ /dev/null @@ -1,278 +0,0 @@ -{{template "head" "Training"}} -{{template "nav" "training"}} - - - -
- -{{/* -- Sidebar -- */}} -
- - - Overview - - {{range .ModelGroups}} - - {{.Model}} - {{.BestStatus}} - - {{end}} -
- -{{/* -- Main content -- */}} -
- -{{if not .SelectedModel}} -{{/* -- Overview: all models -- */}} -

LEM Training

- -{{/* -- Scoring progress summary -- */}} -{{if .ModelGroups}} -
-
-
Models
-
{{.ScoredModels}} / {{len .ModelGroups}}
-
scored
-
-
-
Scoring Runs
-
{{.TotalScoringRuns}}
-
content + capability
-
-
-
Data Points
-
{{fmtInt .TotalDataPoints}}
-
across all benchmarks
-
- {{if gt .UnscoredModels 0}} -
-
Awaiting Scoring
-
{{.UnscoredModels}}
-
{{.UnscoredNames}}
-
- {{else}} -
-
Status
-
Done
-
all models scored
-
- {{end}} -
-{{end}} - -{{if .ModelGroups}} - -{{else}} -
-

No training or benchmark data. InfluxDB refreshes every 60 seconds.

-
-{{end}} - -{{else}} -{{/* -- Detail view: single model -- */}} -{{$sel := .SelectedModel}} -{{$b := .Benchmarks}} -{{$found := false}} - -{{range .ModelGroups}} -{{if eq .Model $sel}} - -
-

{{.Model}}

- {{.BestStatus}} -
- -{{/* Training run status cards */}} -{{if .TrainingRuns}} -
- {{range .TrainingRuns}} -
-
{{.RunID}}
-
{{pct .Pct}}%
-
{{.Iteration}} / {{.TotalIters}} · {{.Status}}
-
- {{end}} - - {{/* Show latest loss stats from most recent run */}} - {{with index .TrainingRuns 0}} - {{if gt .LastLoss 0.0}} -
-
Train Loss
-
{{fmtFloat .LastLoss 3}}
-
latest
-
- {{end}} - {{if gt .ValLoss 0.0}} -
-
Val Loss
-
{{fmtFloat .ValLoss 3}}
-
latest
-
- {{end}} - {{if gt .TokensSec 0.0}} -
-
Tokens/sec
-
{{fmtFloat .TokensSec 0}}
-
throughput
-
- {{end}} - {{end}} -
- -{{/* Progress bars for in-progress training runs only */}} -{{range .TrainingRuns}} -{{if ne .Status "complete"}} -
-
{{.RunID}}
-
-
-{{end}} -{{end}} -{{end}} - -{{/* All benchmark runs for this model -- collect data for tabs */}} -{{$runs := runsForModel $b $sel}} - -{{/* Tabbed charts */}} -
- {{if anyContent $runs $b.Content}}{{end}} - {{if anyCap $runs $b.Capability}}{{end}} - {{if anyCap $runs $b.Capability}}{{end}} - {{if anyLoss $runs $b.Loss}}{{end}} -
- -{{range $runs}} -{{$rid := .RunID}} -{{if hasContentKey $b.Content $rid}} -
-
- {{contentChart (getContent $b.Content $rid)}} -
-
-{{end}} -{{if hasCapKey $b.Capability $rid}} -
-
- {{capabilityChart (getCap $b.Capability $rid)}} -
-
-
-
- {{categoryBreakdown (getCap $b.Capability $rid) (getCapJudge $b.CapabilityJudge $rid)}} -
-
-{{end}} -{{if hasKey $b.Loss $rid}} -
-
- {{lossChart (getLoss $b.Loss $rid)}} -
-
-{{end}} -{{end}} - - - -{{if and (not .TrainingRuns) (not $runs)}} -

No data for this model yet.

-{{end}} - -{{end}} -{{end}} - -{{end}} - -
-
- -{{template "footer"}} diff --git a/pkg/lab/handler/web.go b/pkg/lab/handler/web.go deleted file mode 100644 index b4b9d3f..0000000 --- a/pkg/lab/handler/web.go +++ /dev/null @@ -1,502 +0,0 @@ -package handler - -import ( - "cmp" - "embed" - "fmt" - "html/template" - "net/http" - "slices" - "strings" - "time" - - "forge.lthn.ai/core/go/pkg/lab" -) - -//go:embed templates/* -var templateFS embed.FS - -//go:embed static/* -var StaticFS embed.FS - -type WebHandler struct { - store *lab.Store - tmpl *template.Template -} - -func NewWebHandler(s *lab.Store) *WebHandler { - funcMap := template.FuncMap{ - "timeAgo": func(t time.Time) string { - if t.IsZero() { - return "never" - } - d := time.Since(t) - switch { - case d < time.Minute: - return "just now" - case d < time.Hour: - return fmt.Sprintf("%dm ago", int(d.Minutes())) - case d < 24*time.Hour: - return fmt.Sprintf("%dh ago", int(d.Hours())) - default: - days := int(d.Hours()) / 24 - if days == 1 { - return "1 day ago" - } - return fmt.Sprintf("%d days ago", days) - } - }, - "pct": func(v float64) string { - return fmt.Sprintf("%.1f", v) - }, - "statusClass": func(s string) string { - switch s { - case "ok", "running": - return "status-ok" - case "degraded": - return "status-warn" - default: - return "status-err" - } - }, - "shortMsg": func(s string) string { - if i := strings.IndexByte(s, '\n'); i > 0 { - s = s[:i] - } - if len(s) > 72 { - return s[:69] + "..." - } - return s - }, - "lower": strings.ToLower, - "cpuPct": func(load float64, cores int) string { - if cores <= 0 { - return "0" - } - pct := min(load/float64(cores)*100, 100) - return fmt.Sprintf("%.0f", pct) - }, - "fmtGB": func(v float64) string { - if v >= 1000 { - return fmt.Sprintf("%.1fT", v/1024) - } - return fmt.Sprintf("%.0fG", v) - }, - "countStatus": func(services []lab.Service, status string) int { - n := 0 - for _, s := range services { - if s.Status == status { - n++ - } - } - return n - }, - "categories": func(services []lab.Service) []string { - seen := map[string]bool{} - var cats []string - for _, s := range services { - if !seen[s.Category] { - seen[s.Category] = true - cats = append(cats, s.Category) - } - } - return cats - }, - "filterCat": func(services []lab.Service, cat string) []lab.Service { - var out []lab.Service - for _, s := range services { - if s.Category == cat { - out = append(out, s) - } - } - return out - }, - "lossChart": LossChart, - "contentChart": ContentChart, - "capabilityChart": CapabilityChart, - "categoryBreakdown": CategoryBreakdownWithJudge, - "hasKey": func(m map[string][]lab.LossPoint, key string) bool { - _, ok := m[key] - return ok - }, - "hasContentKey": func(m map[string][]lab.ContentPoint, key string) bool { - _, ok := m[key] - return ok - }, - "hasCapKey": func(m map[string][]lab.CapabilityPoint, key string) bool { - _, ok := m[key] - return ok - }, - "anyContent": func(runs []lab.BenchmarkRun, m map[string][]lab.ContentPoint) bool { - for _, r := range runs { - if _, ok := m[r.RunID]; ok { - return true - } - } - return false - }, - "anyCap": func(runs []lab.BenchmarkRun, m map[string][]lab.CapabilityPoint) bool { - for _, r := range runs { - if _, ok := m[r.RunID]; ok { - return true - } - } - return false - }, - "anyLoss": func(runs []lab.BenchmarkRun, m map[string][]lab.LossPoint) bool { - for _, r := range runs { - if _, ok := m[r.RunID]; ok { - return true - } - } - return false - }, - "getLoss": func(m map[string][]lab.LossPoint, key string) []lab.LossPoint { - return m[key] - }, - "getContent": func(m map[string][]lab.ContentPoint, key string) []lab.ContentPoint { - return m[key] - }, - "getCap": func(m map[string][]lab.CapabilityPoint, key string) []lab.CapabilityPoint { - return m[key] - }, - "getCapJudge": func(m map[string][]lab.CapabilityJudgePoint, key string) []lab.CapabilityJudgePoint { - return m[key] - }, - "runTypeIcon": func(t string) string { - switch t { - case "training": - return "loss" - case "content": - return "content" - case "capability": - return "cap" - default: - return "data" - } - }, - "domainChart": DomainChart, - "voiceChart": VoiceChart, - "pctOf": func(part, total int) float64 { - if total == 0 { - return 0 - } - return float64(part) / float64(total) * 100 - }, - "fmtInt": func(n int) string { - if n < 1000 { - return fmt.Sprintf("%d", n) - } - return fmt.Sprintf("%d,%03d", n/1000, n%1000) - }, - "tableRows": func(tables []lab.DatasetTable, name string) int { - for _, t := range tables { - if t.Name == name { - return t.Rows - } - } - return 0 - }, - "totalRows": func(tables []lab.DatasetTable) int { - total := 0 - for _, t := range tables { - total += t.Rows - } - return total - }, - "fmtFloat": func(v float64, prec int) string { - return fmt.Sprintf("%.*f", prec, v) - }, - "statusColor": func(s string) string { - switch s { - case "complete": - return "var(--green)" - case "training", "fusing": - return "var(--accent)" - case "failed", "fuse_failed": - return "var(--red)" - default: - return "var(--muted)" - } - }, - "statusBadge": func(s string) string { - switch s { - case "complete": - return "badge-ok" - case "training", "fusing": - return "badge-info" - default: - return "badge-err" - } - }, - "runLabel": func(s string) string { - // Make run IDs like "15k-1b@0001000" more readable. - s = strings.ReplaceAll(s, "gemma-3-", "") - s = strings.ReplaceAll(s, "gemma3-", "") - // Strip leading zeros after @. - if idx := strings.Index(s, "@"); idx >= 0 { - prefix := s[:idx+1] - num := strings.TrimLeft(s[idx+1:], "0") - if num == "" { - num = "0" - } - s = prefix + num - } - return s - }, - "normModel": func(s string) string { - return strings.ReplaceAll(s, "gemma3-", "gemma-3-") - }, - "runsForModel": func(b lab.BenchmarkData, modelName string) []lab.BenchmarkRun { - normRun := func(s string) string { - s = strings.ReplaceAll(s, "gemma3-", "gemma-3-") - s = strings.TrimPrefix(s, "baseline-") - return s - } - target := normRun(modelName) - var out []lab.BenchmarkRun - for _, r := range b.Runs { - if normRun(r.Model) == target { - out = append(out, r) - } - } - return out - }, - "benchmarkCount": func(b lab.BenchmarkData) int { - return len(b.Runs) - }, - "dataPoints": func(b lab.BenchmarkData) int { - n := 0 - for _, v := range b.Loss { - n += len(v) - } - for _, v := range b.Content { - n += len(v) - } - for _, v := range b.Capability { - n += len(v) - } - return n - }, - } - - tmpl := template.Must( - template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.html"), - ) - - return &WebHandler{store: s, tmpl: tmpl} -} - -func (h *WebHandler) Dashboard(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/" { - http.NotFound(w, r) - return - } - ov := h.store.Overview() - b := h.store.GetBenchmarks() - h.render(w, "dashboard.html", map[string]any{ - "Machines": ov.Machines, - "Agents": ov.Agents, - "Training": ov.Training, - "Models": ov.Models, - "Commits": ov.Commits, - "Errors": ov.Errors, - "Benchmarks": b, - }) -} - -func (h *WebHandler) Models(w http.ResponseWriter, r *http.Request) { - h.render(w, "models.html", map[string]any{ - "Models": h.store.GetModels(), - }) -} - -// ModelGroup gathers all runs and data for a single model name. -type ModelGroup struct { - Model string - TrainingRuns []lab.TrainingRunStatus - BenchmarkRuns []lab.BenchmarkRun - HasTraining bool - HasContent bool - HasCapability bool - BestStatus string // best training status: complete > training > pending -} - -func buildModelGroups(runs []lab.TrainingRunStatus, benchmarks lab.BenchmarkData) []ModelGroup { - groups := map[string]*ModelGroup{} - - // Normalise model names: gemma3-12b -> gemma-3-12b, baseline-gemma-3-12b -> gemma-3-12b. - norm := func(s string) string { - s = strings.ReplaceAll(s, "gemma3-", "gemma-3-") - s = strings.TrimPrefix(s, "baseline-") - return s - } - - // Training runs. - for _, r := range runs { - key := norm(r.Model) - g, ok := groups[key] - if !ok { - g = &ModelGroup{Model: key} - groups[key] = g - } - g.TrainingRuns = append(g.TrainingRuns, r) - g.HasTraining = true - if r.Status == "complete" || (g.BestStatus != "complete" && r.Status == "training") { - g.BestStatus = r.Status - } - } - - // Benchmark runs. - for _, r := range benchmarks.Runs { - key := norm(r.Model) - g, ok := groups[key] - if !ok { - g = &ModelGroup{Model: key} - groups[key] = g - } - g.BenchmarkRuns = append(g.BenchmarkRuns, r) - switch r.Type { - case "content": - g.HasContent = true - case "capability": - g.HasCapability = true - case "training": - g.HasTraining = true - } - } - - // Sort: models with training first, then alphabetical. - var result []ModelGroup - for _, g := range groups { - if g.BestStatus == "" { - g.BestStatus = "scored" - } - result = append(result, *g) - } - slices.SortFunc(result, func(a, b ModelGroup) int { - if a.HasTraining != b.HasTraining { - if a.HasTraining { - return -1 - } - return 1 - } - return cmp.Compare(a.Model, b.Model) - }) - return result -} - -func (h *WebHandler) Training(w http.ResponseWriter, r *http.Request) { - selectedModel := r.URL.Query().Get("model") - benchmarks := h.store.GetBenchmarks() - trainingRuns := h.store.GetTrainingRuns() - groups := buildModelGroups(trainingRuns, benchmarks) - - // Compute scoring progress from model groups. - var scoredModels, totalScoringRuns, totalDataPoints int - var unscoredNames []string - for _, g := range groups { - if g.HasContent || g.HasCapability { - scoredModels++ - } else { - unscoredNames = append(unscoredNames, g.Model) - } - totalScoringRuns += len(g.BenchmarkRuns) - } - for _, v := range benchmarks.Loss { - totalDataPoints += len(v) - } - for _, v := range benchmarks.Content { - totalDataPoints += len(v) - } - for _, v := range benchmarks.Capability { - totalDataPoints += len(v) - } - - h.render(w, "training.html", map[string]any{ - "Training": h.store.GetTraining(), - "TrainingRuns": trainingRuns, - "Benchmarks": benchmarks, - "ModelGroups": groups, - "Containers": h.store.GetContainers(), - "SelectedModel": selectedModel, - "ScoredModels": scoredModels, - "TotalScoringRuns": totalScoringRuns, - "TotalDataPoints": totalDataPoints, - "UnscoredModels": len(unscoredNames), - "UnscoredNames": strings.Join(unscoredNames, ", "), - }) -} - -func (h *WebHandler) Agents(w http.ResponseWriter, r *http.Request) { - h.render(w, "agents.html", map[string]any{ - "Agents": h.store.GetAgents(), - }) -} - -func (h *WebHandler) Services(w http.ResponseWriter, r *http.Request) { - h.render(w, "services.html", map[string]any{ - "Services": h.store.GetServices(), - }) -} - -func (h *WebHandler) Dataset(w http.ResponseWriter, r *http.Request) { - view := r.URL.Query().Get("view") - h.render(w, "dataset.html", map[string]any{ - "GoldenSet": h.store.GetGoldenSet(), - "Dataset": h.store.GetDataset(), - "SelectedView": view, - }) -} - -func (h *WebHandler) GoldenSet(w http.ResponseWriter, r *http.Request) { - h.render(w, "dataset.html", map[string]any{ - "GoldenSet": h.store.GetGoldenSet(), - "Dataset": h.store.GetDataset(), - "SelectedView": "", - }) -} - -func (h *WebHandler) Runs(w http.ResponseWriter, r *http.Request) { - b := h.store.GetBenchmarks() - h.render(w, "runs.html", map[string]any{ - "Benchmarks": b, - }) -} - -// Events is an SSE endpoint that pushes "update" events when store data changes. -func (h *WebHandler) Events(w http.ResponseWriter, r *http.Request) { - flusher, ok := w.(http.Flusher) - if !ok { - http.Error(w, "streaming not supported", http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "text/event-stream") - w.Header().Set("Cache-Control", "no-cache") - w.Header().Set("Connection", "keep-alive") - - ch := h.store.Subscribe() - defer h.store.Unsubscribe(ch) - - // Send initial keepalive. - fmt.Fprintf(w, ": connected\n\n") - flusher.Flush() - - for { - select { - case <-ch: - fmt.Fprintf(w, "data: update\n\n") - flusher.Flush() - case <-r.Context().Done(): - return - } - } -} - -func (h *WebHandler) render(w http.ResponseWriter, name string, data any) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := h.tmpl.ExecuteTemplate(w, name, data); err != nil { - http.Error(w, "template error: "+err.Error(), 500) - } -} diff --git a/pkg/lab/model.go b/pkg/lab/model.go deleted file mode 100644 index 8320811..0000000 --- a/pkg/lab/model.go +++ /dev/null @@ -1,219 +0,0 @@ -package lab - -import "time" - -type Status string - -const ( - StatusOK Status = "ok" - StatusDegraded Status = "degraded" - StatusUnavailable Status = "unavailable" -) - -type Overview struct { - UpdatedAt time.Time - Machines []Machine - Agents AgentSummary - Training TrainingSummary - Models []HFModel - Commits []Commit - Errors map[string]string -} - -type Machine struct { - Name string - Host string - Status Status - Load1 float64 - MemUsedPct float64 - Containers []Container - // Extended stats - CPUCores int - MemTotalGB float64 - MemUsedGB float64 - DiskTotalGB float64 - DiskUsedGB float64 - DiskUsedPct float64 - GPUName string - GPUVRAMTotal float64 // GB, 0 if not applicable - GPUVRAMUsed float64 - GPUVRAMPct float64 - GPUTemp int // Celsius, 0 if unavailable - Uptime string -} - -type Container struct { - Name string - Status string - Image string - Uptime string - Created time.Time -} - -type AgentSummary struct { - Available bool - RegisteredTotal int - QueuePending int - TasksCompleted int - TasksFailed int - Capabilities int - HeartbeatAge float64 - ExporterUp bool -} - -type TrainingSummary struct { - GoldGenerated int - GoldTarget int - GoldPercent float64 - GoldAvailable bool - InterceptCount int - SessionCount int - LastIntercept time.Time - GGUFCount int - GGUFFiles []string - AdapterCount int -} - -type HFModel struct { - ModelID string `json:"modelId"` - Author string `json:"author"` - Downloads int `json:"downloads"` - Likes int `json:"likes"` - Tags []string `json:"tags"` - PipelineTag string `json:"pipeline_tag"` - CreatedAt time.Time `json:"createdAt"` - LastModified time.Time `json:"lastModified"` -} - -type Commit struct { - SHA string - Message string - Author string - Repo string - Timestamp time.Time -} - -type Service struct { - Name string - URL string - Category string - Machine string - Icon string - Status string // ok, degraded, unavailable, unchecked -} - -// Dataset stats from DuckDB (pushed to InfluxDB as dataset_stats). - -type DatasetTable struct { - Name string - Rows int -} - -type DatasetSummary struct { - Available bool - Tables []DatasetTable - UpdatedAt time.Time -} - -// Golden set data explorer types. - -type GoldenSetSummary struct { - Available bool - TotalExamples int - TargetTotal int - CompletionPct float64 - Domains int - Voices int - AvgGenTime float64 - AvgResponseChars float64 - DomainStats []DomainStat - VoiceStats []VoiceStat - Workers []WorkerStat - UpdatedAt time.Time -} - -type WorkerStat struct { - Worker string - Count int - LastSeen time.Time -} - -type DomainStat struct { - Domain string - Count int - AvgGenTime float64 -} - -type VoiceStat struct { - Voice string - Count int - AvgChars float64 - AvgGenTime float64 -} - -// Live training run status (from InfluxDB training_status measurement). - -type TrainingRunStatus struct { - Model string - RunID string - Status string // training, fusing, complete, failed - Iteration int - TotalIters int - Pct float64 - LastLoss float64 // most recent train loss - ValLoss float64 // most recent val loss - TokensSec float64 // most recent tokens/sec -} - -// Benchmark data types for training run viewer. - -type BenchmarkRun struct { - RunID string - Model string - Type string // "content", "capability", "training" -} - -type LossPoint struct { - Iteration int - Loss float64 - LossType string // "val" or "train" - LearningRate float64 - TokensPerSec float64 -} - -type ContentPoint struct { - Label string - Dimension string - Score float64 - Iteration int - HasKernel bool -} - -type CapabilityPoint struct { - Label string - Category string - Accuracy float64 - Correct int - Total int - Iteration int -} - -type CapabilityJudgePoint struct { - Label string - ProbeID string - Category string - Reasoning float64 - Correctness float64 - Clarity float64 - Avg float64 - Iteration int -} - -type BenchmarkData struct { - Runs []BenchmarkRun - Loss map[string][]LossPoint - Content map[string][]ContentPoint - Capability map[string][]CapabilityPoint - CapabilityJudge map[string][]CapabilityJudgePoint - UpdatedAt time.Time -} diff --git a/pkg/lab/store.go b/pkg/lab/store.go deleted file mode 100644 index 91a8cbd..0000000 --- a/pkg/lab/store.go +++ /dev/null @@ -1,275 +0,0 @@ -package lab - -import ( - "sync" - "time" -) - -type Store struct { - mu sync.RWMutex - - // SSE subscriber channels -- notified on any data change. - subMu sync.Mutex - subs map[chan struct{}]struct{} - - machines []Machine - machinesAt time.Time - - agents AgentSummary - agentsAt time.Time - - training TrainingSummary - trainingAt time.Time - - models []HFModel - modelsAt time.Time - - commits []Commit - commitsAt time.Time - - containers []Container - containersAt time.Time - - services []Service - servicesAt time.Time - - benchmarks BenchmarkData - benchmarksAt time.Time - - goldenSet GoldenSetSummary - goldenSetAt time.Time - - trainingRuns []TrainingRunStatus - trainingRunsAt time.Time - - dataset DatasetSummary - datasetAt time.Time - - errors map[string]string -} - -func NewStore() *Store { - return &Store{ - subs: make(map[chan struct{}]struct{}), - errors: make(map[string]string), - } -} - -// Subscribe returns a channel that receives a signal on every data update. -// Call Unsubscribe when done to avoid leaks. -func (s *Store) Subscribe() chan struct{} { - ch := make(chan struct{}, 1) - s.subMu.Lock() - s.subs[ch] = struct{}{} - s.subMu.Unlock() - return ch -} - -// Unsubscribe removes a subscriber channel. -func (s *Store) Unsubscribe(ch chan struct{}) { - s.subMu.Lock() - delete(s.subs, ch) - s.subMu.Unlock() -} - -// notify sends a non-blocking signal to all subscribers. -func (s *Store) notify() { - s.subMu.Lock() - defer s.subMu.Unlock() - for ch := range s.subs { - select { - case ch <- struct{}{}: - default: - } - } -} - -func (s *Store) SetMachines(m []Machine) { - s.mu.Lock() - s.machines = m - s.machinesAt = time.Now() - s.mu.Unlock() - s.notify() -} - -func (s *Store) SetAgents(a AgentSummary) { - s.mu.Lock() - s.agents = a - s.agentsAt = time.Now() - s.mu.Unlock() - s.notify() -} - -func (s *Store) SetTraining(t TrainingSummary) { - s.mu.Lock() - s.training = t - s.trainingAt = time.Now() - s.mu.Unlock() - s.notify() -} - -func (s *Store) SetModels(m []HFModel) { - s.mu.Lock() - s.models = m - s.modelsAt = time.Now() - s.mu.Unlock() - s.notify() -} - -func (s *Store) SetCommits(c []Commit) { - s.mu.Lock() - s.commits = c - s.commitsAt = time.Now() - s.mu.Unlock() - s.notify() -} - -func (s *Store) SetContainers(c []Container) { - s.mu.Lock() - s.containers = c - s.containersAt = time.Now() - s.mu.Unlock() - s.notify() -} - -func (s *Store) SetError(collector string, err error) { - s.mu.Lock() - if err != nil { - s.errors[collector] = err.Error() - } else { - delete(s.errors, collector) - } - s.mu.Unlock() - s.notify() -} - -func (s *Store) Overview() Overview { - s.mu.RLock() - defer s.mu.RUnlock() - - errCopy := make(map[string]string, len(s.errors)) - for k, v := range s.errors { - errCopy[k] = v - } - - // Merge containers into the first machine (snider-linux / local Docker host). - machines := make([]Machine, len(s.machines)) - copy(machines, s.machines) - if len(machines) > 0 { - machines[0].Containers = s.containers - } - - return Overview{ - UpdatedAt: time.Now(), - Machines: machines, - Agents: s.agents, - Training: s.training, - Models: s.models, - Commits: s.commits, - Errors: errCopy, - } -} - -func (s *Store) GetModels() []HFModel { - s.mu.RLock() - defer s.mu.RUnlock() - return s.models -} - -func (s *Store) GetTraining() TrainingSummary { - s.mu.RLock() - defer s.mu.RUnlock() - return s.training -} - -func (s *Store) GetAgents() AgentSummary { - s.mu.RLock() - defer s.mu.RUnlock() - return s.agents -} - -func (s *Store) GetContainers() []Container { - s.mu.RLock() - defer s.mu.RUnlock() - return s.containers -} - -func (s *Store) SetServices(svc []Service) { - s.mu.Lock() - s.services = svc - s.servicesAt = time.Now() - s.mu.Unlock() - s.notify() -} - -func (s *Store) GetServices() []Service { - s.mu.RLock() - defer s.mu.RUnlock() - return s.services -} - -func (s *Store) SetBenchmarks(b BenchmarkData) { - s.mu.Lock() - s.benchmarks = b - s.benchmarksAt = time.Now() - s.mu.Unlock() - s.notify() -} - -func (s *Store) GetBenchmarks() BenchmarkData { - s.mu.RLock() - defer s.mu.RUnlock() - return s.benchmarks -} - -func (s *Store) SetGoldenSet(g GoldenSetSummary) { - s.mu.Lock() - s.goldenSet = g - s.goldenSetAt = time.Now() - s.mu.Unlock() - s.notify() -} - -func (s *Store) GetGoldenSet() GoldenSetSummary { - s.mu.RLock() - defer s.mu.RUnlock() - return s.goldenSet -} - -func (s *Store) SetTrainingRuns(runs []TrainingRunStatus) { - s.mu.Lock() - s.trainingRuns = runs - s.trainingRunsAt = time.Now() - s.mu.Unlock() - s.notify() -} - -func (s *Store) GetTrainingRuns() []TrainingRunStatus { - s.mu.RLock() - defer s.mu.RUnlock() - return s.trainingRuns -} - -func (s *Store) SetDataset(d DatasetSummary) { - s.mu.Lock() - s.dataset = d - s.datasetAt = time.Now() - s.mu.Unlock() - s.notify() -} - -func (s *Store) GetDataset() DatasetSummary { - s.mu.RLock() - defer s.mu.RUnlock() - return s.dataset -} - -func (s *Store) GetErrors() map[string]string { - s.mu.RLock() - defer s.mu.RUnlock() - errCopy := make(map[string]string, len(s.errors)) - for k, v := range s.errors { - errCopy[k] = v - } - return errCopy -} diff --git a/pkg/lab/store_test.go b/pkg/lab/store_test.go deleted file mode 100644 index 6a37646..0000000 --- a/pkg/lab/store_test.go +++ /dev/null @@ -1,391 +0,0 @@ -package lab - -import ( - "errors" - "testing" - "time" -) - -// ── NewStore ──────────────────────────────────────────────────────── - -func TestNewStore_Good(t *testing.T) { - s := NewStore() - if s == nil { - t.Fatal("NewStore returned nil") - } - if s.subs == nil { - t.Fatal("subs map not initialised") - } - if s.errors == nil { - t.Fatal("errors map not initialised") - } -} - -// ── Subscribe / Unsubscribe ──────────────────────────────────────── - -func TestSubscribe_Good(t *testing.T) { - s := NewStore() - ch := s.Subscribe() - if ch == nil { - t.Fatal("Subscribe returned nil channel") - } - - s.subMu.Lock() - _, ok := s.subs[ch] - s.subMu.Unlock() - if !ok { - t.Fatal("subscriber not registered") - } -} - -func TestUnsubscribe_Good(t *testing.T) { - s := NewStore() - ch := s.Subscribe() - s.Unsubscribe(ch) - - s.subMu.Lock() - _, ok := s.subs[ch] - s.subMu.Unlock() - if ok { - t.Fatal("subscriber not removed after Unsubscribe") - } -} - -func TestUnsubscribe_Bad_NeverSubscribed(t *testing.T) { - s := NewStore() - ch := make(chan struct{}, 1) - // Should not panic. - s.Unsubscribe(ch) -} - -// ── Notify ───────────────────────────────────────────────────────── - -func TestNotify_Good_SubscriberReceivesSignal(t *testing.T) { - s := NewStore() - ch := s.Subscribe() - defer s.Unsubscribe(ch) - - s.SetMachines([]Machine{{Name: "test"}}) - - select { - case <-ch: - // good - case <-time.After(100 * time.Millisecond): - t.Fatal("subscriber did not receive notification") - } -} - -func TestNotify_Good_NonBlockingWhenFull(t *testing.T) { - s := NewStore() - ch := s.Subscribe() - defer s.Unsubscribe(ch) - - // Fill the buffer. - ch <- struct{}{} - - // Should not block. - s.SetMachines([]Machine{{Name: "a"}}) - s.SetMachines([]Machine{{Name: "b"}}) -} - -func TestNotify_Good_MultipleSubscribers(t *testing.T) { - s := NewStore() - ch1 := s.Subscribe() - ch2 := s.Subscribe() - defer s.Unsubscribe(ch1) - defer s.Unsubscribe(ch2) - - s.SetAgents(AgentSummary{Available: true}) - - for _, ch := range []chan struct{}{ch1, ch2} { - select { - case <-ch: - case <-time.After(100 * time.Millisecond): - t.Fatal("subscriber missed notification") - } - } -} - -// ── SetMachines / Overview ───────────────────────────────────────── - -func TestSetMachines_Good(t *testing.T) { - s := NewStore() - machines := []Machine{{Name: "noc", Host: "77.42.42.205"}, {Name: "de1", Host: "116.202.82.115"}} - s.SetMachines(machines) - - ov := s.Overview() - if len(ov.Machines) != 2 { - t.Fatalf("expected 2 machines, got %d", len(ov.Machines)) - } - if ov.Machines[0].Name != "noc" { - t.Fatalf("expected noc, got %s", ov.Machines[0].Name) - } -} - -func TestOverview_Good_ContainersMergedIntoFirstMachine(t *testing.T) { - s := NewStore() - s.SetMachines([]Machine{{Name: "primary"}, {Name: "secondary"}}) - s.SetContainers([]Container{{Name: "forgejo", Status: "running"}}) - - ov := s.Overview() - if len(ov.Machines[0].Containers) != 1 { - t.Fatal("containers not merged into first machine") - } - if ov.Machines[0].Containers[0].Name != "forgejo" { - t.Fatalf("unexpected container name: %s", ov.Machines[0].Containers[0].Name) - } - if len(ov.Machines[1].Containers) != 0 { - t.Fatal("containers leaked into second machine") - } -} - -func TestOverview_Good_EmptyMachinesNoContainerPanic(t *testing.T) { - s := NewStore() - s.SetContainers([]Container{{Name: "c1"}}) - - // No machines set — should not panic. - ov := s.Overview() - if len(ov.Machines) != 0 { - t.Fatal("expected zero machines") - } -} - -func TestOverview_Good_ErrorsCopied(t *testing.T) { - s := NewStore() - s.SetError("prometheus", errors.New("connection refused")) - - ov := s.Overview() - if ov.Errors["prometheus"] != "connection refused" { - t.Fatal("error not in overview") - } - - // Mutating the copy should not affect the store. - ov.Errors["prometheus"] = "hacked" - ov2 := s.Overview() - if ov2.Errors["prometheus"] != "connection refused" { - t.Fatal("overview errors map is not a copy") - } -} - -// ── SetAgents / GetAgents ────────────────────────────────────────── - -func TestAgents_Good(t *testing.T) { - s := NewStore() - s.SetAgents(AgentSummary{Available: true, RegisteredTotal: 3, QueuePending: 1}) - - got := s.GetAgents() - if !got.Available { - t.Fatal("expected Available=true") - } - if got.RegisteredTotal != 3 { - t.Fatalf("expected 3, got %d", got.RegisteredTotal) - } -} - -// ── SetTraining / GetTraining ────────────────────────────────────── - -func TestTraining_Good(t *testing.T) { - s := NewStore() - s.SetTraining(TrainingSummary{GoldGenerated: 404, GoldTarget: 15000, GoldPercent: 2.69}) - - got := s.GetTraining() - if got.GoldGenerated != 404 { - t.Fatalf("expected 404, got %d", got.GoldGenerated) - } -} - -// ── SetModels / GetModels ────────────────────────────────────────── - -func TestModels_Good(t *testing.T) { - s := NewStore() - s.SetModels([]HFModel{{ModelID: "lthn/lem-gemma3-1b", Downloads: 42}}) - - got := s.GetModels() - if len(got) != 1 { - t.Fatal("expected 1 model") - } - if got[0].Downloads != 42 { - t.Fatalf("expected 42 downloads, got %d", got[0].Downloads) - } -} - -// ── SetCommits ───────────────────────────────────────────────────── - -func TestCommits_Good(t *testing.T) { - s := NewStore() - s.SetCommits([]Commit{{SHA: "abc123", Message: "feat: test coverage", Author: "virgil"}}) - - ov := s.Overview() - if len(ov.Commits) != 1 { - t.Fatal("expected 1 commit") - } - if ov.Commits[0].Author != "virgil" { - t.Fatalf("expected virgil, got %s", ov.Commits[0].Author) - } -} - -// ── SetContainers / GetContainers ────────────────────────────────── - -func TestContainers_Good(t *testing.T) { - s := NewStore() - s.SetContainers([]Container{{Name: "traefik", Status: "running"}, {Name: "forgejo", Status: "running"}}) - - got := s.GetContainers() - if len(got) != 2 { - t.Fatal("expected 2 containers") - } -} - -// ── SetError / GetErrors ─────────────────────────────────────────── - -func TestSetError_Good_SetAndClear(t *testing.T) { - s := NewStore() - s.SetError("hf", errors.New("rate limited")) - - errs := s.GetErrors() - if errs["hf"] != "rate limited" { - t.Fatal("error not stored") - } - - // Clear by passing nil. - s.SetError("hf", nil) - errs = s.GetErrors() - if _, ok := errs["hf"]; ok { - t.Fatal("error not cleared") - } -} - -func TestGetErrors_Good_ReturnsCopy(t *testing.T) { - s := NewStore() - s.SetError("forge", errors.New("timeout")) - - errs := s.GetErrors() - errs["forge"] = "tampered" - - fresh := s.GetErrors() - if fresh["forge"] != "timeout" { - t.Fatal("GetErrors did not return a copy") - } -} - -// ── SetServices / GetServices ────────────────────────────────────── - -func TestServices_Good(t *testing.T) { - s := NewStore() - s.SetServices([]Service{{Name: "Forgejo", URL: "https://forge.lthn.ai", Status: "ok"}}) - - got := s.GetServices() - if len(got) != 1 { - t.Fatal("expected 1 service") - } - if got[0].Name != "Forgejo" { - t.Fatalf("expected Forgejo, got %s", got[0].Name) - } -} - -// ── SetBenchmarks / GetBenchmarks ────────────────────────────────── - -func TestBenchmarks_Good(t *testing.T) { - s := NewStore() - s.SetBenchmarks(BenchmarkData{ - Runs: []BenchmarkRun{{RunID: "run-1", Model: "gemma3-4b", Type: "training"}}, - }) - - got := s.GetBenchmarks() - if len(got.Runs) != 1 { - t.Fatal("expected 1 benchmark run") - } -} - -// ── SetGoldenSet / GetGoldenSet ──────────────────────────────────── - -func TestGoldenSet_Good(t *testing.T) { - s := NewStore() - s.SetGoldenSet(GoldenSetSummary{Available: true, TotalExamples: 15000, TargetTotal: 15000, CompletionPct: 100}) - - got := s.GetGoldenSet() - if !got.Available { - t.Fatal("expected Available=true") - } - if got.TotalExamples != 15000 { - t.Fatalf("expected 15000, got %d", got.TotalExamples) - } -} - -// ── SetTrainingRuns / GetTrainingRuns ─────────────────────────────── - -func TestTrainingRuns_Good(t *testing.T) { - s := NewStore() - s.SetTrainingRuns([]TrainingRunStatus{ - {Model: "gemma3-4b", RunID: "r1", Status: "training", Iteration: 100, TotalIters: 300}, - }) - - got := s.GetTrainingRuns() - if len(got) != 1 { - t.Fatal("expected 1 training run") - } - if got[0].Iteration != 100 { - t.Fatalf("expected iter 100, got %d", got[0].Iteration) - } -} - -// ── SetDataset / GetDataset ──────────────────────────────────────── - -func TestDataset_Good(t *testing.T) { - s := NewStore() - s.SetDataset(DatasetSummary{ - Available: true, - Tables: []DatasetTable{{Name: "golden_set", Rows: 15000}}, - }) - - got := s.GetDataset() - if !got.Available { - t.Fatal("expected Available=true") - } - if len(got.Tables) != 1 { - t.Fatal("expected 1 table") - } -} - -// ── Concurrent access (race detector) ────────────────────────────── - -func TestConcurrentAccess_Good(t *testing.T) { - s := NewStore() - done := make(chan struct{}) - - // Writer goroutine. - go func() { - for i := range 100 { - s.SetMachines([]Machine{{Name: "noc"}}) - s.SetAgents(AgentSummary{Available: true}) - s.SetTraining(TrainingSummary{GoldGenerated: i}) - s.SetModels([]HFModel{{ModelID: "m1"}}) - s.SetCommits([]Commit{{SHA: "abc"}}) - s.SetContainers([]Container{{Name: "c1"}}) - s.SetError("test", errors.New("e")) - s.SetServices([]Service{{Name: "s1"}}) - s.SetBenchmarks(BenchmarkData{}) - s.SetGoldenSet(GoldenSetSummary{}) - s.SetTrainingRuns([]TrainingRunStatus{}) - s.SetDataset(DatasetSummary{}) - } - close(done) - }() - - // Reader goroutine. - for range 100 { - _ = s.Overview() - _ = s.GetModels() - _ = s.GetTraining() - _ = s.GetAgents() - _ = s.GetContainers() - _ = s.GetServices() - _ = s.GetBenchmarks() - _ = s.GetGoldenSet() - _ = s.GetTrainingRuns() - _ = s.GetDataset() - _ = s.GetErrors() - } - - <-done -} diff --git a/pkg/manifest/loader.go b/pkg/manifest/loader.go deleted file mode 100644 index e47eda1..0000000 --- a/pkg/manifest/loader.go +++ /dev/null @@ -1,43 +0,0 @@ -package manifest - -import ( - "crypto/ed25519" - "fmt" - "path/filepath" - - "forge.lthn.ai/core/go-io" - "gopkg.in/yaml.v3" -) - -const manifestPath = ".core/view.yml" - -// MarshalYAML serializes a manifest to YAML bytes. -func MarshalYAML(m *Manifest) ([]byte, error) { - return yaml.Marshal(m) -} - -// Load reads and parses a .core/view.yml from the given root directory. -func Load(medium io.Medium, root string) (*Manifest, error) { - path := filepath.Join(root, manifestPath) - data, err := medium.Read(path) - if err != nil { - return nil, fmt.Errorf("manifest.Load: %w", err) - } - return Parse([]byte(data)) -} - -// LoadVerified reads, parses, and verifies the ed25519 signature. -func LoadVerified(medium io.Medium, root string, pub ed25519.PublicKey) (*Manifest, error) { - m, err := Load(medium, root) - if err != nil { - return nil, err - } - ok, err := Verify(m, pub) - if err != nil { - return nil, fmt.Errorf("manifest.LoadVerified: %w", err) - } - if !ok { - return nil, fmt.Errorf("manifest.LoadVerified: signature verification failed for %q", m.Code) - } - return m, nil -} diff --git a/pkg/manifest/loader_test.go b/pkg/manifest/loader_test.go deleted file mode 100644 index a7b489a..0000000 --- a/pkg/manifest/loader_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package manifest - -import ( - "crypto/ed25519" - "testing" - - "forge.lthn.ai/core/go-io" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestLoad_Good(t *testing.T) { - fs := io.NewMockMedium() - fs.Files[".core/view.yml"] = ` -code: test-app -name: Test App -version: 1.0.0 -layout: HLCRF -slots: - C: main-content -` - m, err := Load(fs, ".") - require.NoError(t, err) - assert.Equal(t, "test-app", m.Code) - assert.Equal(t, "main-content", m.Slots["C"]) -} - -func TestLoad_Bad_NoManifest(t *testing.T) { - fs := io.NewMockMedium() - _, err := Load(fs, ".") - assert.Error(t, err) -} - -func TestLoadVerified_Good(t *testing.T) { - pub, priv, _ := ed25519.GenerateKey(nil) - m := &Manifest{ - Code: "signed-app", Name: "Signed", Version: "1.0.0", - Layout: "HLCRF", Slots: map[string]string{"C": "main"}, - } - _ = Sign(m, priv) - - raw, _ := MarshalYAML(m) - fs := io.NewMockMedium() - fs.Files[".core/view.yml"] = string(raw) - - loaded, err := LoadVerified(fs, ".", pub) - require.NoError(t, err) - assert.Equal(t, "signed-app", loaded.Code) -} - -func TestLoadVerified_Bad_Tampered(t *testing.T) { - pub, priv, _ := ed25519.GenerateKey(nil) - m := &Manifest{Code: "app", Version: "1.0.0"} - _ = Sign(m, priv) - - raw, _ := MarshalYAML(m) - tampered := "code: evil\n" + string(raw)[6:] - fs := io.NewMockMedium() - fs.Files[".core/view.yml"] = tampered - - _, err := LoadVerified(fs, ".", pub) - assert.Error(t, err) -} diff --git a/pkg/manifest/manifest.go b/pkg/manifest/manifest.go deleted file mode 100644 index 72ae785..0000000 --- a/pkg/manifest/manifest.go +++ /dev/null @@ -1,50 +0,0 @@ -package manifest - -import ( - "fmt" - - "gopkg.in/yaml.v3" -) - -// Manifest represents a .core/view.yml application manifest. -type Manifest struct { - Code string `yaml:"code"` - Name string `yaml:"name"` - Version string `yaml:"version"` - Sign string `yaml:"sign"` - Layout string `yaml:"layout"` - Slots map[string]string `yaml:"slots"` - - Permissions Permissions `yaml:"permissions"` - Modules []string `yaml:"modules"` -} - -// Permissions declares the I/O capabilities a module requires. -type Permissions struct { - Read []string `yaml:"read"` - Write []string `yaml:"write"` - Net []string `yaml:"net"` - Run []string `yaml:"run"` -} - -// Parse decodes YAML bytes into a Manifest. -func Parse(data []byte) (*Manifest, error) { - var m Manifest - if err := yaml.Unmarshal(data, &m); err != nil { - return nil, fmt.Errorf("manifest.Parse: %w", err) - } - return &m, nil -} - -// SlotNames returns a deduplicated list of component names from slots. -func (m *Manifest) SlotNames() []string { - seen := make(map[string]bool) - var names []string - for _, name := range m.Slots { - if !seen[name] { - seen[name] = true - names = append(names, name) - } - } - return names -} diff --git a/pkg/manifest/manifest_test.go b/pkg/manifest/manifest_test.go deleted file mode 100644 index 63ca253..0000000 --- a/pkg/manifest/manifest_test.go +++ /dev/null @@ -1,65 +0,0 @@ -package manifest - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestParse_Good(t *testing.T) { - raw := ` -code: photo-browser -name: Photo Browser -version: 0.1.0 -sign: dGVzdHNpZw== - -layout: HLCRF -slots: - H: nav-breadcrumb - L: folder-tree - C: photo-grid - R: metadata-panel - F: status-bar - -permissions: - read: ["./photos/"] - write: [] - net: [] - run: [] - -modules: - - core/media - - core/fs -` - m, err := Parse([]byte(raw)) - require.NoError(t, err) - assert.Equal(t, "photo-browser", m.Code) - assert.Equal(t, "Photo Browser", m.Name) - assert.Equal(t, "0.1.0", m.Version) - assert.Equal(t, "dGVzdHNpZw==", m.Sign) - assert.Equal(t, "HLCRF", m.Layout) - assert.Equal(t, "nav-breadcrumb", m.Slots["H"]) - assert.Equal(t, "photo-grid", m.Slots["C"]) - assert.Len(t, m.Permissions.Read, 1) - assert.Equal(t, "./photos/", m.Permissions.Read[0]) - assert.Len(t, m.Modules, 2) -} - -func TestParse_Bad(t *testing.T) { - _, err := Parse([]byte("not: valid: yaml: [")) - assert.Error(t, err) -} - -func TestManifest_SlotNames_Good(t *testing.T) { - m := Manifest{ - Slots: map[string]string{ - "H": "nav-bar", - "C": "main-content", - }, - } - names := m.SlotNames() - assert.Contains(t, names, "nav-bar") - assert.Contains(t, names, "main-content") - assert.Len(t, names, 2) -} diff --git a/pkg/manifest/sign.go b/pkg/manifest/sign.go deleted file mode 100644 index 857d15c..0000000 --- a/pkg/manifest/sign.go +++ /dev/null @@ -1,44 +0,0 @@ -package manifest - -import ( - "crypto/ed25519" - "encoding/base64" - "errors" - "fmt" - - "gopkg.in/yaml.v3" -) - -// signable returns the canonical bytes to sign (manifest without sign field). -func signable(m *Manifest) ([]byte, error) { - tmp := *m - tmp.Sign = "" - return yaml.Marshal(&tmp) -} - -// Sign computes the ed25519 signature and stores it in m.Sign (base64). -func Sign(m *Manifest, priv ed25519.PrivateKey) error { - msg, err := signable(m) - if err != nil { - return fmt.Errorf("manifest.Sign: marshal: %w", err) - } - sig := ed25519.Sign(priv, msg) - m.Sign = base64.StdEncoding.EncodeToString(sig) - return nil -} - -// Verify checks the ed25519 signature in m.Sign against the public key. -func Verify(m *Manifest, pub ed25519.PublicKey) (bool, error) { - if m.Sign == "" { - return false, errors.New("manifest.Verify: no signature present") - } - sig, err := base64.StdEncoding.DecodeString(m.Sign) - if err != nil { - return false, fmt.Errorf("manifest.Verify: decode: %w", err) - } - msg, err := signable(m) - if err != nil { - return false, fmt.Errorf("manifest.Verify: marshal: %w", err) - } - return ed25519.Verify(pub, msg, sig), nil -} diff --git a/pkg/manifest/sign_test.go b/pkg/manifest/sign_test.go deleted file mode 100644 index bee2503..0000000 --- a/pkg/manifest/sign_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package manifest - -import ( - "crypto/ed25519" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestSignAndVerify_Good(t *testing.T) { - pub, priv, err := ed25519.GenerateKey(nil) - require.NoError(t, err) - - m := &Manifest{ - Code: "test-app", - Name: "Test App", - Version: "1.0.0", - Layout: "HLCRF", - Slots: map[string]string{"C": "main"}, - } - - err = Sign(m, priv) - require.NoError(t, err) - assert.NotEmpty(t, m.Sign) - - ok, err := Verify(m, pub) - require.NoError(t, err) - assert.True(t, ok) -} - -func TestVerify_Bad_Tampered(t *testing.T) { - pub, priv, _ := ed25519.GenerateKey(nil) - m := &Manifest{Code: "test-app", Version: "1.0.0"} - _ = Sign(m, priv) - - m.Code = "evil-app" // tamper - - ok, err := Verify(m, pub) - require.NoError(t, err) - assert.False(t, ok) -} - -func TestVerify_Bad_Unsigned(t *testing.T) { - pub, _, _ := ed25519.GenerateKey(nil) - m := &Manifest{Code: "test-app"} - - ok, err := Verify(m, pub) - assert.Error(t, err) - assert.False(t, ok) -} diff --git a/pkg/marketplace/installer.go b/pkg/marketplace/installer.go deleted file mode 100644 index 616b146..0000000 --- a/pkg/marketplace/installer.go +++ /dev/null @@ -1,196 +0,0 @@ -package marketplace - -import ( - "context" - "encoding/hex" - "encoding/json" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "time" - - "forge.lthn.ai/core/go-io" - "forge.lthn.ai/core/go/pkg/manifest" - "forge.lthn.ai/core/go/pkg/store" -) - -const storeGroup = "_modules" - -// Installer handles module installation from Git repos. -type Installer struct { - modulesDir string - store *store.Store -} - -// NewInstaller creates a new module installer. -func NewInstaller(modulesDir string, st *store.Store) *Installer { - return &Installer{ - modulesDir: modulesDir, - store: st, - } -} - -// InstalledModule holds stored metadata about an installed module. -type InstalledModule struct { - Code string `json:"code"` - Name string `json:"name"` - Version string `json:"version"` - Repo string `json:"repo"` - EntryPoint string `json:"entry_point"` - Permissions manifest.Permissions `json:"permissions"` - SignKey string `json:"sign_key,omitempty"` - InstalledAt string `json:"installed_at"` -} - -// Install clones a module repo, verifies its manifest signature, and registers it. -func (i *Installer) Install(ctx context.Context, mod Module) error { - // Check if already installed - if _, err := i.store.Get(storeGroup, mod.Code); err == nil { - return fmt.Errorf("marketplace: module %q already installed", mod.Code) - } - - dest := filepath.Join(i.modulesDir, mod.Code) - if err := os.MkdirAll(i.modulesDir, 0755); err != nil { - return fmt.Errorf("marketplace: mkdir: %w", err) - } - if err := gitClone(ctx, mod.Repo, dest); err != nil { - return fmt.Errorf("marketplace: clone %s: %w", mod.Repo, err) - } - - // On any error after clone, clean up the directory - cleanup := true - defer func() { - if cleanup { - os.RemoveAll(dest) - } - }() - - medium, err := io.NewSandboxed(dest) - if err != nil { - return fmt.Errorf("marketplace: medium: %w", err) - } - - m, err := loadManifest(medium, mod.SignKey) - if err != nil { - return err - } - - entryPoint := filepath.Join(dest, "main.ts") - installed := InstalledModule{ - Code: mod.Code, - Name: m.Name, - Version: m.Version, - Repo: mod.Repo, - EntryPoint: entryPoint, - Permissions: m.Permissions, - SignKey: mod.SignKey, - InstalledAt: time.Now().UTC().Format(time.RFC3339), - } - - data, err := json.Marshal(installed) - if err != nil { - return fmt.Errorf("marketplace: marshal: %w", err) - } - - if err := i.store.Set(storeGroup, mod.Code, string(data)); err != nil { - return fmt.Errorf("marketplace: store: %w", err) - } - - cleanup = false - return nil -} - -// Remove uninstalls a module by deleting its files and store entry. -func (i *Installer) Remove(code string) error { - if _, err := i.store.Get(storeGroup, code); err != nil { - return fmt.Errorf("marketplace: module %q not installed", code) - } - - dest := filepath.Join(i.modulesDir, code) - os.RemoveAll(dest) - - return i.store.Delete(storeGroup, code) -} - -// Update pulls latest changes and re-verifies the manifest. -func (i *Installer) Update(ctx context.Context, code string) error { - raw, err := i.store.Get(storeGroup, code) - if err != nil { - return fmt.Errorf("marketplace: module %q not installed", code) - } - - var installed InstalledModule - if err := json.Unmarshal([]byte(raw), &installed); err != nil { - return fmt.Errorf("marketplace: unmarshal: %w", err) - } - - dest := filepath.Join(i.modulesDir, code) - - cmd := exec.CommandContext(ctx, "git", "-C", dest, "pull", "--ff-only") - if output, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("marketplace: pull: %s: %w", strings.TrimSpace(string(output)), err) - } - - // Reload and re-verify manifest with the same key used at install time - medium, mErr := io.NewSandboxed(dest) - if mErr != nil { - return fmt.Errorf("marketplace: medium: %w", mErr) - } - m, mErr := loadManifest(medium, installed.SignKey) - if mErr != nil { - return fmt.Errorf("marketplace: reload manifest: %w", mErr) - } - - // Update stored metadata - installed.Name = m.Name - installed.Version = m.Version - installed.Permissions = m.Permissions - - data, err := json.Marshal(installed) - if err != nil { - return fmt.Errorf("marketplace: marshal: %w", err) - } - - return i.store.Set(storeGroup, code, string(data)) -} - -// Installed returns all installed module metadata. -func (i *Installer) Installed() ([]InstalledModule, error) { - all, err := i.store.GetAll(storeGroup) - if err != nil { - return nil, fmt.Errorf("marketplace: list: %w", err) - } - - var modules []InstalledModule - for _, raw := range all { - var m InstalledModule - if err := json.Unmarshal([]byte(raw), &m); err != nil { - continue - } - modules = append(modules, m) - } - return modules, nil -} - -// loadManifest loads and optionally verifies a module manifest. -func loadManifest(medium io.Medium, signKey string) (*manifest.Manifest, error) { - if signKey != "" { - pubBytes, err := hex.DecodeString(signKey) - if err != nil { - return nil, fmt.Errorf("marketplace: decode sign key: %w", err) - } - return manifest.LoadVerified(medium, ".", pubBytes) - } - return manifest.Load(medium, ".") -} - -// gitClone clones a repository with --depth=1. -func gitClone(ctx context.Context, repo, dest string) error { - cmd := exec.CommandContext(ctx, "git", "clone", "--depth=1", repo, dest) - if output, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("%s: %w", strings.TrimSpace(string(output)), err) - } - return nil -} diff --git a/pkg/marketplace/installer_test.go b/pkg/marketplace/installer_test.go deleted file mode 100644 index a8164fe..0000000 --- a/pkg/marketplace/installer_test.go +++ /dev/null @@ -1,263 +0,0 @@ -package marketplace - -import ( - "context" - "crypto/ed25519" - "encoding/hex" - "os" - "os/exec" - "path/filepath" - "testing" - - "forge.lthn.ai/core/go/pkg/manifest" - "forge.lthn.ai/core/go/pkg/store" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// createTestRepo creates a bare-bones git repo with a manifest and main.ts. -// Returns the repo path (usable as Module.Repo for local clone). -func createTestRepo(t *testing.T, code, version string) string { - t.Helper() - dir := filepath.Join(t.TempDir(), code) - require.NoError(t, os.MkdirAll(filepath.Join(dir, ".core"), 0755)) - - manifestYAML := "code: " + code + "\nname: Test " + code + "\nversion: \"" + version + "\"\n" - require.NoError(t, os.WriteFile( - filepath.Join(dir, ".core", "view.yml"), - []byte(manifestYAML), 0644, - )) - require.NoError(t, os.WriteFile( - filepath.Join(dir, "main.ts"), - []byte("export async function init(core: any) {}\n"), 0644, - )) - - runGit(t, dir, "init") - runGit(t, dir, "add", ".") - runGit(t, dir, "commit", "-m", "init") - return dir -} - -// createSignedTestRepo creates a git repo with a signed manifest. -// Returns (repo path, hex-encoded public key). -func createSignedTestRepo(t *testing.T, code, version string) (string, string) { - t.Helper() - pub, priv, err := ed25519.GenerateKey(nil) - require.NoError(t, err) - - dir := filepath.Join(t.TempDir(), code) - require.NoError(t, os.MkdirAll(filepath.Join(dir, ".core"), 0755)) - - m := &manifest.Manifest{ - Code: code, - Name: "Test " + code, - Version: version, - } - require.NoError(t, manifest.Sign(m, priv)) - - data, err := manifest.MarshalYAML(m) - require.NoError(t, err) - require.NoError(t, os.WriteFile(filepath.Join(dir, ".core", "view.yml"), data, 0644)) - require.NoError(t, os.WriteFile(filepath.Join(dir, "main.ts"), []byte("export async function init(core: any) {}\n"), 0644)) - - runGit(t, dir, "init") - runGit(t, dir, "add", ".") - runGit(t, dir, "commit", "-m", "init") - - return dir, hex.EncodeToString(pub) -} - -func runGit(t *testing.T, dir string, args ...string) { - t.Helper() - cmd := exec.Command("git", append([]string{"-C", dir, "-c", "user.email=test@test.com", "-c", "user.name=test"}, args...)...) - out, err := cmd.CombinedOutput() - require.NoError(t, err, "git %v: %s", args, string(out)) -} - -func TestInstall_Good(t *testing.T) { - repo := createTestRepo(t, "hello-mod", "1.0") - modulesDir := filepath.Join(t.TempDir(), "modules") - - st, err := store.New(":memory:") - require.NoError(t, err) - defer st.Close() - - inst := NewInstaller(modulesDir, st) - err = inst.Install(context.Background(), Module{ - Code: "hello-mod", - Repo: repo, - }) - require.NoError(t, err) - - // Verify directory exists - _, err = os.Stat(filepath.Join(modulesDir, "hello-mod", "main.ts")) - assert.NoError(t, err, "main.ts should exist in installed module") - - // Verify store entry - raw, err := st.Get("_modules", "hello-mod") - require.NoError(t, err) - assert.Contains(t, raw, `"code":"hello-mod"`) - assert.Contains(t, raw, `"version":"1.0"`) -} - -func TestInstall_Good_Signed(t *testing.T) { - repo, signKey := createSignedTestRepo(t, "signed-mod", "2.0") - modulesDir := filepath.Join(t.TempDir(), "modules") - - st, err := store.New(":memory:") - require.NoError(t, err) - defer st.Close() - - inst := NewInstaller(modulesDir, st) - err = inst.Install(context.Background(), Module{ - Code: "signed-mod", - Repo: repo, - SignKey: signKey, - }) - require.NoError(t, err) - - raw, err := st.Get("_modules", "signed-mod") - require.NoError(t, err) - assert.Contains(t, raw, `"version":"2.0"`) -} - -func TestInstall_Bad_AlreadyInstalled(t *testing.T) { - repo := createTestRepo(t, "dup-mod", "1.0") - modulesDir := filepath.Join(t.TempDir(), "modules") - - st, err := store.New(":memory:") - require.NoError(t, err) - defer st.Close() - - inst := NewInstaller(modulesDir, st) - mod := Module{Code: "dup-mod", Repo: repo} - - require.NoError(t, inst.Install(context.Background(), mod)) - err = inst.Install(context.Background(), mod) - assert.Error(t, err) - assert.Contains(t, err.Error(), "already installed") -} - -func TestInstall_Bad_InvalidSignature(t *testing.T) { - // Sign with key A, verify with key B - repo, _ := createSignedTestRepo(t, "bad-sig", "1.0") - _, wrongKey := createSignedTestRepo(t, "dummy", "1.0") // different key - - modulesDir := filepath.Join(t.TempDir(), "modules") - - st, err := store.New(":memory:") - require.NoError(t, err) - defer st.Close() - - inst := NewInstaller(modulesDir, st) - err = inst.Install(context.Background(), Module{ - Code: "bad-sig", - Repo: repo, - SignKey: wrongKey, - }) - assert.Error(t, err) - - // Verify directory was cleaned up - _, statErr := os.Stat(filepath.Join(modulesDir, "bad-sig")) - assert.True(t, os.IsNotExist(statErr), "directory should be cleaned up on failure") -} - -func TestRemove_Good(t *testing.T) { - repo := createTestRepo(t, "rm-mod", "1.0") - modulesDir := filepath.Join(t.TempDir(), "modules") - - st, err := store.New(":memory:") - require.NoError(t, err) - defer st.Close() - - inst := NewInstaller(modulesDir, st) - require.NoError(t, inst.Install(context.Background(), Module{Code: "rm-mod", Repo: repo})) - - err = inst.Remove("rm-mod") - require.NoError(t, err) - - // Directory gone - _, statErr := os.Stat(filepath.Join(modulesDir, "rm-mod")) - assert.True(t, os.IsNotExist(statErr)) - - // Store entry gone - _, err = st.Get("_modules", "rm-mod") - assert.Error(t, err) -} - -func TestRemove_Bad_NotInstalled(t *testing.T) { - st, err := store.New(":memory:") - require.NoError(t, err) - defer st.Close() - - inst := NewInstaller(t.TempDir(), st) - err = inst.Remove("nonexistent") - assert.Error(t, err) - assert.Contains(t, err.Error(), "not installed") -} - -func TestInstalled_Good(t *testing.T) { - modulesDir := filepath.Join(t.TempDir(), "modules") - - st, err := store.New(":memory:") - require.NoError(t, err) - defer st.Close() - - inst := NewInstaller(modulesDir, st) - - repo1 := createTestRepo(t, "mod-a", "1.0") - repo2 := createTestRepo(t, "mod-b", "2.0") - - require.NoError(t, inst.Install(context.Background(), Module{Code: "mod-a", Repo: repo1})) - require.NoError(t, inst.Install(context.Background(), Module{Code: "mod-b", Repo: repo2})) - - installed, err := inst.Installed() - require.NoError(t, err) - assert.Len(t, installed, 2) - - codes := map[string]bool{} - for _, m := range installed { - codes[m.Code] = true - } - assert.True(t, codes["mod-a"]) - assert.True(t, codes["mod-b"]) -} - -func TestInstalled_Good_Empty(t *testing.T) { - st, err := store.New(":memory:") - require.NoError(t, err) - defer st.Close() - - inst := NewInstaller(t.TempDir(), st) - installed, err := inst.Installed() - require.NoError(t, err) - assert.Empty(t, installed) -} - -func TestUpdate_Good(t *testing.T) { - repo := createTestRepo(t, "upd-mod", "1.0") - modulesDir := filepath.Join(t.TempDir(), "modules") - - st, err := store.New(":memory:") - require.NoError(t, err) - defer st.Close() - - inst := NewInstaller(modulesDir, st) - require.NoError(t, inst.Install(context.Background(), Module{Code: "upd-mod", Repo: repo})) - - // Update the origin repo - newManifest := "code: upd-mod\nname: Updated Module\nversion: \"2.0\"\n" - require.NoError(t, os.WriteFile(filepath.Join(repo, ".core", "view.yml"), []byte(newManifest), 0644)) - runGit(t, repo, "add", ".") - runGit(t, repo, "commit", "-m", "bump version") - - err = inst.Update(context.Background(), "upd-mod") - require.NoError(t, err) - - // Verify updated metadata - installed, err := inst.Installed() - require.NoError(t, err) - require.Len(t, installed, 1) - assert.Equal(t, "2.0", installed[0].Version) - assert.Equal(t, "Updated Module", installed[0].Name) -} diff --git a/pkg/marketplace/marketplace.go b/pkg/marketplace/marketplace.go deleted file mode 100644 index 52b4a8f..0000000 --- a/pkg/marketplace/marketplace.go +++ /dev/null @@ -1,67 +0,0 @@ -package marketplace - -import ( - "encoding/json" - "fmt" - "strings" -) - -// Module is a marketplace entry pointing to a module's Git repo. -type Module struct { - Code string `json:"code"` - Name string `json:"name"` - Repo string `json:"repo"` - SignKey string `json:"sign_key"` - Category string `json:"category"` -} - -// Index is the root marketplace catalog. -type Index struct { - Version int `json:"version"` - Modules []Module `json:"modules"` - Categories []string `json:"categories"` -} - -// ParseIndex decodes a marketplace index.json. -func ParseIndex(data []byte) (*Index, error) { - var idx Index - if err := json.Unmarshal(data, &idx); err != nil { - return nil, fmt.Errorf("marketplace.ParseIndex: %w", err) - } - return &idx, nil -} - -// Search returns modules matching the query in code, name, or category. -func (idx *Index) Search(query string) []Module { - q := strings.ToLower(query) - var results []Module - for _, m := range idx.Modules { - if strings.Contains(strings.ToLower(m.Code), q) || - strings.Contains(strings.ToLower(m.Name), q) || - strings.Contains(strings.ToLower(m.Category), q) { - results = append(results, m) - } - } - return results -} - -// ByCategory returns all modules in the given category. -func (idx *Index) ByCategory(category string) []Module { - var results []Module - for _, m := range idx.Modules { - if m.Category == category { - results = append(results, m) - } - } - return results -} - -// Find returns the module with the given code, or false if not found. -func (idx *Index) Find(code string) (Module, bool) { - for _, m := range idx.Modules { - if m.Code == code { - return m, true - } - } - return Module{}, false -} diff --git a/pkg/marketplace/marketplace_test.go b/pkg/marketplace/marketplace_test.go deleted file mode 100644 index c51d0ee..0000000 --- a/pkg/marketplace/marketplace_test.go +++ /dev/null @@ -1,65 +0,0 @@ -package marketplace - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestParseIndex_Good(t *testing.T) { - raw := `{ - "version": 1, - "modules": [ - {"code": "mining-xmrig", "name": "XMRig Miner", "repo": "https://forge.lthn.io/host-uk/mod-xmrig.git", "sign_key": "abc123", "category": "miner"}, - {"code": "utils-cyberchef", "name": "CyberChef", "repo": "https://forge.lthn.io/host-uk/mod-cyberchef.git", "sign_key": "def456", "category": "utils"} - ], - "categories": ["miner", "utils"] - }` - idx, err := ParseIndex([]byte(raw)) - require.NoError(t, err) - assert.Equal(t, 1, idx.Version) - assert.Len(t, idx.Modules, 2) - assert.Equal(t, "mining-xmrig", idx.Modules[0].Code) -} - -func TestSearch_Good(t *testing.T) { - idx := &Index{ - Modules: []Module{ - {Code: "mining-xmrig", Name: "XMRig Miner", Category: "miner"}, - {Code: "utils-cyberchef", Name: "CyberChef", Category: "utils"}, - }, - } - results := idx.Search("miner") - assert.Len(t, results, 1) - assert.Equal(t, "mining-xmrig", results[0].Code) -} - -func TestByCategory_Good(t *testing.T) { - idx := &Index{ - Modules: []Module{ - {Code: "a", Category: "miner"}, - {Code: "b", Category: "utils"}, - {Code: "c", Category: "miner"}, - }, - } - miners := idx.ByCategory("miner") - assert.Len(t, miners, 2) -} - -func TestFind_Good(t *testing.T) { - idx := &Index{ - Modules: []Module{ - {Code: "mining-xmrig", Name: "XMRig"}, - }, - } - m, ok := idx.Find("mining-xmrig") - assert.True(t, ok) - assert.Equal(t, "XMRig", m.Name) -} - -func TestFind_Bad_NotFound(t *testing.T) { - idx := &Index{} - _, ok := idx.Find("nope") - assert.False(t, ok) -} diff --git a/pkg/plugin/config.go b/pkg/plugin/config.go deleted file mode 100644 index 3155489..0000000 --- a/pkg/plugin/config.go +++ /dev/null @@ -1,10 +0,0 @@ -package plugin - -// PluginConfig holds configuration for a single installed plugin. -type PluginConfig struct { - Name string `json:"name" yaml:"name"` - Version string `json:"version" yaml:"version"` - Source string `json:"source" yaml:"source"` // e.g., "github:org/repo" - Enabled bool `json:"enabled" yaml:"enabled"` - InstalledAt string `json:"installed_at" yaml:"installed_at"` // RFC 3339 timestamp -} diff --git a/pkg/plugin/installer.go b/pkg/plugin/installer.go deleted file mode 100644 index 8a595bb..0000000 --- a/pkg/plugin/installer.go +++ /dev/null @@ -1,195 +0,0 @@ -package plugin - -import ( - "context" - "fmt" - "os/exec" - "path/filepath" - "strings" - "time" - - coreerr "forge.lthn.ai/core/go-log" - "forge.lthn.ai/core/go-io" -) - -// Installer handles plugin installation from GitHub. -type Installer struct { - medium io.Medium - registry *Registry -} - -// NewInstaller creates a new plugin installer. -func NewInstaller(m io.Medium, registry *Registry) *Installer { - return &Installer{ - medium: m, - registry: registry, - } -} - -// Install downloads and installs a plugin from GitHub. -// The source format is "org/repo" or "org/repo@version". -func (i *Installer) Install(ctx context.Context, source string) error { - org, repo, version, err := ParseSource(source) - if err != nil { - return coreerr.E("plugin.Installer.Install", "invalid source", err) - } - - // Check if already installed - if _, exists := i.registry.Get(repo); exists { - return coreerr.E("plugin.Installer.Install", "plugin already installed: "+repo, nil) - } - - // Clone the repository - pluginDir := filepath.Join(i.registry.basePath, repo) - if err := i.medium.EnsureDir(pluginDir); err != nil { - return coreerr.E("plugin.Installer.Install", "failed to create plugin directory", err) - } - - if err := i.cloneRepo(ctx, org, repo, version, pluginDir); err != nil { - return coreerr.E("plugin.Installer.Install", "failed to clone repository", err) - } - - // Load and validate manifest - manifestPath := filepath.Join(pluginDir, "plugin.json") - manifest, err := LoadManifest(i.medium, manifestPath) - if err != nil { - // Clean up on failure - _ = i.medium.DeleteAll(pluginDir) - return coreerr.E("plugin.Installer.Install", "failed to load manifest", err) - } - - if err := manifest.Validate(); err != nil { - _ = i.medium.DeleteAll(pluginDir) - return coreerr.E("plugin.Installer.Install", "invalid manifest", err) - } - - // Resolve version - if version == "" { - version = manifest.Version - } - - // Register in the registry - cfg := &PluginConfig{ - Name: manifest.Name, - Version: version, - Source: fmt.Sprintf("github:%s/%s", org, repo), - Enabled: true, - InstalledAt: time.Now().UTC().Format(time.RFC3339), - } - - if err := i.registry.Add(cfg); err != nil { - return coreerr.E("plugin.Installer.Install", "failed to register plugin", err) - } - - if err := i.registry.Save(); err != nil { - return coreerr.E("plugin.Installer.Install", "failed to save registry", err) - } - - return nil -} - -// Update updates a plugin to the latest version. -func (i *Installer) Update(ctx context.Context, name string) error { - cfg, ok := i.registry.Get(name) - if !ok { - return coreerr.E("plugin.Installer.Update", "plugin not found: "+name, nil) - } - - // Parse the source to get org/repo - source := strings.TrimPrefix(cfg.Source, "github:") - pluginDir := filepath.Join(i.registry.basePath, name) - - // Pull latest changes - cmd := exec.CommandContext(ctx, "git", "-C", pluginDir, "pull", "--ff-only") - if output, err := cmd.CombinedOutput(); err != nil { - return coreerr.E("plugin.Installer.Update", "failed to pull updates: "+strings.TrimSpace(string(output)), err) - } - - // Reload manifest to get updated version - manifestPath := filepath.Join(pluginDir, "plugin.json") - manifest, err := LoadManifest(i.medium, manifestPath) - if err != nil { - return coreerr.E("plugin.Installer.Update", "failed to read updated manifest", err) - } - - // Update registry - cfg.Version = manifest.Version - if err := i.registry.Save(); err != nil { - return coreerr.E("plugin.Installer.Update", "failed to save registry", err) - } - - _ = source // used for context - return nil -} - -// Remove uninstalls a plugin by removing its files and registry entry. -func (i *Installer) Remove(name string) error { - if _, ok := i.registry.Get(name); !ok { - return coreerr.E("plugin.Installer.Remove", "plugin not found: "+name, nil) - } - - // Delete plugin directory - pluginDir := filepath.Join(i.registry.basePath, name) - if i.medium.Exists(pluginDir) { - if err := i.medium.DeleteAll(pluginDir); err != nil { - return coreerr.E("plugin.Installer.Remove", "failed to delete plugin files", err) - } - } - - // Remove from registry - if err := i.registry.Remove(name); err != nil { - return coreerr.E("plugin.Installer.Remove", "failed to unregister plugin", err) - } - - if err := i.registry.Save(); err != nil { - return coreerr.E("plugin.Installer.Remove", "failed to save registry", err) - } - - return nil -} - -// cloneRepo clones a GitHub repository using the gh CLI. -func (i *Installer) cloneRepo(ctx context.Context, org, repo, version, dest string) error { - repoURL := fmt.Sprintf("%s/%s", org, repo) - - args := []string{"repo", "clone", repoURL, dest} - if version != "" { - args = append(args, "--", "--branch", version) - } - - cmd := exec.CommandContext(ctx, "gh", args...) - if output, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(output))) - } - - return nil -} - -// ParseSource parses a plugin source string into org, repo, and version. -// Accepted formats: -// - "org/repo" -> org="org", repo="repo", version="" -// - "org/repo@v1.0" -> org="org", repo="repo", version="v1.0" -func ParseSource(source string) (org, repo, version string, err error) { - if source == "" { - return "", "", "", coreerr.E("plugin.ParseSource", "source is empty", nil) - } - - // Split off version if present - atIdx := strings.LastIndex(source, "@") - path := source - if atIdx != -1 { - path = source[:atIdx] - version = source[atIdx+1:] - if version == "" { - return "", "", "", coreerr.E("plugin.ParseSource", "version is empty after @", nil) - } - } - - // Split org/repo - parts := strings.Split(path, "/") - if len(parts) != 2 || parts[0] == "" || parts[1] == "" { - return "", "", "", coreerr.E("plugin.ParseSource", "source must be in format org/repo[@version]", nil) - } - - return parts[0], parts[1], version, nil -} diff --git a/pkg/plugin/installer_test.go b/pkg/plugin/installer_test.go deleted file mode 100644 index 62a8b4f..0000000 --- a/pkg/plugin/installer_test.go +++ /dev/null @@ -1,166 +0,0 @@ -package plugin - -import ( - "context" - "testing" - - "forge.lthn.ai/core/go-io" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// ── NewInstaller ─────────────────────────────────────────────────── - -func TestNewInstaller_Good(t *testing.T) { - m := io.NewMockMedium() - reg := NewRegistry(m, "/plugins") - inst := NewInstaller(m, reg) - - assert.NotNil(t, inst) - assert.Equal(t, m, inst.medium) - assert.Equal(t, reg, inst.registry) -} - -// ── Install error paths ──────────────────────────────────────────── - -func TestInstall_Bad_InvalidSource(t *testing.T) { - m := io.NewMockMedium() - reg := NewRegistry(m, "/plugins") - inst := NewInstaller(m, reg) - - err := inst.Install(context.Background(), "bad-source") - assert.Error(t, err) - assert.Contains(t, err.Error(), "invalid source") -} - -func TestInstall_Bad_AlreadyInstalled(t *testing.T) { - m := io.NewMockMedium() - reg := NewRegistry(m, "/plugins") - _ = reg.Add(&PluginConfig{Name: "my-plugin", Version: "1.0.0"}) - - inst := NewInstaller(m, reg) - err := inst.Install(context.Background(), "org/my-plugin") - assert.Error(t, err) - assert.Contains(t, err.Error(), "already installed") -} - -// ── Remove ───────────────────────────────────────────────────────── - -func TestRemove_Good(t *testing.T) { - m := io.NewMockMedium() - reg := NewRegistry(m, "/plugins") - _ = reg.Add(&PluginConfig{Name: "removable", Version: "1.0.0"}) - - // Create plugin directory. - _ = m.EnsureDir("/plugins/removable") - _ = m.Write("/plugins/removable/plugin.json", `{"name":"removable"}`) - - inst := NewInstaller(m, reg) - err := inst.Remove("removable") - require.NoError(t, err) - - // Plugin removed from registry. - _, ok := reg.Get("removable") - assert.False(t, ok) - - // Directory cleaned up. - assert.False(t, m.Exists("/plugins/removable")) -} - -func TestRemove_Good_DirAlreadyGone(t *testing.T) { - m := io.NewMockMedium() - reg := NewRegistry(m, "/plugins") - _ = reg.Add(&PluginConfig{Name: "ghost", Version: "1.0.0"}) - // No directory exists — should still succeed. - - inst := NewInstaller(m, reg) - err := inst.Remove("ghost") - require.NoError(t, err) - - _, ok := reg.Get("ghost") - assert.False(t, ok) -} - -func TestRemove_Bad_NotFound(t *testing.T) { - m := io.NewMockMedium() - reg := NewRegistry(m, "/plugins") - inst := NewInstaller(m, reg) - - err := inst.Remove("nonexistent") - assert.Error(t, err) - assert.Contains(t, err.Error(), "plugin not found") -} - -// ── Update error paths ───────────────────────────────────────────── - -func TestUpdate_Bad_NotFound(t *testing.T) { - m := io.NewMockMedium() - reg := NewRegistry(m, "/plugins") - inst := NewInstaller(m, reg) - - err := inst.Update(context.Background(), "missing") - assert.Error(t, err) - assert.Contains(t, err.Error(), "plugin not found") -} - -// ── ParseSource ──────────────────────────────────────────────────── - -func TestParseSource_Good_OrgRepo(t *testing.T) { - org, repo, version, err := ParseSource("host-uk/core-plugin") - assert.NoError(t, err) - assert.Equal(t, "host-uk", org) - assert.Equal(t, "core-plugin", repo) - assert.Equal(t, "", version) -} - -func TestParseSource_Good_OrgRepoVersion(t *testing.T) { - org, repo, version, err := ParseSource("host-uk/core-plugin@v1.0.0") - assert.NoError(t, err) - assert.Equal(t, "host-uk", org) - assert.Equal(t, "core-plugin", repo) - assert.Equal(t, "v1.0.0", version) -} - -func TestParseSource_Good_VersionWithoutPrefix(t *testing.T) { - org, repo, version, err := ParseSource("org/repo@1.2.3") - assert.NoError(t, err) - assert.Equal(t, "org", org) - assert.Equal(t, "repo", repo) - assert.Equal(t, "1.2.3", version) -} - -func TestParseSource_Bad_Empty(t *testing.T) { - _, _, _, err := ParseSource("") - assert.Error(t, err) - assert.Contains(t, err.Error(), "source is empty") -} - -func TestParseSource_Bad_NoSlash(t *testing.T) { - _, _, _, err := ParseSource("just-a-name") - assert.Error(t, err) - assert.Contains(t, err.Error(), "org/repo") -} - -func TestParseSource_Bad_TooManySlashes(t *testing.T) { - _, _, _, err := ParseSource("a/b/c") - assert.Error(t, err) - assert.Contains(t, err.Error(), "org/repo") -} - -func TestParseSource_Bad_EmptyOrg(t *testing.T) { - _, _, _, err := ParseSource("/repo") - assert.Error(t, err) - assert.Contains(t, err.Error(), "org/repo") -} - -func TestParseSource_Bad_EmptyRepo(t *testing.T) { - _, _, _, err := ParseSource("org/") - assert.Error(t, err) - assert.Contains(t, err.Error(), "org/repo") -} - -func TestParseSource_Bad_EmptyVersion(t *testing.T) { - _, _, _, err := ParseSource("org/repo@") - assert.Error(t, err) - assert.Contains(t, err.Error(), "version is empty") -} diff --git a/pkg/plugin/loader.go b/pkg/plugin/loader.go deleted file mode 100644 index c9a00fd..0000000 --- a/pkg/plugin/loader.go +++ /dev/null @@ -1,63 +0,0 @@ -package plugin - -import ( - "path/filepath" - - coreerr "forge.lthn.ai/core/go-log" - "forge.lthn.ai/core/go-io" -) - -// Loader loads plugins from the filesystem. -type Loader struct { - medium io.Medium - baseDir string -} - -// NewLoader creates a new plugin loader. -func NewLoader(m io.Medium, baseDir string) *Loader { - return &Loader{ - medium: m, - baseDir: baseDir, - } -} - -// Discover finds all plugin directories under baseDir and returns their manifests. -// Directories without a valid plugin.json are silently skipped. -func (l *Loader) Discover() ([]*Manifest, error) { - entries, err := l.medium.List(l.baseDir) - if err != nil { - return nil, coreerr.E("plugin.Loader.Discover", "failed to list plugin directory", err) - } - - var manifests []*Manifest - for _, entry := range entries { - if !entry.IsDir() { - continue - } - - manifest, err := l.LoadPlugin(entry.Name()) - if err != nil { - // Skip directories without valid manifests - continue - } - - manifests = append(manifests, manifest) - } - - return manifests, nil -} - -// LoadPlugin loads a single plugin's manifest by name. -func (l *Loader) LoadPlugin(name string) (*Manifest, error) { - manifestPath := filepath.Join(l.baseDir, name, "plugin.json") - manifest, err := LoadManifest(l.medium, manifestPath) - if err != nil { - return nil, coreerr.E("plugin.Loader.LoadPlugin", "failed to load plugin: "+name, err) - } - - if err := manifest.Validate(); err != nil { - return nil, coreerr.E("plugin.Loader.LoadPlugin", "invalid plugin manifest: "+name, err) - } - - return manifest, nil -} diff --git a/pkg/plugin/loader_test.go b/pkg/plugin/loader_test.go deleted file mode 100644 index a6875b1..0000000 --- a/pkg/plugin/loader_test.go +++ /dev/null @@ -1,146 +0,0 @@ -package plugin - -import ( - "testing" - - "forge.lthn.ai/core/go-io" - "github.com/stretchr/testify/assert" -) - -func TestLoader_Discover_Good(t *testing.T) { - m := io.NewMockMedium() - baseDir := "/home/user/.core/plugins" - - // Set up mock filesystem with two plugins - m.Dirs[baseDir] = true - m.Dirs[baseDir+"/plugin-a"] = true - m.Dirs[baseDir+"/plugin-b"] = true - - m.Files[baseDir+"/plugin-a/plugin.json"] = `{ - "name": "plugin-a", - "version": "1.0.0", - "description": "Plugin A", - "entrypoint": "main.go" - }` - - m.Files[baseDir+"/plugin-b/plugin.json"] = `{ - "name": "plugin-b", - "version": "2.0.0", - "description": "Plugin B", - "entrypoint": "run.sh" - }` - - loader := NewLoader(m, baseDir) - manifests, err := loader.Discover() - assert.NoError(t, err) - assert.Len(t, manifests, 2) - - names := make(map[string]bool) - for _, manifest := range manifests { - names[manifest.Name] = true - } - assert.True(t, names["plugin-a"]) - assert.True(t, names["plugin-b"]) -} - -func TestLoader_Discover_Good_SkipsInvalidPlugins(t *testing.T) { - m := io.NewMockMedium() - baseDir := "/home/user/.core/plugins" - - m.Dirs[baseDir] = true - m.Dirs[baseDir+"/good-plugin"] = true - m.Dirs[baseDir+"/bad-plugin"] = true - - // Valid plugin - m.Files[baseDir+"/good-plugin/plugin.json"] = `{ - "name": "good-plugin", - "version": "1.0.0", - "entrypoint": "main.go" - }` - - // Invalid plugin (bad JSON) - m.Files[baseDir+"/bad-plugin/plugin.json"] = `{invalid}` - - loader := NewLoader(m, baseDir) - manifests, err := loader.Discover() - assert.NoError(t, err) - assert.Len(t, manifests, 1) - assert.Equal(t, "good-plugin", manifests[0].Name) -} - -func TestLoader_Discover_Good_SkipsFiles(t *testing.T) { - m := io.NewMockMedium() - baseDir := "/home/user/.core/plugins" - - m.Dirs[baseDir] = true - m.Dirs[baseDir+"/real-plugin"] = true - m.Files[baseDir+"/registry.json"] = `{}` // A file, not a directory - - m.Files[baseDir+"/real-plugin/plugin.json"] = `{ - "name": "real-plugin", - "version": "1.0.0", - "entrypoint": "main.go" - }` - - loader := NewLoader(m, baseDir) - manifests, err := loader.Discover() - assert.NoError(t, err) - assert.Len(t, manifests, 1) - assert.Equal(t, "real-plugin", manifests[0].Name) -} - -func TestLoader_Discover_Good_EmptyDirectory(t *testing.T) { - m := io.NewMockMedium() - baseDir := "/home/user/.core/plugins" - m.Dirs[baseDir] = true - - loader := NewLoader(m, baseDir) - manifests, err := loader.Discover() - assert.NoError(t, err) - assert.Empty(t, manifests) -} - -func TestLoader_LoadPlugin_Good(t *testing.T) { - m := io.NewMockMedium() - baseDir := "/home/user/.core/plugins" - - m.Dirs[baseDir+"/my-plugin"] = true - m.Files[baseDir+"/my-plugin/plugin.json"] = `{ - "name": "my-plugin", - "version": "1.0.0", - "description": "My plugin", - "author": "Test", - "entrypoint": "main.go" - }` - - loader := NewLoader(m, baseDir) - manifest, err := loader.LoadPlugin("my-plugin") - assert.NoError(t, err) - assert.Equal(t, "my-plugin", manifest.Name) - assert.Equal(t, "1.0.0", manifest.Version) -} - -func TestLoader_LoadPlugin_Bad_NotFound(t *testing.T) { - m := io.NewMockMedium() - loader := NewLoader(m, "/home/user/.core/plugins") - - _, err := loader.LoadPlugin("nonexistent") - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to load plugin") -} - -func TestLoader_LoadPlugin_Bad_InvalidManifest(t *testing.T) { - m := io.NewMockMedium() - baseDir := "/home/user/.core/plugins" - - m.Dirs[baseDir+"/bad-plugin"] = true - m.Files[baseDir+"/bad-plugin/plugin.json"] = `{ - "name": "bad-plugin", - "version": "1.0.0" - }` // Missing entrypoint - - loader := NewLoader(m, baseDir) - _, err := loader.LoadPlugin("bad-plugin") - assert.Error(t, err) - assert.Contains(t, err.Error(), "invalid plugin manifest") -} diff --git a/pkg/plugin/manifest.go b/pkg/plugin/manifest.go deleted file mode 100644 index 65b1945..0000000 --- a/pkg/plugin/manifest.go +++ /dev/null @@ -1,50 +0,0 @@ -package plugin - -import ( - "encoding/json" - - coreerr "forge.lthn.ai/core/go-log" - "forge.lthn.ai/core/go-io" -) - -// Manifest represents a plugin.json manifest file. -// Each plugin repository must contain a plugin.json at its root. -type Manifest struct { - Name string `json:"name"` - Version string `json:"version"` - Description string `json:"description"` - Author string `json:"author"` - Entrypoint string `json:"entrypoint"` - Dependencies []string `json:"dependencies,omitempty"` - MinVersion string `json:"min_version,omitempty"` -} - -// LoadManifest reads and parses a plugin.json file from the given path. -func LoadManifest(m io.Medium, path string) (*Manifest, error) { - content, err := m.Read(path) - if err != nil { - return nil, coreerr.E("plugin.LoadManifest", "failed to read manifest", err) - } - - var manifest Manifest - if err := json.Unmarshal([]byte(content), &manifest); err != nil { - return nil, coreerr.E("plugin.LoadManifest", "failed to parse manifest JSON", err) - } - - return &manifest, nil -} - -// Validate checks the manifest for required fields. -// Returns an error if name, version, or entrypoint are missing. -func (m *Manifest) Validate() error { - if m.Name == "" { - return coreerr.E("plugin.Manifest.Validate", "name is required", nil) - } - if m.Version == "" { - return coreerr.E("plugin.Manifest.Validate", "version is required", nil) - } - if m.Entrypoint == "" { - return coreerr.E("plugin.Manifest.Validate", "entrypoint is required", nil) - } - return nil -} diff --git a/pkg/plugin/manifest_test.go b/pkg/plugin/manifest_test.go deleted file mode 100644 index be1cc4a..0000000 --- a/pkg/plugin/manifest_test.go +++ /dev/null @@ -1,109 +0,0 @@ -package plugin - -import ( - "testing" - - "forge.lthn.ai/core/go-io" - "github.com/stretchr/testify/assert" -) - -func TestLoadManifest_Good(t *testing.T) { - m := io.NewMockMedium() - m.Files["plugins/test/plugin.json"] = `{ - "name": "test-plugin", - "version": "1.0.0", - "description": "A test plugin", - "author": "Test Author", - "entrypoint": "main.go", - "dependencies": ["dep-a", "dep-b"], - "min_version": "0.5.0" - }` - - manifest, err := LoadManifest(m, "plugins/test/plugin.json") - assert.NoError(t, err) - assert.Equal(t, "test-plugin", manifest.Name) - assert.Equal(t, "1.0.0", manifest.Version) - assert.Equal(t, "A test plugin", manifest.Description) - assert.Equal(t, "Test Author", manifest.Author) - assert.Equal(t, "main.go", manifest.Entrypoint) - assert.Equal(t, []string{"dep-a", "dep-b"}, manifest.Dependencies) - assert.Equal(t, "0.5.0", manifest.MinVersion) -} - -func TestLoadManifest_Good_MinimalFields(t *testing.T) { - m := io.NewMockMedium() - m.Files["plugin.json"] = `{ - "name": "minimal", - "version": "0.1.0", - "entrypoint": "run.sh" - }` - - manifest, err := LoadManifest(m, "plugin.json") - assert.NoError(t, err) - assert.Equal(t, "minimal", manifest.Name) - assert.Equal(t, "0.1.0", manifest.Version) - assert.Equal(t, "run.sh", manifest.Entrypoint) - assert.Empty(t, manifest.Dependencies) - assert.Empty(t, manifest.MinVersion) -} - -func TestLoadManifest_Bad_FileNotFound(t *testing.T) { - m := io.NewMockMedium() - - _, err := LoadManifest(m, "nonexistent/plugin.json") - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to read manifest") -} - -func TestLoadManifest_Bad_InvalidJSON(t *testing.T) { - m := io.NewMockMedium() - m.Files["plugin.json"] = `{invalid json}` - - _, err := LoadManifest(m, "plugin.json") - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to parse manifest JSON") -} - -func TestManifest_Validate_Good(t *testing.T) { - manifest := &Manifest{ - Name: "test-plugin", - Version: "1.0.0", - Entrypoint: "main.go", - } - - err := manifest.Validate() - assert.NoError(t, err) -} - -func TestManifest_Validate_Bad_MissingName(t *testing.T) { - manifest := &Manifest{ - Version: "1.0.0", - Entrypoint: "main.go", - } - - err := manifest.Validate() - assert.Error(t, err) - assert.Contains(t, err.Error(), "name is required") -} - -func TestManifest_Validate_Bad_MissingVersion(t *testing.T) { - manifest := &Manifest{ - Name: "test-plugin", - Entrypoint: "main.go", - } - - err := manifest.Validate() - assert.Error(t, err) - assert.Contains(t, err.Error(), "version is required") -} - -func TestManifest_Validate_Bad_MissingEntrypoint(t *testing.T) { - manifest := &Manifest{ - Name: "test-plugin", - Version: "1.0.0", - } - - err := manifest.Validate() - assert.Error(t, err) - assert.Contains(t, err.Error(), "entrypoint is required") -} diff --git a/pkg/plugin/plugin.go b/pkg/plugin/plugin.go deleted file mode 100644 index 9f060ec..0000000 --- a/pkg/plugin/plugin.go +++ /dev/null @@ -1,54 +0,0 @@ -// Package plugin provides a plugin system for the core CLI. -// -// Plugins extend the CLI with additional commands and functionality. -// They are distributed as GitHub repositories and managed via a local registry. -// -// Plugin lifecycle: -// - Install: Download from GitHub, validate manifest, register -// - Init: Parse manifest and prepare plugin -// - Start: Activate plugin functionality -// - Stop: Deactivate and clean up -// - Remove: Unregister and delete files -package plugin - -import "context" - -// Plugin is the interface that all plugins must implement. -type Plugin interface { - // Name returns the plugin's unique identifier. - Name() string - - // Version returns the plugin's semantic version. - Version() string - - // Init prepares the plugin for use. - Init(ctx context.Context) error - - // Start activates the plugin. - Start(ctx context.Context) error - - // Stop deactivates the plugin and releases resources. - Stop(ctx context.Context) error -} - -// BasePlugin provides a default implementation of Plugin. -// Embed this in concrete plugin types to inherit default behaviour. -type BasePlugin struct { - PluginName string - PluginVersion string -} - -// Name returns the plugin name. -func (p *BasePlugin) Name() string { return p.PluginName } - -// Version returns the plugin version. -func (p *BasePlugin) Version() string { return p.PluginVersion } - -// Init is a no-op default implementation. -func (p *BasePlugin) Init(_ context.Context) error { return nil } - -// Start is a no-op default implementation. -func (p *BasePlugin) Start(_ context.Context) error { return nil } - -// Stop is a no-op default implementation. -func (p *BasePlugin) Stop(_ context.Context) error { return nil } diff --git a/pkg/plugin/plugin_test.go b/pkg/plugin/plugin_test.go deleted file mode 100644 index b5850e6..0000000 --- a/pkg/plugin/plugin_test.go +++ /dev/null @@ -1,39 +0,0 @@ -package plugin - -import ( - "context" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestBasePlugin_Good(t *testing.T) { - p := &BasePlugin{ - PluginName: "test-plugin", - PluginVersion: "1.0.0", - } - - assert.Equal(t, "test-plugin", p.Name()) - assert.Equal(t, "1.0.0", p.Version()) - - ctx := context.Background() - assert.NoError(t, p.Init(ctx)) - assert.NoError(t, p.Start(ctx)) - assert.NoError(t, p.Stop(ctx)) -} - -func TestBasePlugin_Good_EmptyFields(t *testing.T) { - p := &BasePlugin{} - - assert.Equal(t, "", p.Name()) - assert.Equal(t, "", p.Version()) - - ctx := context.Background() - assert.NoError(t, p.Init(ctx)) - assert.NoError(t, p.Start(ctx)) - assert.NoError(t, p.Stop(ctx)) -} - -func TestBasePlugin_Good_ImplementsPlugin(t *testing.T) { - var _ Plugin = &BasePlugin{} -} diff --git a/pkg/plugin/registry.go b/pkg/plugin/registry.go deleted file mode 100644 index 8fb64d3..0000000 --- a/pkg/plugin/registry.go +++ /dev/null @@ -1,118 +0,0 @@ -package plugin - -import ( - "cmp" - "encoding/json" - "path/filepath" - "slices" - - coreerr "forge.lthn.ai/core/go-log" - "forge.lthn.ai/core/go-io" -) - -const registryFilename = "registry.json" - -// Registry manages installed plugins. -// Plugin metadata is stored in a registry.json file under the base path. -type Registry struct { - medium io.Medium - basePath string // e.g., ~/.core/plugins/ - plugins map[string]*PluginConfig -} - -// NewRegistry creates a new plugin registry. -func NewRegistry(m io.Medium, basePath string) *Registry { - return &Registry{ - medium: m, - basePath: basePath, - plugins: make(map[string]*PluginConfig), - } -} - -// List returns all installed plugins sorted by name. -func (r *Registry) List() []*PluginConfig { - result := make([]*PluginConfig, 0, len(r.plugins)) - for _, cfg := range r.plugins { - result = append(result, cfg) - } - slices.SortFunc(result, func(a, b *PluginConfig) int { - return cmp.Compare(a.Name, b.Name) - }) - return result -} - -// Get returns a plugin by name. -// The second return value indicates whether the plugin was found. -func (r *Registry) Get(name string) (*PluginConfig, bool) { - cfg, ok := r.plugins[name] - return cfg, ok -} - -// Add registers a plugin in the registry. -func (r *Registry) Add(cfg *PluginConfig) error { - if cfg.Name == "" { - return coreerr.E("plugin.Registry.Add", "plugin name is required", nil) - } - r.plugins[cfg.Name] = cfg - return nil -} - -// Remove unregisters a plugin from the registry. -func (r *Registry) Remove(name string) error { - if _, ok := r.plugins[name]; !ok { - return coreerr.E("plugin.Registry.Remove", "plugin not found: "+name, nil) - } - delete(r.plugins, name) - return nil -} - -// registryPath returns the full path to the registry file. -func (r *Registry) registryPath() string { - return filepath.Join(r.basePath, registryFilename) -} - -// Load reads the plugin registry from disk. -// If the registry file does not exist, the registry starts empty. -func (r *Registry) Load() error { - path := r.registryPath() - - if !r.medium.IsFile(path) { - // No registry file yet; start with empty registry - r.plugins = make(map[string]*PluginConfig) - return nil - } - - content, err := r.medium.Read(path) - if err != nil { - return coreerr.E("plugin.Registry.Load", "failed to read registry", err) - } - - var plugins map[string]*PluginConfig - if err := json.Unmarshal([]byte(content), &plugins); err != nil { - return coreerr.E("plugin.Registry.Load", "failed to parse registry", err) - } - - if plugins == nil { - plugins = make(map[string]*PluginConfig) - } - r.plugins = plugins - return nil -} - -// Save writes the plugin registry to disk. -func (r *Registry) Save() error { - if err := r.medium.EnsureDir(r.basePath); err != nil { - return coreerr.E("plugin.Registry.Save", "failed to create plugin directory", err) - } - - data, err := json.MarshalIndent(r.plugins, "", " ") - if err != nil { - return coreerr.E("plugin.Registry.Save", "failed to marshal registry", err) - } - - if err := r.medium.Write(r.registryPath(), string(data)); err != nil { - return coreerr.E("plugin.Registry.Save", "failed to write registry", err) - } - - return nil -} diff --git a/pkg/plugin/registry_test.go b/pkg/plugin/registry_test.go deleted file mode 100644 index 3984d0e..0000000 --- a/pkg/plugin/registry_test.go +++ /dev/null @@ -1,192 +0,0 @@ -package plugin - -import ( - "testing" - - "forge.lthn.ai/core/go-io" - "github.com/stretchr/testify/assert" -) - -func TestRegistry_Add_Good(t *testing.T) { - m := io.NewMockMedium() - reg := NewRegistry(m, "/home/user/.core/plugins") - - err := reg.Add(&PluginConfig{ - Name: "my-plugin", - Version: "1.0.0", - Source: "github:org/my-plugin", - Enabled: true, - }) - assert.NoError(t, err) - - list := reg.List() - assert.Len(t, list, 1) - assert.Equal(t, "my-plugin", list[0].Name) - assert.Equal(t, "1.0.0", list[0].Version) -} - -func TestRegistry_Add_Bad_EmptyName(t *testing.T) { - m := io.NewMockMedium() - reg := NewRegistry(m, "/home/user/.core/plugins") - - err := reg.Add(&PluginConfig{ - Version: "1.0.0", - }) - assert.Error(t, err) - assert.Contains(t, err.Error(), "plugin name is required") -} - -func TestRegistry_Remove_Good(t *testing.T) { - m := io.NewMockMedium() - reg := NewRegistry(m, "/home/user/.core/plugins") - - _ = reg.Add(&PluginConfig{ - Name: "my-plugin", - Version: "1.0.0", - }) - - err := reg.Remove("my-plugin") - assert.NoError(t, err) - assert.Empty(t, reg.List()) -} - -func TestRegistry_Get_Good(t *testing.T) { - m := io.NewMockMedium() - reg := NewRegistry(m, "/home/user/.core/plugins") - - _ = reg.Add(&PluginConfig{ - Name: "test-plugin", - Version: "2.0.0", - Source: "github:org/test-plugin", - }) - - cfg, ok := reg.Get("test-plugin") - assert.True(t, ok) - assert.Equal(t, "test-plugin", cfg.Name) - assert.Equal(t, "2.0.0", cfg.Version) -} - -func TestRegistry_Get_Bad_NotFound(t *testing.T) { - m := io.NewMockMedium() - reg := NewRegistry(m, "/home/user/.core/plugins") - - cfg, ok := reg.Get("nonexistent") - assert.False(t, ok) - assert.Nil(t, cfg) -} - -func TestRegistry_Remove_Bad_NotFound(t *testing.T) { - m := io.NewMockMedium() - reg := NewRegistry(m, "/home/user/.core/plugins") - - err := reg.Remove("nonexistent") - assert.Error(t, err) - assert.Contains(t, err.Error(), "plugin not found") -} - -func TestRegistry_SaveLoad_Good(t *testing.T) { - m := io.NewMockMedium() - basePath := "/home/user/.core/plugins" - reg := NewRegistry(m, basePath) - - _ = reg.Add(&PluginConfig{ - Name: "plugin-a", - Version: "1.0.0", - Source: "github:org/plugin-a", - Enabled: true, - InstalledAt: "2025-01-01T00:00:00Z", - }) - _ = reg.Add(&PluginConfig{ - Name: "plugin-b", - Version: "2.0.0", - Source: "github:org/plugin-b", - Enabled: false, - InstalledAt: "2025-01-02T00:00:00Z", - }) - - err := reg.Save() - assert.NoError(t, err) - - // Load into a fresh registry - reg2 := NewRegistry(m, basePath) - err = reg2.Load() - assert.NoError(t, err) - - list := reg2.List() - assert.Len(t, list, 2) - - a, ok := reg2.Get("plugin-a") - assert.True(t, ok) - assert.Equal(t, "1.0.0", a.Version) - assert.True(t, a.Enabled) - - b, ok := reg2.Get("plugin-b") - assert.True(t, ok) - assert.Equal(t, "2.0.0", b.Version) - assert.False(t, b.Enabled) -} - -func TestRegistry_Load_Good_EmptyWhenNoFile(t *testing.T) { - m := io.NewMockMedium() - reg := NewRegistry(m, "/home/user/.core/plugins") - - err := reg.Load() - assert.NoError(t, err) - assert.Empty(t, reg.List()) -} - -func TestRegistry_Load_Bad_InvalidJSON(t *testing.T) { - m := io.NewMockMedium() - basePath := "/home/user/.core/plugins" - _ = m.Write(basePath+"/registry.json", "not valid json {{{") - - reg := NewRegistry(m, basePath) - err := reg.Load() - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to parse registry") -} - -func TestRegistry_Load_Good_NullJSON(t *testing.T) { - m := io.NewMockMedium() - basePath := "/home/user/.core/plugins" - _ = m.Write(basePath+"/registry.json", "null") - - reg := NewRegistry(m, basePath) - err := reg.Load() - assert.NoError(t, err) - assert.Empty(t, reg.List()) -} - -func TestRegistry_Save_Good_CreatesDir(t *testing.T) { - m := io.NewMockMedium() - basePath := "/home/user/.core/plugins" - reg := NewRegistry(m, basePath) - - _ = reg.Add(&PluginConfig{Name: "test", Version: "1.0.0"}) - err := reg.Save() - assert.NoError(t, err) - - // Verify file was written. - assert.True(t, m.IsFile(basePath+"/registry.json")) -} - -func TestRegistry_List_Good_Sorted(t *testing.T) { - m := io.NewMockMedium() - reg := NewRegistry(m, "/plugins") - - _ = reg.Add(&PluginConfig{Name: "zebra", Version: "1.0.0"}) - _ = reg.Add(&PluginConfig{Name: "alpha", Version: "1.0.0"}) - _ = reg.Add(&PluginConfig{Name: "middle", Version: "1.0.0"}) - - list := reg.List() - assert.Len(t, list, 3) - assert.Equal(t, "alpha", list[0].Name) - assert.Equal(t, "middle", list[1].Name) - assert.Equal(t, "zebra", list[2].Name) -} - -func TestRegistry_RegistryPath_Good(t *testing.T) { - m := io.NewMockMedium() - reg := NewRegistry(m, "/base/path") - assert.Equal(t, "/base/path/registry.json", reg.registryPath()) -} diff --git a/pkg/process/actions.go b/pkg/process/actions.go deleted file mode 100644 index 7f33cf8..0000000 --- a/pkg/process/actions.go +++ /dev/null @@ -1,37 +0,0 @@ -package process - -import "time" - -// --- ACTION messages (broadcast via Core.ACTION) --- - -// ActionProcessStarted is broadcast when a process begins execution. -type ActionProcessStarted struct { - ID string - Command string - Args []string - Dir string - PID int -} - -// ActionProcessOutput is broadcast for each line of output. -// Subscribe to this for real-time streaming. -type ActionProcessOutput struct { - ID string - Line string - Stream Stream -} - -// ActionProcessExited is broadcast when a process completes. -// Check ExitCode for success (0) or failure. -type ActionProcessExited struct { - ID string - ExitCode int - Duration time.Duration - Error error // Non-nil if failed to start or was killed -} - -// ActionProcessKilled is broadcast when a process is terminated. -type ActionProcessKilled struct { - ID string - Signal string -} diff --git a/pkg/process/buffer.go b/pkg/process/buffer.go deleted file mode 100644 index bf02f59..0000000 --- a/pkg/process/buffer.go +++ /dev/null @@ -1,108 +0,0 @@ -package process - -import "sync" - -// RingBuffer is a fixed-size circular buffer that overwrites old data. -// Thread-safe for concurrent reads and writes. -type RingBuffer struct { - data []byte - size int - start int - end int - full bool - mu sync.RWMutex -} - -// NewRingBuffer creates a ring buffer with the given capacity. -func NewRingBuffer(size int) *RingBuffer { - return &RingBuffer{ - data: make([]byte, size), - size: size, - } -} - -// Write appends data to the buffer, overwriting oldest data if full. -func (rb *RingBuffer) Write(p []byte) (n int, err error) { - rb.mu.Lock() - defer rb.mu.Unlock() - - for _, b := range p { - rb.data[rb.end] = b - rb.end = (rb.end + 1) % rb.size - if rb.full { - rb.start = (rb.start + 1) % rb.size - } - if rb.end == rb.start { - rb.full = true - } - } - return len(p), nil -} - -// String returns the buffer contents as a string. -func (rb *RingBuffer) String() string { - rb.mu.RLock() - defer rb.mu.RUnlock() - - if !rb.full && rb.start == rb.end { - return "" - } - - if rb.full { - result := make([]byte, rb.size) - copy(result, rb.data[rb.start:]) - copy(result[rb.size-rb.start:], rb.data[:rb.end]) - return string(result) - } - - return string(rb.data[rb.start:rb.end]) -} - -// Bytes returns a copy of the buffer contents. -func (rb *RingBuffer) Bytes() []byte { - rb.mu.RLock() - defer rb.mu.RUnlock() - - if !rb.full && rb.start == rb.end { - return nil - } - - if rb.full { - result := make([]byte, rb.size) - copy(result, rb.data[rb.start:]) - copy(result[rb.size-rb.start:], rb.data[:rb.end]) - return result - } - - result := make([]byte, rb.end-rb.start) - copy(result, rb.data[rb.start:rb.end]) - return result -} - -// Len returns the current length of data in the buffer. -func (rb *RingBuffer) Len() int { - rb.mu.RLock() - defer rb.mu.RUnlock() - - if rb.full { - return rb.size - } - if rb.end >= rb.start { - return rb.end - rb.start - } - return rb.size - rb.start + rb.end -} - -// Cap returns the buffer capacity. -func (rb *RingBuffer) Cap() int { - return rb.size -} - -// Reset clears the buffer. -func (rb *RingBuffer) Reset() { - rb.mu.Lock() - defer rb.mu.Unlock() - rb.start = 0 - rb.end = 0 - rb.full = false -} diff --git a/pkg/process/buffer_test.go b/pkg/process/buffer_test.go deleted file mode 100644 index bbd4f1c..0000000 --- a/pkg/process/buffer_test.go +++ /dev/null @@ -1,72 +0,0 @@ -package process - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestRingBuffer(t *testing.T) { - t.Run("write and read", func(t *testing.T) { - rb := NewRingBuffer(10) - - n, err := rb.Write([]byte("hello")) - assert.NoError(t, err) - assert.Equal(t, 5, n) - assert.Equal(t, "hello", rb.String()) - assert.Equal(t, 5, rb.Len()) - }) - - t.Run("overflow wraps around", func(t *testing.T) { - rb := NewRingBuffer(5) - - _, _ = rb.Write([]byte("hello")) - assert.Equal(t, "hello", rb.String()) - - _, _ = rb.Write([]byte("world")) - // Should contain "world" (overwrote "hello") - assert.Equal(t, 5, rb.Len()) - assert.Equal(t, "world", rb.String()) - }) - - t.Run("partial overflow", func(t *testing.T) { - rb := NewRingBuffer(10) - - _, _ = rb.Write([]byte("hello")) - _, _ = rb.Write([]byte("worldx")) - // Should contain "lloworldx" (11 chars, buffer is 10) - assert.Equal(t, 10, rb.Len()) - }) - - t.Run("empty buffer", func(t *testing.T) { - rb := NewRingBuffer(10) - assert.Equal(t, "", rb.String()) - assert.Equal(t, 0, rb.Len()) - assert.Nil(t, rb.Bytes()) - }) - - t.Run("reset", func(t *testing.T) { - rb := NewRingBuffer(10) - _, _ = rb.Write([]byte("hello")) - rb.Reset() - assert.Equal(t, "", rb.String()) - assert.Equal(t, 0, rb.Len()) - }) - - t.Run("cap", func(t *testing.T) { - rb := NewRingBuffer(42) - assert.Equal(t, 42, rb.Cap()) - }) - - t.Run("bytes returns copy", func(t *testing.T) { - rb := NewRingBuffer(10) - _, _ = rb.Write([]byte("hello")) - - bytes := rb.Bytes() - assert.Equal(t, []byte("hello"), bytes) - - // Modifying returned bytes shouldn't affect buffer - bytes[0] = 'x' - assert.Equal(t, "hello", rb.String()) - }) -} diff --git a/pkg/process/exec/exec.go b/pkg/process/exec/exec.go deleted file mode 100644 index 21978a9..0000000 --- a/pkg/process/exec/exec.go +++ /dev/null @@ -1,176 +0,0 @@ -package exec - -import ( - "bytes" - "context" - "fmt" - "io" - "os" - "os/exec" - "strings" -) - -// Options configuration for command execution -type Options struct { - Dir string - Env []string - Stdin io.Reader - Stdout io.Writer - Stderr io.Writer - // If true, command will run in background (not implemented in this wrapper yet) - // Background bool -} - -// Command wraps os/exec.Command with logging and context -func Command(ctx context.Context, name string, args ...string) *Cmd { - return &Cmd{ - name: name, - args: args, - ctx: ctx, - } -} - -// Cmd represents a wrapped command -type Cmd struct { - name string - args []string - ctx context.Context - opts Options - cmd *exec.Cmd - logger Logger -} - -// WithDir sets the working directory -func (c *Cmd) WithDir(dir string) *Cmd { - c.opts.Dir = dir - return c -} - -// WithEnv sets the environment variables -func (c *Cmd) WithEnv(env []string) *Cmd { - c.opts.Env = env - return c -} - -// WithStdin sets stdin -func (c *Cmd) WithStdin(r io.Reader) *Cmd { - c.opts.Stdin = r - return c -} - -// WithStdout sets stdout -func (c *Cmd) WithStdout(w io.Writer) *Cmd { - c.opts.Stdout = w - return c -} - -// WithStderr sets stderr -func (c *Cmd) WithStderr(w io.Writer) *Cmd { - c.opts.Stderr = w - return c -} - -// WithLogger sets a custom logger for this command. -// If not set, the package default logger is used. -func (c *Cmd) WithLogger(l Logger) *Cmd { - c.logger = l - return c -} - -// Run executes the command and waits for it to finish. -// It automatically logs the command execution at debug level. -func (c *Cmd) Run() error { - c.prepare() - c.logDebug("executing command") - - if err := c.cmd.Run(); err != nil { - wrapped := wrapError(err, c.name, c.args) - c.logError("command failed", wrapped) - return wrapped - } - return nil -} - -// Output runs the command and returns its standard output. -func (c *Cmd) Output() ([]byte, error) { - c.prepare() - c.logDebug("executing command") - - out, err := c.cmd.Output() - if err != nil { - wrapped := wrapError(err, c.name, c.args) - c.logError("command failed", wrapped) - return nil, wrapped - } - return out, nil -} - -// CombinedOutput runs the command and returns its combined standard output and standard error. -func (c *Cmd) CombinedOutput() ([]byte, error) { - c.prepare() - c.logDebug("executing command") - - out, err := c.cmd.CombinedOutput() - if err != nil { - wrapped := wrapError(err, c.name, c.args) - c.logError("command failed", wrapped) - return out, wrapped - } - return out, nil -} - -func (c *Cmd) prepare() { - if c.ctx != nil { - c.cmd = exec.CommandContext(c.ctx, c.name, c.args...) - } else { - // Should we enforce context? The issue says "Enforce context usage". - // For now, let's allow nil but log a warning if we had a logger? - // Or strictly panic/error? - // Let's fallback to Background for now but maybe strict later. - c.cmd = exec.Command(c.name, c.args...) - } - - c.cmd.Dir = c.opts.Dir - if len(c.opts.Env) > 0 { - c.cmd.Env = append(os.Environ(), c.opts.Env...) - } - - c.cmd.Stdin = c.opts.Stdin - c.cmd.Stdout = c.opts.Stdout - c.cmd.Stderr = c.opts.Stderr -} - -// RunQuiet executes the command suppressing stdout unless there is an error. -// Useful for internal commands. -func RunQuiet(ctx context.Context, name string, args ...string) error { - var stderr bytes.Buffer - cmd := Command(ctx, name, args...).WithStderr(&stderr) - if err := cmd.Run(); err != nil { - // Include stderr in error message - return fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String())) - } - return nil -} - -func wrapError(err error, name string, args []string) error { - cmdStr := name + " " + strings.Join(args, " ") - if exitErr, ok := err.(*exec.ExitError); ok { - return fmt.Errorf("command %q failed with exit code %d: %w", cmdStr, exitErr.ExitCode(), err) - } - return fmt.Errorf("failed to execute %q: %w", cmdStr, err) -} - -func (c *Cmd) getLogger() Logger { - if c.logger != nil { - return c.logger - } - return defaultLogger -} - -func (c *Cmd) logDebug(msg string) { - c.getLogger().Debug(msg, "cmd", c.name, "args", strings.Join(c.args, " ")) -} - -func (c *Cmd) logError(msg string, err error) { - c.getLogger().Error(msg, "cmd", c.name, "args", strings.Join(c.args, " "), "err", err) -} diff --git a/pkg/process/exec/exec_test.go b/pkg/process/exec/exec_test.go deleted file mode 100644 index 9d3656c..0000000 --- a/pkg/process/exec/exec_test.go +++ /dev/null @@ -1,148 +0,0 @@ -package exec_test - -import ( - "context" - "strings" - "testing" - - "forge.lthn.ai/core/go/pkg/process/exec" -) - -// mockLogger captures log calls for testing -type mockLogger struct { - debugCalls []logCall - errorCalls []logCall -} - -type logCall struct { - msg string - keyvals []any -} - -func (m *mockLogger) Debug(msg string, keyvals ...any) { - m.debugCalls = append(m.debugCalls, logCall{msg, keyvals}) -} - -func (m *mockLogger) Error(msg string, keyvals ...any) { - m.errorCalls = append(m.errorCalls, logCall{msg, keyvals}) -} - -func TestCommand_Run_Good_LogsDebug(t *testing.T) { - logger := &mockLogger{} - ctx := context.Background() - - err := exec.Command(ctx, "echo", "hello"). - WithLogger(logger). - Run() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if len(logger.debugCalls) != 1 { - t.Fatalf("expected 1 debug call, got %d", len(logger.debugCalls)) - } - if logger.debugCalls[0].msg != "executing command" { - t.Errorf("expected msg 'executing command', got %q", logger.debugCalls[0].msg) - } - if len(logger.errorCalls) != 0 { - t.Errorf("expected no error calls, got %d", len(logger.errorCalls)) - } -} - -func TestCommand_Run_Bad_LogsError(t *testing.T) { - logger := &mockLogger{} - ctx := context.Background() - - err := exec.Command(ctx, "false"). - WithLogger(logger). - Run() - if err == nil { - t.Fatal("expected error") - } - - if len(logger.debugCalls) != 1 { - t.Fatalf("expected 1 debug call, got %d", len(logger.debugCalls)) - } - if len(logger.errorCalls) != 1 { - t.Fatalf("expected 1 error call, got %d", len(logger.errorCalls)) - } - if logger.errorCalls[0].msg != "command failed" { - t.Errorf("expected msg 'command failed', got %q", logger.errorCalls[0].msg) - } -} - -func TestCommand_Output_Good(t *testing.T) { - logger := &mockLogger{} - ctx := context.Background() - - out, err := exec.Command(ctx, "echo", "test"). - WithLogger(logger). - Output() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if strings.TrimSpace(string(out)) != "test" { - t.Errorf("expected 'test', got %q", string(out)) - } - if len(logger.debugCalls) != 1 { - t.Errorf("expected 1 debug call, got %d", len(logger.debugCalls)) - } -} - -func TestCommand_CombinedOutput_Good(t *testing.T) { - logger := &mockLogger{} - ctx := context.Background() - - out, err := exec.Command(ctx, "echo", "combined"). - WithLogger(logger). - CombinedOutput() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if strings.TrimSpace(string(out)) != "combined" { - t.Errorf("expected 'combined', got %q", string(out)) - } - if len(logger.debugCalls) != 1 { - t.Errorf("expected 1 debug call, got %d", len(logger.debugCalls)) - } -} - -func TestNopLogger(t *testing.T) { - // Verify NopLogger doesn't panic - var nop exec.NopLogger - nop.Debug("msg", "key", "val") - nop.Error("msg", "key", "val") -} - -func TestSetDefaultLogger(t *testing.T) { - original := exec.DefaultLogger() - defer exec.SetDefaultLogger(original) - - logger := &mockLogger{} - exec.SetDefaultLogger(logger) - - if exec.DefaultLogger() != logger { - t.Error("default logger not set correctly") - } - - // Test nil resets to NopLogger - exec.SetDefaultLogger(nil) - if _, ok := exec.DefaultLogger().(exec.NopLogger); !ok { - t.Error("expected NopLogger when setting nil") - } -} - -func TestCommand_UsesDefaultLogger(t *testing.T) { - original := exec.DefaultLogger() - defer exec.SetDefaultLogger(original) - - logger := &mockLogger{} - exec.SetDefaultLogger(logger) - - ctx := context.Background() - _ = exec.Command(ctx, "echo", "test").Run() - - if len(logger.debugCalls) != 1 { - t.Errorf("expected default logger to receive 1 debug call, got %d", len(logger.debugCalls)) - } -} diff --git a/pkg/process/exec/logger.go b/pkg/process/exec/logger.go deleted file mode 100644 index e8f5a6b..0000000 --- a/pkg/process/exec/logger.go +++ /dev/null @@ -1,35 +0,0 @@ -package exec - -// Logger interface for command execution logging. -// Compatible with pkg/log.Logger and other structured loggers. -type Logger interface { - // Debug logs a debug-level message with optional key-value pairs. - Debug(msg string, keyvals ...any) - // Error logs an error-level message with optional key-value pairs. - Error(msg string, keyvals ...any) -} - -// NopLogger is a no-op logger that discards all messages. -type NopLogger struct{} - -// Debug discards the message (no-op implementation). -func (NopLogger) Debug(string, ...any) {} - -// Error discards the message (no-op implementation). -func (NopLogger) Error(string, ...any) {} - -var defaultLogger Logger = NopLogger{} - -// SetDefaultLogger sets the package-level default logger. -// Commands without an explicit logger will use this. -func SetDefaultLogger(l Logger) { - if l == nil { - l = NopLogger{} - } - defaultLogger = l -} - -// DefaultLogger returns the current default logger. -func DefaultLogger() Logger { - return defaultLogger -} diff --git a/pkg/process/global_test.go b/pkg/process/global_test.go deleted file mode 100644 index 80a8158..0000000 --- a/pkg/process/global_test.go +++ /dev/null @@ -1,298 +0,0 @@ -package process - -import ( - "context" - "sync" - "testing" - - "forge.lthn.ai/core/go/pkg/framework" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestGlobal_DefaultNotInitialized(t *testing.T) { - // Reset global state for this test - old := defaultService.Swap(nil) - defer func() { - if old != nil { - defaultService.Store(old) - } - }() - - assert.Nil(t, Default()) - - _, err := Start(context.Background(), "echo", "test") - assert.ErrorIs(t, err, ErrServiceNotInitialized) - - _, err = Run(context.Background(), "echo", "test") - assert.ErrorIs(t, err, ErrServiceNotInitialized) - - _, err = Get("proc-1") - assert.ErrorIs(t, err, ErrServiceNotInitialized) - - assert.Nil(t, List()) - assert.Nil(t, Running()) - - err = Kill("proc-1") - assert.ErrorIs(t, err, ErrServiceNotInitialized) - - _, err = StartWithOptions(context.Background(), RunOptions{Command: "echo"}) - assert.ErrorIs(t, err, ErrServiceNotInitialized) - - _, err = RunWithOptions(context.Background(), RunOptions{Command: "echo"}) - assert.ErrorIs(t, err, ErrServiceNotInitialized) -} - -func TestGlobal_SetDefault(t *testing.T) { - t.Run("sets and retrieves service", func(t *testing.T) { - // Reset global state - old := defaultService.Swap(nil) - defer func() { - if old != nil { - defaultService.Store(old) - } - }() - - core, err := framework.New( - framework.WithName("process", NewService(Options{})), - ) - require.NoError(t, err) - - svc, err := framework.ServiceFor[*Service](core, "process") - require.NoError(t, err) - - SetDefault(svc) - assert.Equal(t, svc, Default()) - }) - - t.Run("panics on nil", func(t *testing.T) { - assert.Panics(t, func() { - SetDefault(nil) - }) - }) -} - -func TestGlobal_ConcurrentDefault(t *testing.T) { - // Reset global state - old := defaultService.Swap(nil) - defer func() { - if old != nil { - defaultService.Store(old) - } - }() - - core, err := framework.New( - framework.WithName("process", NewService(Options{})), - ) - require.NoError(t, err) - - svc, err := framework.ServiceFor[*Service](core, "process") - require.NoError(t, err) - - SetDefault(svc) - - // Concurrent reads of Default() - var wg sync.WaitGroup - for i := 0; i < 100; i++ { - wg.Add(1) - go func() { - defer wg.Done() - s := Default() - assert.NotNil(t, s) - assert.Equal(t, svc, s) - }() - } - wg.Wait() -} - -func TestGlobal_ConcurrentSetDefault(t *testing.T) { - // Reset global state - old := defaultService.Swap(nil) - defer func() { - if old != nil { - defaultService.Store(old) - } - }() - - // Create multiple services - var services []*Service - for i := 0; i < 10; i++ { - core, err := framework.New( - framework.WithName("process", NewService(Options{})), - ) - require.NoError(t, err) - - svc, err := framework.ServiceFor[*Service](core, "process") - require.NoError(t, err) - services = append(services, svc) - } - - // Concurrent SetDefault calls - should not panic or race - var wg sync.WaitGroup - for _, svc := range services { - wg.Add(1) - go func(s *Service) { - defer wg.Done() - SetDefault(s) - }(svc) - } - wg.Wait() - - // Final state should be one of the services - final := Default() - assert.NotNil(t, final) - - found := false - for _, svc := range services { - if svc == final { - found = true - break - } - } - assert.True(t, found, "Default should be one of the set services") -} - -func TestGlobal_ConcurrentOperations(t *testing.T) { - // Reset global state - old := defaultService.Swap(nil) - defer func() { - if old != nil { - defaultService.Store(old) - } - }() - - core, err := framework.New( - framework.WithName("process", NewService(Options{})), - ) - require.NoError(t, err) - - svc, err := framework.ServiceFor[*Service](core, "process") - require.NoError(t, err) - - SetDefault(svc) - - // Concurrent Start, List, Get operations - var wg sync.WaitGroup - var processes []*Process - var procMu sync.Mutex - - // Start 20 processes concurrently - for i := 0; i < 20; i++ { - wg.Add(1) - go func() { - defer wg.Done() - proc, err := Start(context.Background(), "echo", "concurrent") - if err == nil { - procMu.Lock() - processes = append(processes, proc) - procMu.Unlock() - } - }() - } - - // Concurrent List calls while starting - for i := 0; i < 10; i++ { - wg.Add(1) - go func() { - defer wg.Done() - _ = List() - _ = Running() - }() - } - - wg.Wait() - - // Wait for all processes to complete - procMu.Lock() - for _, p := range processes { - <-p.Done() - } - procMu.Unlock() - - // All should have succeeded - assert.Len(t, processes, 20) - - // Concurrent Get calls - var wg2 sync.WaitGroup - for _, p := range processes { - wg2.Add(1) - go func(id string) { - defer wg2.Done() - got, err := Get(id) - assert.NoError(t, err) - assert.NotNil(t, got) - }(p.ID) - } - wg2.Wait() -} - -func TestGlobal_StartWithOptions(t *testing.T) { - svc, _ := newTestService(t) - - // Set as default - old := defaultService.Swap(svc) - defer func() { - if old != nil { - defaultService.Store(old) - } - }() - - proc, err := StartWithOptions(context.Background(), RunOptions{ - Command: "echo", - Args: []string{"with", "options"}, - }) - require.NoError(t, err) - - <-proc.Done() - - assert.Equal(t, 0, proc.ExitCode) - assert.Contains(t, proc.Output(), "with options") -} - -func TestGlobal_RunWithOptions(t *testing.T) { - svc, _ := newTestService(t) - - // Set as default - old := defaultService.Swap(svc) - defer func() { - if old != nil { - defaultService.Store(old) - } - }() - - output, err := RunWithOptions(context.Background(), RunOptions{ - Command: "echo", - Args: []string{"run", "options"}, - }) - require.NoError(t, err) - assert.Contains(t, output, "run options") -} - -func TestGlobal_Running(t *testing.T) { - svc, _ := newTestService(t) - - // Set as default - old := defaultService.Swap(svc) - defer func() { - if old != nil { - defaultService.Store(old) - } - }() - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - // Start a long-running process - proc, err := Start(ctx, "sleep", "60") - require.NoError(t, err) - - running := Running() - assert.Len(t, running, 1) - assert.Equal(t, proc.ID, running[0].ID) - - cancel() - <-proc.Done() - - running = Running() - assert.Len(t, running, 0) -} diff --git a/pkg/process/process.go b/pkg/process/process.go deleted file mode 100644 index 45ee0d9..0000000 --- a/pkg/process/process.go +++ /dev/null @@ -1,167 +0,0 @@ -package process - -import ( - "context" - "io" - "os/exec" - "sync" - "time" -) - -// Process represents a managed external process. -type Process struct { - ID string - Command string - Args []string - Dir string - Env []string - StartedAt time.Time - Status Status - ExitCode int - Duration time.Duration - - cmd *exec.Cmd - ctx context.Context - cancel context.CancelFunc - output *RingBuffer - stdin io.WriteCloser - done chan struct{} - mu sync.RWMutex -} - -// Info returns a snapshot of process state. -func (p *Process) Info() Info { - p.mu.RLock() - defer p.mu.RUnlock() - - pid := 0 - if p.cmd != nil && p.cmd.Process != nil { - pid = p.cmd.Process.Pid - } - - return Info{ - ID: p.ID, - Command: p.Command, - Args: p.Args, - Dir: p.Dir, - StartedAt: p.StartedAt, - Status: p.Status, - ExitCode: p.ExitCode, - Duration: p.Duration, - PID: pid, - } -} - -// Output returns the captured output as a string. -func (p *Process) Output() string { - p.mu.RLock() - defer p.mu.RUnlock() - if p.output == nil { - return "" - } - return p.output.String() -} - -// OutputBytes returns the captured output as bytes. -func (p *Process) OutputBytes() []byte { - p.mu.RLock() - defer p.mu.RUnlock() - if p.output == nil { - return nil - } - return p.output.Bytes() -} - -// IsRunning returns true if the process is still executing. -func (p *Process) IsRunning() bool { - p.mu.RLock() - defer p.mu.RUnlock() - return p.Status == StatusRunning -} - -// Wait blocks until the process exits. -func (p *Process) Wait() error { - <-p.done - p.mu.RLock() - defer p.mu.RUnlock() - if p.Status == StatusFailed || p.Status == StatusKilled { - return &exec.ExitError{} - } - if p.ExitCode != 0 { - return &exec.ExitError{} - } - return nil -} - -// Done returns a channel that closes when the process exits. -func (p *Process) Done() <-chan struct{} { - return p.done -} - -// Kill forcefully terminates the process. -func (p *Process) Kill() error { - p.mu.Lock() - defer p.mu.Unlock() - - if p.Status != StatusRunning { - return nil - } - - if p.cmd == nil || p.cmd.Process == nil { - return nil - } - - return p.cmd.Process.Kill() -} - -// Signal sends a signal to the process. -func (p *Process) Signal(sig interface{ Signal() }) error { - p.mu.Lock() - defer p.mu.Unlock() - - if p.Status != StatusRunning { - return nil - } - - if p.cmd == nil || p.cmd.Process == nil { - return nil - } - - // Type assert to os.Signal for Process.Signal - if osSig, ok := sig.(interface{ String() string }); ok { - _ = osSig // Satisfy linter - } - - return p.cmd.Process.Kill() // Simplified - would use Signal in full impl -} - -// SendInput writes to the process stdin. -func (p *Process) SendInput(input string) error { - p.mu.RLock() - defer p.mu.RUnlock() - - if p.Status != StatusRunning { - return ErrProcessNotRunning - } - - if p.stdin == nil { - return ErrStdinNotAvailable - } - - _, err := p.stdin.Write([]byte(input)) - return err -} - -// CloseStdin closes the process stdin pipe. -func (p *Process) CloseStdin() error { - p.mu.Lock() - defer p.mu.Unlock() - - if p.stdin == nil { - return nil - } - - err := p.stdin.Close() - p.stdin = nil - return err -} diff --git a/pkg/process/process_global.go b/pkg/process/process_global.go deleted file mode 100644 index deed860..0000000 --- a/pkg/process/process_global.go +++ /dev/null @@ -1,133 +0,0 @@ -package process - -import ( - "context" - "sync" - "sync/atomic" - - "forge.lthn.ai/core/go/pkg/framework" -) - -// Global default service (follows i18n pattern). -var ( - defaultService atomic.Pointer[Service] - defaultOnce sync.Once - defaultErr error -) - -// Default returns the global process service. -// Returns nil if not initialized. -func Default() *Service { - return defaultService.Load() -} - -// SetDefault sets the global process service. -// Thread-safe: can be called concurrently with Default(). -func SetDefault(s *Service) { - if s == nil { - panic("process: SetDefault called with nil service") - } - defaultService.Store(s) -} - -// Init initializes the default global service with a Core instance. -// This is typically called during application startup. -func Init(c *framework.Core) error { - defaultOnce.Do(func() { - factory := NewService(Options{}) - svc, err := factory(c) - if err != nil { - defaultErr = err - return - } - defaultService.Store(svc.(*Service)) - }) - return defaultErr -} - -// --- Global convenience functions --- - -// Start spawns a new process using the default service. -func Start(ctx context.Context, command string, args ...string) (*Process, error) { - svc := Default() - if svc == nil { - return nil, ErrServiceNotInitialized - } - return svc.Start(ctx, command, args...) -} - -// Run executes a command and waits for completion using the default service. -func Run(ctx context.Context, command string, args ...string) (string, error) { - svc := Default() - if svc == nil { - return "", ErrServiceNotInitialized - } - return svc.Run(ctx, command, args...) -} - -// Get returns a process by ID from the default service. -func Get(id string) (*Process, error) { - svc := Default() - if svc == nil { - return nil, ErrServiceNotInitialized - } - return svc.Get(id) -} - -// List returns all processes from the default service. -func List() []*Process { - svc := Default() - if svc == nil { - return nil - } - return svc.List() -} - -// Kill terminates a process by ID using the default service. -func Kill(id string) error { - svc := Default() - if svc == nil { - return ErrServiceNotInitialized - } - return svc.Kill(id) -} - -// StartWithOptions spawns a process with full configuration using the default service. -func StartWithOptions(ctx context.Context, opts RunOptions) (*Process, error) { - svc := Default() - if svc == nil { - return nil, ErrServiceNotInitialized - } - return svc.StartWithOptions(ctx, opts) -} - -// RunWithOptions executes a command with options and waits using the default service. -func RunWithOptions(ctx context.Context, opts RunOptions) (string, error) { - svc := Default() - if svc == nil { - return "", ErrServiceNotInitialized - } - return svc.RunWithOptions(ctx, opts) -} - -// Running returns all currently running processes from the default service. -func Running() []*Process { - svc := Default() - if svc == nil { - return nil - } - return svc.Running() -} - -// ErrServiceNotInitialized is returned when the service is not initialized. -var ErrServiceNotInitialized = &ServiceError{msg: "process: service not initialized"} - -// ServiceError represents a service-level error. -type ServiceError struct { - msg string -} - -// Error returns the service error message. -func (e *ServiceError) Error() string { - return e.msg -} diff --git a/pkg/process/process_test.go b/pkg/process/process_test.go deleted file mode 100644 index 8bf7bf7..0000000 --- a/pkg/process/process_test.go +++ /dev/null @@ -1,227 +0,0 @@ -package process - -import ( - "context" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestProcess_Info(t *testing.T) { - svc, _ := newTestService(t) - - proc, err := svc.Start(context.Background(), "echo", "hello") - require.NoError(t, err) - - <-proc.Done() - - info := proc.Info() - assert.Equal(t, proc.ID, info.ID) - assert.Equal(t, "echo", info.Command) - assert.Equal(t, []string{"hello"}, info.Args) - assert.Equal(t, StatusExited, info.Status) - assert.Equal(t, 0, info.ExitCode) - assert.Greater(t, info.Duration, time.Duration(0)) -} - -func TestProcess_Output(t *testing.T) { - t.Run("captures stdout", func(t *testing.T) { - svc, _ := newTestService(t) - - proc, err := svc.Start(context.Background(), "echo", "hello world") - require.NoError(t, err) - - <-proc.Done() - - output := proc.Output() - assert.Contains(t, output, "hello world") - }) - - t.Run("OutputBytes returns copy", func(t *testing.T) { - svc, _ := newTestService(t) - - proc, err := svc.Start(context.Background(), "echo", "test") - require.NoError(t, err) - - <-proc.Done() - - bytes := proc.OutputBytes() - assert.NotNil(t, bytes) - assert.Contains(t, string(bytes), "test") - }) -} - -func TestProcess_IsRunning(t *testing.T) { - t.Run("true while running", func(t *testing.T) { - svc, _ := newTestService(t) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - proc, err := svc.Start(ctx, "sleep", "10") - require.NoError(t, err) - - assert.True(t, proc.IsRunning()) - - cancel() - <-proc.Done() - - assert.False(t, proc.IsRunning()) - }) - - t.Run("false after completion", func(t *testing.T) { - svc, _ := newTestService(t) - - proc, err := svc.Start(context.Background(), "echo", "done") - require.NoError(t, err) - - <-proc.Done() - - assert.False(t, proc.IsRunning()) - }) -} - -func TestProcess_Wait(t *testing.T) { - t.Run("returns nil on success", func(t *testing.T) { - svc, _ := newTestService(t) - - proc, err := svc.Start(context.Background(), "echo", "ok") - require.NoError(t, err) - - err = proc.Wait() - assert.NoError(t, err) - }) - - t.Run("returns error on failure", func(t *testing.T) { - svc, _ := newTestService(t) - - proc, err := svc.Start(context.Background(), "sh", "-c", "exit 1") - require.NoError(t, err) - - err = proc.Wait() - assert.Error(t, err) - }) -} - -func TestProcess_Done(t *testing.T) { - t.Run("channel closes on completion", func(t *testing.T) { - svc, _ := newTestService(t) - - proc, err := svc.Start(context.Background(), "echo", "test") - require.NoError(t, err) - - select { - case <-proc.Done(): - // Success - channel closed - case <-time.After(5 * time.Second): - t.Fatal("Done channel should have closed") - } - }) -} - -func TestProcess_Kill(t *testing.T) { - t.Run("terminates running process", func(t *testing.T) { - svc, _ := newTestService(t) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - proc, err := svc.Start(ctx, "sleep", "60") - require.NoError(t, err) - - assert.True(t, proc.IsRunning()) - - err = proc.Kill() - assert.NoError(t, err) - - select { - case <-proc.Done(): - // Good - process terminated - case <-time.After(2 * time.Second): - t.Fatal("process should have been killed") - } - }) - - t.Run("noop on completed process", func(t *testing.T) { - svc, _ := newTestService(t) - - proc, err := svc.Start(context.Background(), "echo", "done") - require.NoError(t, err) - - <-proc.Done() - - err = proc.Kill() - assert.NoError(t, err) - }) -} - -func TestProcess_SendInput(t *testing.T) { - t.Run("writes to stdin", func(t *testing.T) { - svc, _ := newTestService(t) - - // Use cat to echo back stdin - proc, err := svc.Start(context.Background(), "cat") - require.NoError(t, err) - - err = proc.SendInput("hello\n") - assert.NoError(t, err) - - err = proc.CloseStdin() - assert.NoError(t, err) - - <-proc.Done() - - assert.Contains(t, proc.Output(), "hello") - }) - - t.Run("error on completed process", func(t *testing.T) { - svc, _ := newTestService(t) - - proc, err := svc.Start(context.Background(), "echo", "done") - require.NoError(t, err) - - <-proc.Done() - - err = proc.SendInput("test") - assert.ErrorIs(t, err, ErrProcessNotRunning) - }) -} - -func TestProcess_CloseStdin(t *testing.T) { - t.Run("closes stdin pipe", func(t *testing.T) { - svc, _ := newTestService(t) - - proc, err := svc.Start(context.Background(), "cat") - require.NoError(t, err) - - err = proc.CloseStdin() - assert.NoError(t, err) - - // Process should exit now that stdin is closed - select { - case <-proc.Done(): - // Good - case <-time.After(2 * time.Second): - t.Fatal("cat should exit when stdin is closed") - } - }) - - t.Run("double close is safe", func(t *testing.T) { - svc, _ := newTestService(t) - - proc, err := svc.Start(context.Background(), "cat") - require.NoError(t, err) - - // First close - err = proc.CloseStdin() - assert.NoError(t, err) - - <-proc.Done() - - // Second close should be safe (stdin already nil) - err = proc.CloseStdin() - assert.NoError(t, err) - }) -} diff --git a/pkg/process/runner.go b/pkg/process/runner.go deleted file mode 100644 index 8eb1fd5..0000000 --- a/pkg/process/runner.go +++ /dev/null @@ -1,293 +0,0 @@ -package process - -import ( - "context" - "errors" - "sync" - "time" -) - -// Runner orchestrates multiple processes with dependencies. -type Runner struct { - service *Service -} - -// NewRunner creates a runner for the given service. -func NewRunner(svc *Service) *Runner { - return &Runner{service: svc} -} - -// RunSpec defines a process to run with optional dependencies. -type RunSpec struct { - // Name is a friendly identifier (e.g., "lint", "test"). - Name string - // Command is the executable to run. - Command string - // Args are the command arguments. - Args []string - // Dir is the working directory. - Dir string - // Env are additional environment variables. - Env []string - // After lists spec names that must complete successfully first. - After []string - // AllowFailure if true, continues pipeline even if this spec fails. - AllowFailure bool -} - -// RunResult captures the outcome of a single process. -type RunResult struct { - Name string - Spec RunSpec - ExitCode int - Duration time.Duration - Output string - Error error - Skipped bool -} - -// Passed returns true if the process succeeded. -func (r RunResult) Passed() bool { - return !r.Skipped && r.Error == nil && r.ExitCode == 0 -} - -// RunAllResult is the aggregate result of running multiple specs. -type RunAllResult struct { - Results []RunResult - Duration time.Duration - Passed int - Failed int - Skipped int -} - -// Success returns true if all non-skipped specs passed. -func (r RunAllResult) Success() bool { - return r.Failed == 0 -} - -// RunAll executes specs respecting dependencies, parallelising where possible. -func (r *Runner) RunAll(ctx context.Context, specs []RunSpec) (*RunAllResult, error) { - start := time.Now() - - // Build dependency graph - specMap := make(map[string]RunSpec) - for _, spec := range specs { - specMap[spec.Name] = spec - } - - // Track completion - completed := make(map[string]*RunResult) - var completedMu sync.Mutex - - results := make([]RunResult, 0, len(specs)) - var resultsMu sync.Mutex - - // Process specs in waves - remaining := make(map[string]RunSpec) - for _, spec := range specs { - remaining[spec.Name] = spec - } - - for len(remaining) > 0 { - // Find specs ready to run (all dependencies satisfied) - ready := make([]RunSpec, 0) - for _, spec := range remaining { - if r.canRun(spec, completed) { - ready = append(ready, spec) - } - } - - if len(ready) == 0 && len(remaining) > 0 { - // Deadlock - circular dependency or missing specs - for name := range remaining { - results = append(results, RunResult{ - Name: name, - Spec: remaining[name], - Skipped: true, - Error: errors.New("circular dependency or missing dependency"), - }) - } - break - } - - // Run ready specs in parallel - var wg sync.WaitGroup - for _, spec := range ready { - wg.Add(1) - go func(spec RunSpec) { - defer wg.Done() - - // Check if dependencies failed - completedMu.Lock() - shouldSkip := false - for _, dep := range spec.After { - if result, ok := completed[dep]; ok { - if !result.Passed() && !specMap[dep].AllowFailure { - shouldSkip = true - break - } - } - } - completedMu.Unlock() - - var result RunResult - if shouldSkip { - result = RunResult{ - Name: spec.Name, - Spec: spec, - Skipped: true, - Error: errors.New("skipped due to dependency failure"), - } - } else { - result = r.runSpec(ctx, spec) - } - - completedMu.Lock() - completed[spec.Name] = &result - completedMu.Unlock() - - resultsMu.Lock() - results = append(results, result) - resultsMu.Unlock() - }(spec) - } - wg.Wait() - - // Remove completed from remaining - for _, spec := range ready { - delete(remaining, spec.Name) - } - } - - // Build aggregate result - aggResult := &RunAllResult{ - Results: results, - Duration: time.Since(start), - } - - for _, res := range results { - if res.Skipped { - aggResult.Skipped++ - } else if res.Passed() { - aggResult.Passed++ - } else { - aggResult.Failed++ - } - } - - return aggResult, nil -} - -// canRun checks if all dependencies are completed. -func (r *Runner) canRun(spec RunSpec, completed map[string]*RunResult) bool { - for _, dep := range spec.After { - if _, ok := completed[dep]; !ok { - return false - } - } - return true -} - -// runSpec executes a single spec. -func (r *Runner) runSpec(ctx context.Context, spec RunSpec) RunResult { - start := time.Now() - - proc, err := r.service.StartWithOptions(ctx, RunOptions{ - Command: spec.Command, - Args: spec.Args, - Dir: spec.Dir, - Env: spec.Env, - }) - if err != nil { - return RunResult{ - Name: spec.Name, - Spec: spec, - Duration: time.Since(start), - Error: err, - } - } - - <-proc.Done() - - return RunResult{ - Name: spec.Name, - Spec: spec, - ExitCode: proc.ExitCode, - Duration: proc.Duration, - Output: proc.Output(), - Error: nil, - } -} - -// RunSequential executes specs one after another, stopping on first failure. -func (r *Runner) RunSequential(ctx context.Context, specs []RunSpec) (*RunAllResult, error) { - start := time.Now() - results := make([]RunResult, 0, len(specs)) - - for _, spec := range specs { - result := r.runSpec(ctx, spec) - results = append(results, result) - - if !result.Passed() && !spec.AllowFailure { - // Mark remaining as skipped - for i := len(results); i < len(specs); i++ { - results = append(results, RunResult{ - Name: specs[i].Name, - Spec: specs[i], - Skipped: true, - }) - } - break - } - } - - aggResult := &RunAllResult{ - Results: results, - Duration: time.Since(start), - } - - for _, res := range results { - if res.Skipped { - aggResult.Skipped++ - } else if res.Passed() { - aggResult.Passed++ - } else { - aggResult.Failed++ - } - } - - return aggResult, nil -} - -// RunParallel executes all specs concurrently, regardless of dependencies. -func (r *Runner) RunParallel(ctx context.Context, specs []RunSpec) (*RunAllResult, error) { - start := time.Now() - results := make([]RunResult, len(specs)) - - var wg sync.WaitGroup - for i, spec := range specs { - wg.Add(1) - go func(i int, spec RunSpec) { - defer wg.Done() - results[i] = r.runSpec(ctx, spec) - }(i, spec) - } - wg.Wait() - - aggResult := &RunAllResult{ - Results: results, - Duration: time.Since(start), - } - - for _, res := range results { - if res.Skipped { - aggResult.Skipped++ - } else if res.Passed() { - aggResult.Passed++ - } else { - aggResult.Failed++ - } - } - - return aggResult, nil -} diff --git a/pkg/process/runner_test.go b/pkg/process/runner_test.go deleted file mode 100644 index 7d27f8c..0000000 --- a/pkg/process/runner_test.go +++ /dev/null @@ -1,176 +0,0 @@ -package process - -import ( - "context" - "testing" - - "forge.lthn.ai/core/go/pkg/framework" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func newTestRunner(t *testing.T) *Runner { - t.Helper() - - core, err := framework.New( - framework.WithName("process", NewService(Options{})), - ) - require.NoError(t, err) - - svc, err := framework.ServiceFor[*Service](core, "process") - require.NoError(t, err) - - return NewRunner(svc) -} - -func TestRunner_RunSequential(t *testing.T) { - t.Run("all pass", func(t *testing.T) { - runner := newTestRunner(t) - - result, err := runner.RunSequential(context.Background(), []RunSpec{ - {Name: "first", Command: "echo", Args: []string{"1"}}, - {Name: "second", Command: "echo", Args: []string{"2"}}, - {Name: "third", Command: "echo", Args: []string{"3"}}, - }) - require.NoError(t, err) - - assert.True(t, result.Success()) - assert.Equal(t, 3, result.Passed) - assert.Equal(t, 0, result.Failed) - assert.Equal(t, 0, result.Skipped) - }) - - t.Run("stops on failure", func(t *testing.T) { - runner := newTestRunner(t) - - result, err := runner.RunSequential(context.Background(), []RunSpec{ - {Name: "first", Command: "echo", Args: []string{"1"}}, - {Name: "fails", Command: "sh", Args: []string{"-c", "exit 1"}}, - {Name: "third", Command: "echo", Args: []string{"3"}}, - }) - require.NoError(t, err) - - assert.False(t, result.Success()) - assert.Equal(t, 1, result.Passed) - assert.Equal(t, 1, result.Failed) - assert.Equal(t, 1, result.Skipped) - }) - - t.Run("allow failure continues", func(t *testing.T) { - runner := newTestRunner(t) - - result, err := runner.RunSequential(context.Background(), []RunSpec{ - {Name: "first", Command: "echo", Args: []string{"1"}}, - {Name: "fails", Command: "sh", Args: []string{"-c", "exit 1"}, AllowFailure: true}, - {Name: "third", Command: "echo", Args: []string{"3"}}, - }) - require.NoError(t, err) - - // Still counts as failed but pipeline continues - assert.Equal(t, 2, result.Passed) - assert.Equal(t, 1, result.Failed) - assert.Equal(t, 0, result.Skipped) - }) -} - -func TestRunner_RunParallel(t *testing.T) { - t.Run("all run concurrently", func(t *testing.T) { - runner := newTestRunner(t) - - result, err := runner.RunParallel(context.Background(), []RunSpec{ - {Name: "first", Command: "echo", Args: []string{"1"}}, - {Name: "second", Command: "echo", Args: []string{"2"}}, - {Name: "third", Command: "echo", Args: []string{"3"}}, - }) - require.NoError(t, err) - - assert.True(t, result.Success()) - assert.Equal(t, 3, result.Passed) - assert.Len(t, result.Results, 3) - }) - - t.Run("failure doesnt stop others", func(t *testing.T) { - runner := newTestRunner(t) - - result, err := runner.RunParallel(context.Background(), []RunSpec{ - {Name: "first", Command: "echo", Args: []string{"1"}}, - {Name: "fails", Command: "sh", Args: []string{"-c", "exit 1"}}, - {Name: "third", Command: "echo", Args: []string{"3"}}, - }) - require.NoError(t, err) - - assert.False(t, result.Success()) - assert.Equal(t, 2, result.Passed) - assert.Equal(t, 1, result.Failed) - }) -} - -func TestRunner_RunAll(t *testing.T) { - t.Run("respects dependencies", func(t *testing.T) { - runner := newTestRunner(t) - - result, err := runner.RunAll(context.Background(), []RunSpec{ - {Name: "third", Command: "echo", Args: []string{"3"}, After: []string{"second"}}, - {Name: "first", Command: "echo", Args: []string{"1"}}, - {Name: "second", Command: "echo", Args: []string{"2"}, After: []string{"first"}}, - }) - require.NoError(t, err) - - assert.True(t, result.Success()) - assert.Equal(t, 3, result.Passed) - }) - - t.Run("skips dependents on failure", func(t *testing.T) { - runner := newTestRunner(t) - - result, err := runner.RunAll(context.Background(), []RunSpec{ - {Name: "first", Command: "sh", Args: []string{"-c", "exit 1"}}, - {Name: "second", Command: "echo", Args: []string{"2"}, After: []string{"first"}}, - {Name: "third", Command: "echo", Args: []string{"3"}, After: []string{"second"}}, - }) - require.NoError(t, err) - - assert.False(t, result.Success()) - assert.Equal(t, 0, result.Passed) - assert.Equal(t, 1, result.Failed) - assert.Equal(t, 2, result.Skipped) - }) - - t.Run("parallel independent specs", func(t *testing.T) { - runner := newTestRunner(t) - - // These should run in parallel since they have no dependencies - result, err := runner.RunAll(context.Background(), []RunSpec{ - {Name: "a", Command: "echo", Args: []string{"a"}}, - {Name: "b", Command: "echo", Args: []string{"b"}}, - {Name: "c", Command: "echo", Args: []string{"c"}}, - {Name: "final", Command: "echo", Args: []string{"done"}, After: []string{"a", "b", "c"}}, - }) - require.NoError(t, err) - - assert.True(t, result.Success()) - assert.Equal(t, 4, result.Passed) - }) -} - -func TestRunResult_Passed(t *testing.T) { - t.Run("success", func(t *testing.T) { - r := RunResult{ExitCode: 0} - assert.True(t, r.Passed()) - }) - - t.Run("non-zero exit", func(t *testing.T) { - r := RunResult{ExitCode: 1} - assert.False(t, r.Passed()) - }) - - t.Run("skipped", func(t *testing.T) { - r := RunResult{ExitCode: 0, Skipped: true} - assert.False(t, r.Passed()) - }) - - t.Run("error", func(t *testing.T) { - r := RunResult{ExitCode: 0, Error: assert.AnError} - assert.False(t, r.Passed()) - }) -} diff --git a/pkg/process/service.go b/pkg/process/service.go deleted file mode 100644 index 405fac1..0000000 --- a/pkg/process/service.go +++ /dev/null @@ -1,378 +0,0 @@ -package process - -import ( - "bufio" - "context" - "errors" - "fmt" - "io" - "os/exec" - "sync" - "sync/atomic" - "time" - - "forge.lthn.ai/core/go/pkg/framework" -) - -// Default buffer size for process output (1MB). -const DefaultBufferSize = 1024 * 1024 - -// Errors -var ( - ErrProcessNotFound = errors.New("process not found") - ErrProcessNotRunning = errors.New("process is not running") - ErrStdinNotAvailable = errors.New("stdin not available") -) - -// Service manages process execution with Core IPC integration. -type Service struct { - *framework.ServiceRuntime[Options] - - processes map[string]*Process - mu sync.RWMutex - bufSize int - idCounter atomic.Uint64 -} - -// Options configures the process service. -type Options struct { - // BufferSize is the ring buffer size for output capture. - // Default: 1MB (1024 * 1024 bytes). - BufferSize int -} - -// NewService creates a process service factory for Core registration. -// -// core, _ := framework.New( -// framework.WithName("process", process.NewService(process.Options{})), -// ) -func NewService(opts Options) func(*framework.Core) (any, error) { - return func(c *framework.Core) (any, error) { - if opts.BufferSize == 0 { - opts.BufferSize = DefaultBufferSize - } - svc := &Service{ - ServiceRuntime: framework.NewServiceRuntime(c, opts), - processes: make(map[string]*Process), - bufSize: opts.BufferSize, - } - return svc, nil - } -} - -// OnStartup implements framework.Startable. -func (s *Service) OnStartup(ctx context.Context) error { - return nil -} - -// OnShutdown implements framework.Stoppable. -// Kills all running processes on shutdown. -func (s *Service) OnShutdown(ctx context.Context) error { - s.mu.RLock() - procs := make([]*Process, 0, len(s.processes)) - for _, p := range s.processes { - if p.IsRunning() { - procs = append(procs, p) - } - } - s.mu.RUnlock() - - for _, p := range procs { - _ = p.Kill() - } - - return nil -} - -// Start spawns a new process with the given command and args. -func (s *Service) Start(ctx context.Context, command string, args ...string) (*Process, error) { - return s.StartWithOptions(ctx, RunOptions{ - Command: command, - Args: args, - }) -} - -// StartWithOptions spawns a process with full configuration. -func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Process, error) { - id := fmt.Sprintf("proc-%d", s.idCounter.Add(1)) - - procCtx, cancel := context.WithCancel(ctx) - cmd := exec.CommandContext(procCtx, opts.Command, opts.Args...) - - if opts.Dir != "" { - cmd.Dir = opts.Dir - } - if len(opts.Env) > 0 { - cmd.Env = append(cmd.Environ(), opts.Env...) - } - - // Set up pipes - stdout, err := cmd.StdoutPipe() - if err != nil { - cancel() - return nil, fmt.Errorf("failed to create stdout pipe: %w", err) - } - - stderr, err := cmd.StderrPipe() - if err != nil { - cancel() - return nil, fmt.Errorf("failed to create stderr pipe: %w", err) - } - - stdin, err := cmd.StdinPipe() - if err != nil { - cancel() - return nil, fmt.Errorf("failed to create stdin pipe: %w", err) - } - - // Create output buffer (enabled by default) - var output *RingBuffer - if !opts.DisableCapture { - output = NewRingBuffer(s.bufSize) - } - - proc := &Process{ - ID: id, - Command: opts.Command, - Args: opts.Args, - Dir: opts.Dir, - Env: opts.Env, - StartedAt: time.Now(), - Status: StatusRunning, - cmd: cmd, - ctx: procCtx, - cancel: cancel, - output: output, - stdin: stdin, - done: make(chan struct{}), - } - - // Start the process - if err := cmd.Start(); err != nil { - cancel() - return nil, fmt.Errorf("failed to start process: %w", err) - } - - // Store process - s.mu.Lock() - s.processes[id] = proc - s.mu.Unlock() - - // Broadcast start - _ = s.Core().ACTION(ActionProcessStarted{ - ID: id, - Command: opts.Command, - Args: opts.Args, - Dir: opts.Dir, - PID: cmd.Process.Pid, - }) - - // Stream output in goroutines - var wg sync.WaitGroup - wg.Add(2) - go func() { - defer wg.Done() - s.streamOutput(proc, stdout, StreamStdout) - }() - go func() { - defer wg.Done() - s.streamOutput(proc, stderr, StreamStderr) - }() - - // Wait for process completion - go func() { - // Wait for output streaming to complete - wg.Wait() - - // Wait for process exit - err := cmd.Wait() - - duration := time.Since(proc.StartedAt) - - proc.mu.Lock() - proc.Duration = duration - if err != nil { - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - proc.ExitCode = exitErr.ExitCode() - proc.Status = StatusExited - } else { - proc.Status = StatusFailed - } - } else { - proc.ExitCode = 0 - proc.Status = StatusExited - } - status := proc.Status - exitCode := proc.ExitCode - proc.mu.Unlock() - - close(proc.done) - - // Broadcast exit - var exitErr error - if status == StatusFailed { - exitErr = err - } - _ = s.Core().ACTION(ActionProcessExited{ - ID: id, - ExitCode: exitCode, - Duration: duration, - Error: exitErr, - }) - }() - - return proc, nil -} - -// streamOutput reads from a pipe and broadcasts lines via ACTION. -func (s *Service) streamOutput(proc *Process, r io.Reader, stream Stream) { - scanner := bufio.NewScanner(r) - // Increase buffer for long lines - scanner.Buffer(make([]byte, 64*1024), 1024*1024) - - for scanner.Scan() { - line := scanner.Text() - - // Write to ring buffer - if proc.output != nil { - _, _ = proc.output.Write([]byte(line + "\n")) - } - - // Broadcast output - _ = s.Core().ACTION(ActionProcessOutput{ - ID: proc.ID, - Line: line, - Stream: stream, - }) - } -} - -// Get returns a process by ID. -func (s *Service) Get(id string) (*Process, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - proc, ok := s.processes[id] - if !ok { - return nil, ErrProcessNotFound - } - return proc, nil -} - -// List returns all processes. -func (s *Service) List() []*Process { - s.mu.RLock() - defer s.mu.RUnlock() - - result := make([]*Process, 0, len(s.processes)) - for _, p := range s.processes { - result = append(result, p) - } - return result -} - -// Running returns all currently running processes. -func (s *Service) Running() []*Process { - s.mu.RLock() - defer s.mu.RUnlock() - - var result []*Process - for _, p := range s.processes { - if p.IsRunning() { - result = append(result, p) - } - } - return result -} - -// Kill terminates a process by ID. -func (s *Service) Kill(id string) error { - proc, err := s.Get(id) - if err != nil { - return err - } - - if err := proc.Kill(); err != nil { - return err - } - - _ = s.Core().ACTION(ActionProcessKilled{ - ID: id, - Signal: "SIGKILL", - }) - - return nil -} - -// Remove removes a completed process from the list. -func (s *Service) Remove(id string) error { - s.mu.Lock() - defer s.mu.Unlock() - - proc, ok := s.processes[id] - if !ok { - return ErrProcessNotFound - } - - if proc.IsRunning() { - return errors.New("cannot remove running process") - } - - delete(s.processes, id) - return nil -} - -// Clear removes all completed processes. -func (s *Service) Clear() { - s.mu.Lock() - defer s.mu.Unlock() - - for id, p := range s.processes { - if !p.IsRunning() { - delete(s.processes, id) - } - } -} - -// Output returns the captured output of a process. -func (s *Service) Output(id string) (string, error) { - proc, err := s.Get(id) - if err != nil { - return "", err - } - return proc.Output(), nil -} - -// Run executes a command and waits for completion. -// Returns the combined output and any error. -func (s *Service) Run(ctx context.Context, command string, args ...string) (string, error) { - proc, err := s.Start(ctx, command, args...) - if err != nil { - return "", err - } - - <-proc.Done() - - output := proc.Output() - if proc.ExitCode != 0 { - return output, fmt.Errorf("process exited with code %d", proc.ExitCode) - } - return output, nil -} - -// RunWithOptions executes a command with options and waits for completion. -func (s *Service) RunWithOptions(ctx context.Context, opts RunOptions) (string, error) { - proc, err := s.StartWithOptions(ctx, opts) - if err != nil { - return "", err - } - - <-proc.Done() - - output := proc.Output() - if proc.ExitCode != 0 { - return output, fmt.Errorf("process exited with code %d", proc.ExitCode) - } - return output, nil -} diff --git a/pkg/process/service_test.go b/pkg/process/service_test.go deleted file mode 100644 index b72e3e2..0000000 --- a/pkg/process/service_test.go +++ /dev/null @@ -1,257 +0,0 @@ -package process - -import ( - "context" - "strings" - "sync" - "testing" - "time" - - "forge.lthn.ai/core/go/pkg/framework" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func newTestService(t *testing.T) (*Service, *framework.Core) { - t.Helper() - - core, err := framework.New( - framework.WithName("process", NewService(Options{BufferSize: 1024})), - ) - require.NoError(t, err) - - svc, err := framework.ServiceFor[*Service](core, "process") - require.NoError(t, err) - - return svc, core -} - -func TestService_Start(t *testing.T) { - t.Run("echo command", func(t *testing.T) { - svc, _ := newTestService(t) - - proc, err := svc.Start(context.Background(), "echo", "hello") - require.NoError(t, err) - require.NotNil(t, proc) - - assert.NotEmpty(t, proc.ID) - assert.Equal(t, "echo", proc.Command) - assert.Equal(t, []string{"hello"}, proc.Args) - - // Wait for completion - <-proc.Done() - - assert.Equal(t, StatusExited, proc.Status) - assert.Equal(t, 0, proc.ExitCode) - assert.Contains(t, proc.Output(), "hello") - }) - - t.Run("failing command", func(t *testing.T) { - svc, _ := newTestService(t) - - proc, err := svc.Start(context.Background(), "sh", "-c", "exit 42") - require.NoError(t, err) - - <-proc.Done() - - assert.Equal(t, StatusExited, proc.Status) - assert.Equal(t, 42, proc.ExitCode) - }) - - t.Run("non-existent command", func(t *testing.T) { - svc, _ := newTestService(t) - - _, err := svc.Start(context.Background(), "nonexistent_command_xyz") - assert.Error(t, err) - }) - - t.Run("with working directory", func(t *testing.T) { - svc, _ := newTestService(t) - - proc, err := svc.StartWithOptions(context.Background(), RunOptions{ - Command: "pwd", - Dir: "/tmp", - }) - require.NoError(t, err) - - <-proc.Done() - - // On macOS /tmp is a symlink to /private/tmp - output := strings.TrimSpace(proc.Output()) - assert.True(t, output == "/tmp" || output == "/private/tmp", "got: %s", output) - }) - - t.Run("context cancellation", func(t *testing.T) { - svc, _ := newTestService(t) - - ctx, cancel := context.WithCancel(context.Background()) - proc, err := svc.Start(ctx, "sleep", "10") - require.NoError(t, err) - - // Cancel immediately - cancel() - - select { - case <-proc.Done(): - // Good - process was killed - case <-time.After(2 * time.Second): - t.Fatal("process should have been killed") - } - }) -} - -func TestService_Run(t *testing.T) { - t.Run("returns output", func(t *testing.T) { - svc, _ := newTestService(t) - - output, err := svc.Run(context.Background(), "echo", "hello world") - require.NoError(t, err) - assert.Contains(t, output, "hello world") - }) - - t.Run("returns error on failure", func(t *testing.T) { - svc, _ := newTestService(t) - - _, err := svc.Run(context.Background(), "sh", "-c", "exit 1") - assert.Error(t, err) - assert.Contains(t, err.Error(), "exited with code 1") - }) -} - -func TestService_Actions(t *testing.T) { - t.Run("broadcasts events", func(t *testing.T) { - core, err := framework.New( - framework.WithName("process", NewService(Options{})), - ) - require.NoError(t, err) - - var started []ActionProcessStarted - var outputs []ActionProcessOutput - var exited []ActionProcessExited - var mu sync.Mutex - - core.RegisterAction(func(c *framework.Core, msg framework.Message) error { - mu.Lock() - defer mu.Unlock() - switch m := msg.(type) { - case ActionProcessStarted: - started = append(started, m) - case ActionProcessOutput: - outputs = append(outputs, m) - case ActionProcessExited: - exited = append(exited, m) - } - return nil - }) - - svc, _ := framework.ServiceFor[*Service](core, "process") - proc, err := svc.Start(context.Background(), "echo", "test") - require.NoError(t, err) - - <-proc.Done() - - // Give time for events to propagate - time.Sleep(10 * time.Millisecond) - - mu.Lock() - defer mu.Unlock() - - assert.Len(t, started, 1) - assert.Equal(t, "echo", started[0].Command) - assert.Equal(t, []string{"test"}, started[0].Args) - - assert.NotEmpty(t, outputs) - foundTest := false - for _, o := range outputs { - if strings.Contains(o.Line, "test") { - foundTest = true - break - } - } - assert.True(t, foundTest, "should have output containing 'test'") - - assert.Len(t, exited, 1) - assert.Equal(t, 0, exited[0].ExitCode) - }) -} - -func TestService_List(t *testing.T) { - t.Run("tracks processes", func(t *testing.T) { - svc, _ := newTestService(t) - - proc1, _ := svc.Start(context.Background(), "echo", "1") - proc2, _ := svc.Start(context.Background(), "echo", "2") - - <-proc1.Done() - <-proc2.Done() - - list := svc.List() - assert.Len(t, list, 2) - }) - - t.Run("get by id", func(t *testing.T) { - svc, _ := newTestService(t) - - proc, _ := svc.Start(context.Background(), "echo", "test") - <-proc.Done() - - got, err := svc.Get(proc.ID) - require.NoError(t, err) - assert.Equal(t, proc.ID, got.ID) - }) - - t.Run("get not found", func(t *testing.T) { - svc, _ := newTestService(t) - - _, err := svc.Get("nonexistent") - assert.ErrorIs(t, err, ErrProcessNotFound) - }) -} - -func TestService_Remove(t *testing.T) { - t.Run("removes completed process", func(t *testing.T) { - svc, _ := newTestService(t) - - proc, _ := svc.Start(context.Background(), "echo", "test") - <-proc.Done() - - err := svc.Remove(proc.ID) - require.NoError(t, err) - - _, err = svc.Get(proc.ID) - assert.ErrorIs(t, err, ErrProcessNotFound) - }) - - t.Run("cannot remove running process", func(t *testing.T) { - svc, _ := newTestService(t) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - proc, _ := svc.Start(ctx, "sleep", "10") - - err := svc.Remove(proc.ID) - assert.Error(t, err) - - cancel() - <-proc.Done() - }) -} - -func TestService_Clear(t *testing.T) { - t.Run("clears completed processes", func(t *testing.T) { - svc, _ := newTestService(t) - - proc1, _ := svc.Start(context.Background(), "echo", "1") - proc2, _ := svc.Start(context.Background(), "echo", "2") - - <-proc1.Done() - <-proc2.Done() - - assert.Len(t, svc.List(), 2) - - svc.Clear() - - assert.Len(t, svc.List(), 0) - }) -} diff --git a/pkg/process/types.go b/pkg/process/types.go deleted file mode 100644 index 4489af7..0000000 --- a/pkg/process/types.go +++ /dev/null @@ -1,89 +0,0 @@ -// Package process provides process management with Core IPC integration. -// -// The process package enables spawning, monitoring, and controlling external -// processes with output streaming via the Core ACTION system. -// -// # Getting Started -// -// // Register with Core -// core, _ := framework.New( -// framework.WithName("process", process.NewService(process.Options{})), -// ) -// -// // Get service and run a process -// svc, err := framework.ServiceFor[*process.Service](core, "process") -// if err != nil { -// return err -// } -// proc, err := svc.Start(ctx, "go", "test", "./...") -// -// # Listening for Events -// -// Process events are broadcast via Core.ACTION: -// -// core.RegisterAction(func(c *framework.Core, msg framework.Message) error { -// switch m := msg.(type) { -// case process.ActionProcessOutput: -// fmt.Print(m.Line) -// case process.ActionProcessExited: -// fmt.Printf("Exit code: %d\n", m.ExitCode) -// } -// return nil -// }) -package process - -import "time" - -// Status represents the process lifecycle state. -type Status string - -const ( - // StatusPending indicates the process is queued but not yet started. - StatusPending Status = "pending" - // StatusRunning indicates the process is actively executing. - StatusRunning Status = "running" - // StatusExited indicates the process completed (check ExitCode). - StatusExited Status = "exited" - // StatusFailed indicates the process could not be started. - StatusFailed Status = "failed" - // StatusKilled indicates the process was terminated by signal. - StatusKilled Status = "killed" -) - -// Stream identifies the output source. -type Stream string - -const ( - // StreamStdout is standard output. - StreamStdout Stream = "stdout" - // StreamStderr is standard error. - StreamStderr Stream = "stderr" -) - -// RunOptions configures process execution. -type RunOptions struct { - // Command is the executable to run. - Command string - // Args are the command arguments. - Args []string - // Dir is the working directory (empty = current). - Dir string - // Env are additional environment variables (KEY=VALUE format). - Env []string - // DisableCapture disables output buffering. - // By default, output is captured to a ring buffer. - DisableCapture bool -} - -// Info provides a snapshot of process state without internal fields. -type Info struct { - ID string `json:"id"` - Command string `json:"command"` - Args []string `json:"args"` - Dir string `json:"dir"` - StartedAt time.Time `json:"startedAt"` - Status Status `json:"status"` - ExitCode int `json:"exitCode"` - Duration time.Duration `json:"duration"` - PID int `json:"pid"` -} diff --git a/pkg/ratelimit/ratelimit.go b/pkg/ratelimit/ratelimit.go deleted file mode 100644 index bb51d49..0000000 --- a/pkg/ratelimit/ratelimit.go +++ /dev/null @@ -1,389 +0,0 @@ -package ratelimit - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "path/filepath" - "sync" - "time" - - "gopkg.in/yaml.v3" -) - -// ModelQuota defines the rate limits for a specific model. -type ModelQuota struct { - MaxRPM int `yaml:"max_rpm"` // Requests per minute - MaxTPM int `yaml:"max_tpm"` // Tokens per minute - MaxRPD int `yaml:"max_rpd"` // Requests per day (0 = unlimited) -} - -// TokenEntry records a token usage event. -type TokenEntry struct { - Time time.Time `yaml:"time"` - Count int `yaml:"count"` -} - -// UsageStats tracks usage history for a model. -type UsageStats struct { - Requests []time.Time `yaml:"requests"` // Sliding window (1m) - Tokens []TokenEntry `yaml:"tokens"` // Sliding window (1m) - DayStart time.Time `yaml:"day_start"` - DayCount int `yaml:"day_count"` -} - -// RateLimiter manages rate limits across multiple models. -type RateLimiter struct { - mu sync.RWMutex - Quotas map[string]ModelQuota `yaml:"quotas"` - State map[string]*UsageStats `yaml:"state"` - filePath string -} - -// New creates a new RateLimiter with default quotas. -func New() (*RateLimiter, error) { - home, err := os.UserHomeDir() - if err != nil { - return nil, err - } - - rl := &RateLimiter{ - Quotas: make(map[string]ModelQuota), - State: make(map[string]*UsageStats), - filePath: filepath.Join(home, ".core", "ratelimits.yaml"), - } - - // Default quotas based on Tier 1 observations (Feb 2026) - rl.Quotas["gemini-3-pro-preview"] = ModelQuota{MaxRPM: 150, MaxTPM: 1000000, MaxRPD: 1000} - rl.Quotas["gemini-3-flash-preview"] = ModelQuota{MaxRPM: 150, MaxTPM: 1000000, MaxRPD: 1000} - rl.Quotas["gemini-2.5-pro"] = ModelQuota{MaxRPM: 150, MaxTPM: 1000000, MaxRPD: 1000} - rl.Quotas["gemini-2.0-flash"] = ModelQuota{MaxRPM: 150, MaxTPM: 1000000, MaxRPD: 0} // Unlimited RPD - rl.Quotas["gemini-2.0-flash-lite"] = ModelQuota{MaxRPM: 0, MaxTPM: 0, MaxRPD: 0} // Unlimited - - return rl, nil -} - -// Load reads the state from disk. -func (rl *RateLimiter) Load() error { - rl.mu.Lock() - defer rl.mu.Unlock() - - data, err := os.ReadFile(rl.filePath) - if os.IsNotExist(err) { - return nil - } - if err != nil { - return err - } - - return yaml.Unmarshal(data, rl) -} - -// Persist writes the state to disk. -func (rl *RateLimiter) Persist() error { - rl.mu.RLock() - defer rl.mu.RUnlock() - - data, err := yaml.Marshal(rl) - if err != nil { - return err - } - - dir := filepath.Dir(rl.filePath) - if err := os.MkdirAll(dir, 0755); err != nil { - return err - } - - return os.WriteFile(rl.filePath, data, 0644) -} - -// prune removes entries older than the sliding window (1 minute). -// Caller must hold lock. -func (rl *RateLimiter) prune(model string) { - stats, ok := rl.State[model] - if !ok { - return - } - - now := time.Now() - window := now.Add(-1 * time.Minute) - - // Prune requests - validReqs := 0 - for _, t := range stats.Requests { - if t.After(window) { - stats.Requests[validReqs] = t - validReqs++ - } - } - stats.Requests = stats.Requests[:validReqs] - - // Prune tokens - validTokens := 0 - for _, t := range stats.Tokens { - if t.Time.After(window) { - stats.Tokens[validTokens] = t - validTokens++ - } - } - stats.Tokens = stats.Tokens[:validTokens] - - // Reset daily counter if day has passed - if now.Sub(stats.DayStart) >= 24*time.Hour { - stats.DayStart = now - stats.DayCount = 0 - } -} - -// CanSend checks if a request can be sent without violating limits. -func (rl *RateLimiter) CanSend(model string, estimatedTokens int) bool { - rl.mu.Lock() - defer rl.mu.Unlock() - - quota, ok := rl.Quotas[model] - if !ok { - return true // Unknown models are allowed - } - - // Unlimited check - if quota.MaxRPM == 0 && quota.MaxTPM == 0 && quota.MaxRPD == 0 { - return true - } - - // Ensure state exists - if _, ok := rl.State[model]; !ok { - rl.State[model] = &UsageStats{ - DayStart: time.Now(), - } - } - - rl.prune(model) - stats := rl.State[model] - - // Check RPD - if quota.MaxRPD > 0 && stats.DayCount >= quota.MaxRPD { - return false - } - - // Check RPM - if quota.MaxRPM > 0 && len(stats.Requests) >= quota.MaxRPM { - return false - } - - // Check TPM - if quota.MaxTPM > 0 { - currentTokens := 0 - for _, t := range stats.Tokens { - currentTokens += t.Count - } - if currentTokens+estimatedTokens > quota.MaxTPM { - return false - } - } - - return true -} - -// RecordUsage records a successful API call. -func (rl *RateLimiter) RecordUsage(model string, promptTokens, outputTokens int) { - rl.mu.Lock() - defer rl.mu.Unlock() - - if _, ok := rl.State[model]; !ok { - rl.State[model] = &UsageStats{ - DayStart: time.Now(), - } - } - - stats := rl.State[model] - now := time.Now() - - stats.Requests = append(stats.Requests, now) - stats.Tokens = append(stats.Tokens, TokenEntry{Time: now, Count: promptTokens + outputTokens}) - stats.DayCount++ -} - -// WaitForCapacity blocks until capacity is available or context is cancelled. -func (rl *RateLimiter) WaitForCapacity(ctx context.Context, model string, tokens int) error { - ticker := time.NewTicker(1 * time.Second) - defer ticker.Stop() - - for { - if rl.CanSend(model, tokens) { - return nil - } - - select { - case <-ctx.Done(): - return ctx.Err() - case <-ticker.C: - // check again - } - } -} - -// Reset clears stats for a model (or all if model is empty). -func (rl *RateLimiter) Reset(model string) { - rl.mu.Lock() - defer rl.mu.Unlock() - - if model == "" { - rl.State = make(map[string]*UsageStats) - } else { - delete(rl.State, model) - } -} - -// ModelStats represents a snapshot of usage. -type ModelStats struct { - RPM int - MaxRPM int - TPM int - MaxTPM int - RPD int - MaxRPD int - DayStart time.Time -} - -// Stats returns current stats for a model. -func (rl *RateLimiter) Stats(model string) ModelStats { - rl.mu.Lock() - defer rl.mu.Unlock() - - rl.prune(model) - - stats := ModelStats{} - quota, ok := rl.Quotas[model] - if ok { - stats.MaxRPM = quota.MaxRPM - stats.MaxTPM = quota.MaxTPM - stats.MaxRPD = quota.MaxRPD - } - - if s, ok := rl.State[model]; ok { - stats.RPM = len(s.Requests) - stats.RPD = s.DayCount - stats.DayStart = s.DayStart - for _, t := range s.Tokens { - stats.TPM += t.Count - } - } - - return stats -} - -// AllStats returns stats for all tracked models. -func (rl *RateLimiter) AllStats() map[string]ModelStats { - rl.mu.Lock() - defer rl.mu.Unlock() - - result := make(map[string]ModelStats) - - // Collect all model names - for m := range rl.Quotas { - result[m] = ModelStats{} - } - for m := range rl.State { - result[m] = ModelStats{} - } - - now := time.Now() - window := now.Add(-1 * time.Minute) - - for m := range result { - // Prune inline - if s, ok := rl.State[m]; ok { - validReqs := 0 - for _, t := range s.Requests { - if t.After(window) { - s.Requests[validReqs] = t - validReqs++ - } - } - s.Requests = s.Requests[:validReqs] - - validTokens := 0 - for _, t := range s.Tokens { - if t.Time.After(window) { - s.Tokens[validTokens] = t - validTokens++ - } - } - s.Tokens = s.Tokens[:validTokens] - - if now.Sub(s.DayStart) >= 24*time.Hour { - s.DayStart = now - s.DayCount = 0 - } - } - - ms := ModelStats{} - if q, ok := rl.Quotas[m]; ok { - ms.MaxRPM = q.MaxRPM - ms.MaxTPM = q.MaxTPM - ms.MaxRPD = q.MaxRPD - } - if s, ok := rl.State[m]; ok { - ms.RPM = len(s.Requests) - ms.RPD = s.DayCount - ms.DayStart = s.DayStart - for _, t := range s.Tokens { - ms.TPM += t.Count - } - } - result[m] = ms - } - - return result -} - -// CountTokens calls the Google API to count tokens for a prompt. -func CountTokens(apiKey, model, text string) (int, error) { - url := fmt.Sprintf("https://generativelanguage.googleapis.com/v1beta/models/%s:countTokens", model) - - reqBody := map[string]any{ - "contents": []any{ - map[string]any{ - "parts": []any{ - map[string]string{"text": text}, - }, - }, - }, - } - - jsonBody, err := json.Marshal(reqBody) - if err != nil { - return 0, err - } - - req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonBody)) - if err != nil { - return 0, err - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("x-goog-api-key", apiKey) - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return 0, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return 0, fmt.Errorf("API error %d: %s", resp.StatusCode, string(body)) - } - - var result struct { - TotalTokens int `json:"totalTokens"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return 0, err - } - - return result.TotalTokens, nil -} diff --git a/pkg/ratelimit/ratelimit_test.go b/pkg/ratelimit/ratelimit_test.go deleted file mode 100644 index 1247960..0000000 --- a/pkg/ratelimit/ratelimit_test.go +++ /dev/null @@ -1,176 +0,0 @@ -package ratelimit - -import ( - "context" - "path/filepath" - "testing" - "time" -) - -func TestCanSend_Good(t *testing.T) { - rl, _ := New() - rl.filePath = filepath.Join(t.TempDir(), "ratelimits.yaml") - - model := "test-model" - rl.Quotas[model] = ModelQuota{MaxRPM: 10, MaxTPM: 1000, MaxRPD: 100} - - if !rl.CanSend(model, 100) { - t.Errorf("Expected CanSend to return true for fresh state") - } -} - -func TestCanSend_RPMExceeded_Bad(t *testing.T) { - rl, _ := New() - model := "test-rpm" - rl.Quotas[model] = ModelQuota{MaxRPM: 2, MaxTPM: 1000000, MaxRPD: 100} - - rl.RecordUsage(model, 10, 10) - rl.RecordUsage(model, 10, 10) - - if rl.CanSend(model, 10) { - t.Errorf("Expected CanSend to return false after exceeding RPM") - } -} - -func TestCanSend_TPMExceeded_Bad(t *testing.T) { - rl, _ := New() - model := "test-tpm" - rl.Quotas[model] = ModelQuota{MaxRPM: 10, MaxTPM: 100, MaxRPD: 100} - - rl.RecordUsage(model, 50, 40) // 90 tokens used - - if rl.CanSend(model, 20) { // 90 + 20 = 110 > 100 - t.Errorf("Expected CanSend to return false when estimated tokens exceed TPM") - } -} - -func TestCanSend_RPDExceeded_Bad(t *testing.T) { - rl, _ := New() - model := "test-rpd" - rl.Quotas[model] = ModelQuota{MaxRPM: 10, MaxTPM: 1000000, MaxRPD: 2} - - rl.RecordUsage(model, 10, 10) - rl.RecordUsage(model, 10, 10) - - if rl.CanSend(model, 10) { - t.Errorf("Expected CanSend to return false after exceeding RPD") - } -} - -func TestCanSend_UnlimitedModel_Good(t *testing.T) { - rl, _ := New() - model := "test-unlimited" - rl.Quotas[model] = ModelQuota{MaxRPM: 0, MaxTPM: 0, MaxRPD: 0} - - // Should always be allowed - for i := 0; i < 1000; i++ { - rl.RecordUsage(model, 100, 100) - } - if !rl.CanSend(model, 999999) { - t.Errorf("Expected unlimited model to always allow sends") - } -} - -func TestRecordUsage_PrunesOldEntries_Good(t *testing.T) { - rl, _ := New() - model := "test-prune" - rl.Quotas[model] = ModelQuota{MaxRPM: 5, MaxTPM: 1000000, MaxRPD: 100} - - // Manually inject old data - oldTime := time.Now().Add(-2 * time.Minute) - rl.State[model] = &UsageStats{ - Requests: []time.Time{oldTime, oldTime, oldTime}, - Tokens: []TokenEntry{ - {Time: oldTime, Count: 100}, - {Time: oldTime, Count: 100}, - }, - DayStart: time.Now(), - } - - // CanSend triggers prune - if !rl.CanSend(model, 10) { - t.Errorf("Expected CanSend to return true after pruning old entries") - } - - stats := rl.State[model] - if len(stats.Requests) != 0 { - t.Errorf("Expected 0 requests after pruning old entries, got %d", len(stats.Requests)) - } -} - -func TestPersistAndLoad_Good(t *testing.T) { - tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "ratelimits.yaml") - - rl1, _ := New() - rl1.filePath = path - model := "persist-test" - rl1.Quotas[model] = ModelQuota{MaxRPM: 50, MaxTPM: 5000, MaxRPD: 500} - rl1.RecordUsage(model, 100, 100) - - if err := rl1.Persist(); err != nil { - t.Fatalf("Persist failed: %v", err) - } - - rl2, _ := New() - rl2.filePath = path - if err := rl2.Load(); err != nil { - t.Fatalf("Load failed: %v", err) - } - - stats := rl2.Stats(model) - if stats.RPM != 1 { - t.Errorf("Expected RPM 1 after load, got %d", stats.RPM) - } - if stats.TPM != 200 { - t.Errorf("Expected TPM 200 after load, got %d", stats.TPM) - } -} - -func TestWaitForCapacity_Ugly(t *testing.T) { - rl, _ := New() - model := "wait-test" - rl.Quotas[model] = ModelQuota{MaxRPM: 1, MaxTPM: 1000000, MaxRPD: 100} - - rl.RecordUsage(model, 10, 10) // Use up the 1 RPM - - ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) - defer cancel() - - err := rl.WaitForCapacity(ctx, model, 10) - if err != context.DeadlineExceeded { - t.Errorf("Expected DeadlineExceeded, got %v", err) - } -} - -func TestDefaultQuotas_Good(t *testing.T) { - rl, _ := New() - expected := []string{ - "gemini-3-pro-preview", - "gemini-3-flash-preview", - "gemini-2.0-flash", - } - for _, m := range expected { - if _, ok := rl.Quotas[m]; !ok { - t.Errorf("Expected default quota for %s", m) - } - } -} - -func TestAllStats_Good(t *testing.T) { - rl, _ := New() - rl.RecordUsage("gemini-3-pro-preview", 1000, 500) - - all := rl.AllStats() - if len(all) < 5 { - t.Errorf("Expected at least 5 models in AllStats, got %d", len(all)) - } - - pro := all["gemini-3-pro-preview"] - if pro.RPM != 1 { - t.Errorf("Expected RPM 1 for pro, got %d", pro.RPM) - } - if pro.TPM != 1500 { - t.Errorf("Expected TPM 1500 for pro, got %d", pro.TPM) - } -} diff --git a/pkg/repos/registry.go b/pkg/repos/registry.go deleted file mode 100644 index e10e2ba..0000000 --- a/pkg/repos/registry.go +++ /dev/null @@ -1,342 +0,0 @@ -// Package repos provides functionality for managing multi-repo workspaces. -// It reads a repos.yaml registry file that defines repositories, their types, -// dependencies, and metadata. -package repos - -import ( - "errors" - "fmt" - "os" - "path/filepath" - "strings" - - "forge.lthn.ai/core/go-io" - "gopkg.in/yaml.v3" -) - -// Registry represents a collection of repositories defined in repos.yaml. -type Registry struct { - Version int `yaml:"version"` - Org string `yaml:"org"` - BasePath string `yaml:"base_path"` - Repos map[string]*Repo `yaml:"repos"` - Defaults RegistryDefaults `yaml:"defaults"` - medium io.Medium `yaml:"-"` -} - -// RegistryDefaults contains default values applied to all repos. -type RegistryDefaults struct { - CI string `yaml:"ci"` - License string `yaml:"license"` - Branch string `yaml:"branch"` -} - -// RepoType indicates the role of a repository in the ecosystem. -type RepoType string - -// Repository type constants for ecosystem classification. -const ( - // RepoTypeFoundation indicates core foundation packages. - RepoTypeFoundation RepoType = "foundation" - // RepoTypeModule indicates reusable module packages. - RepoTypeModule RepoType = "module" - // RepoTypeProduct indicates end-user product applications. - RepoTypeProduct RepoType = "product" - // RepoTypeTemplate indicates starter templates. - RepoTypeTemplate RepoType = "template" -) - -// Repo represents a single repository in the registry. -type Repo struct { - Name string `yaml:"-"` // Set from map key - Type string `yaml:"type"` - DependsOn []string `yaml:"depends_on"` - Description string `yaml:"description"` - Docs bool `yaml:"docs"` - CI string `yaml:"ci"` - Domain string `yaml:"domain,omitempty"` - Clone *bool `yaml:"clone,omitempty"` // nil = true, false = skip cloning - - // Computed fields - Path string `yaml:"path,omitempty"` // Full path to repo directory (optional, defaults to base_path/name) - registry *Registry `yaml:"-"` -} - -// LoadRegistry reads and parses a repos.yaml file from the given medium. -// The path should be a valid path for the provided medium. -func LoadRegistry(m io.Medium, path string) (*Registry, error) { - content, err := m.Read(path) - if err != nil { - return nil, fmt.Errorf("failed to read registry file: %w", err) - } - data := []byte(content) - - var reg Registry - if err := yaml.Unmarshal(data, ®); err != nil { - return nil, fmt.Errorf("failed to parse registry file: %w", err) - } - - reg.medium = m - - // Expand base path - reg.BasePath = expandPath(reg.BasePath) - - // Set computed fields on each repo - for name, repo := range reg.Repos { - repo.Name = name - if repo.Path == "" { - repo.Path = filepath.Join(reg.BasePath, name) - } else { - repo.Path = expandPath(repo.Path) - } - repo.registry = ® - - // Apply defaults if not set - if repo.CI == "" { - repo.CI = reg.Defaults.CI - } - } - - return ®, nil -} - -// FindRegistry searches for repos.yaml in common locations. -// It checks: current directory, parent directories, and home directory. -// This function is primarily intended for use with io.Local or other local-like filesystems. -func FindRegistry(m io.Medium) (string, error) { - // Check current directory and parents - dir, err := os.Getwd() - if err != nil { - return "", err - } - - for { - // Check repos.yaml (existing) - candidate := filepath.Join(dir, "repos.yaml") - if m.Exists(candidate) { - return candidate, nil - } - // Check .core/repos.yaml (new) - candidate = filepath.Join(dir, ".core", "repos.yaml") - if m.Exists(candidate) { - return candidate, nil - } - - parent := filepath.Dir(dir) - if parent == dir { - break - } - dir = parent - } - - // Check home directory common locations - home, err := os.UserHomeDir() - if err != nil { - return "", err - } - - commonPaths := []string{ - filepath.Join(home, "Code", "host-uk", ".core", "repos.yaml"), - filepath.Join(home, "Code", "host-uk", "repos.yaml"), - filepath.Join(home, ".config", "core", "repos.yaml"), - } - - for _, p := range commonPaths { - if m.Exists(p) { - return p, nil - } - } - - return "", errors.New("repos.yaml not found") -} - -// ScanDirectory creates a Registry by scanning a directory for git repos. -// This is used as a fallback when no repos.yaml is found. -// The dir should be a valid path for the provided medium. -func ScanDirectory(m io.Medium, dir string) (*Registry, error) { - entries, err := m.List(dir) - if err != nil { - return nil, fmt.Errorf("failed to read directory: %w", err) - } - - reg := &Registry{ - Version: 1, - BasePath: dir, - Repos: make(map[string]*Repo), - medium: m, - } - - // Try to detect org from git remote - for _, entry := range entries { - if !entry.IsDir() { - continue - } - - repoPath := filepath.Join(dir, entry.Name()) - gitPath := filepath.Join(repoPath, ".git") - - if !m.IsDir(gitPath) { - continue // Not a git repo - } - - repo := &Repo{ - Name: entry.Name(), - Path: repoPath, - Type: "module", // Default type - registry: reg, - } - - reg.Repos[entry.Name()] = repo - - // Try to detect org from first repo's remote - if reg.Org == "" { - reg.Org = detectOrg(m, repoPath) - } - } - - return reg, nil -} - -// detectOrg tries to extract the GitHub org from a repo's origin remote. -func detectOrg(m io.Medium, repoPath string) string { - // Try to read git remote - configPath := filepath.Join(repoPath, ".git", "config") - content, err := m.Read(configPath) - if err != nil { - return "" - } - // Look for patterns like github.com:org/repo or github.com/org/repo - for _, line := range strings.Split(content, "\n") { - line = strings.TrimSpace(line) - if !strings.HasPrefix(line, "url = ") { - continue - } - url := strings.TrimPrefix(line, "url = ") - - // git@github.com:org/repo.git - if strings.Contains(url, "github.com:") { - parts := strings.Split(url, ":") - if len(parts) >= 2 { - orgRepo := strings.TrimSuffix(parts[1], ".git") - orgParts := strings.Split(orgRepo, "/") - if len(orgParts) >= 1 { - return orgParts[0] - } - } - } - - // https://github.com/org/repo.git - if strings.Contains(url, "github.com/") { - parts := strings.Split(url, "github.com/") - if len(parts) >= 2 { - orgRepo := strings.TrimSuffix(parts[1], ".git") - orgParts := strings.Split(orgRepo, "/") - if len(orgParts) >= 1 { - return orgParts[0] - } - } - } - } - - return "" -} - -// List returns all repos in the registry. -func (r *Registry) List() []*Repo { - repos := make([]*Repo, 0, len(r.Repos)) - for _, repo := range r.Repos { - - repos = append(repos, repo) - } - return repos -} - -// Get returns a repo by name. -func (r *Registry) Get(name string) (*Repo, bool) { - repo, ok := r.Repos[name] - return repo, ok -} - -// ByType returns repos filtered by type. -func (r *Registry) ByType(t string) []*Repo { - var repos []*Repo - for _, repo := range r.Repos { - if repo.Type == t { - repos = append(repos, repo) - } - } - return repos -} - -// TopologicalOrder returns repos sorted by dependency order. -// Foundation repos come first, then modules, then products. -func (r *Registry) TopologicalOrder() ([]*Repo, error) { - // Build dependency graph - visited := make(map[string]bool) - visiting := make(map[string]bool) - var result []*Repo - - var visit func(name string) error - visit = func(name string) error { - if visited[name] { - return nil - } - if visiting[name] { - return fmt.Errorf("circular dependency detected: %s", name) - } - - repo, ok := r.Repos[name] - if !ok { - return fmt.Errorf("unknown repo: %s", name) - } - - visiting[name] = true - for _, dep := range repo.DependsOn { - if err := visit(dep); err != nil { - return err - } - } - visiting[name] = false - visited[name] = true - result = append(result, repo) - return nil - } - - for name := range r.Repos { - if err := visit(name); err != nil { - return nil, err - } - } - - return result, nil -} - -// Exists checks if the repo directory exists on disk. -func (repo *Repo) Exists() bool { - return repo.getMedium().IsDir(repo.Path) -} - -// IsGitRepo checks if the repo directory contains a .git folder. -func (repo *Repo) IsGitRepo() bool { - gitPath := filepath.Join(repo.Path, ".git") - return repo.getMedium().IsDir(gitPath) -} - -func (repo *Repo) getMedium() io.Medium { - if repo.registry != nil && repo.registry.medium != nil { - return repo.registry.medium - } - return io.Local -} - -// expandPath expands ~ to home directory. -func expandPath(path string) string { - if strings.HasPrefix(path, "~/") { - home, err := os.UserHomeDir() - if err != nil { - return path - } - return filepath.Join(home, path[2:]) - } - return path -} diff --git a/pkg/repos/registry_test.go b/pkg/repos/registry_test.go deleted file mode 100644 index 264dc9d..0000000 --- a/pkg/repos/registry_test.go +++ /dev/null @@ -1,486 +0,0 @@ -package repos - -import ( - "testing" - - "forge.lthn.ai/core/go-io" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// ── LoadRegistry ─────────────────────────────────────────────────── - -func TestLoadRegistry_Good(t *testing.T) { - m := io.NewMockMedium() - yaml := ` -version: 1 -org: host-uk -base_path: /tmp/repos -repos: - core: - type: foundation - description: Core package -` - _ = m.Write("/tmp/repos.yaml", yaml) - - reg, err := LoadRegistry(m, "/tmp/repos.yaml") - assert.NoError(t, err) - assert.NotNil(t, reg) - assert.Equal(t, "host-uk", reg.Org) - assert.Equal(t, "/tmp/repos", reg.BasePath) - assert.Equal(t, m, reg.medium) - - repo, ok := reg.Get("core") - assert.True(t, ok) - assert.Equal(t, "core", repo.Name) - assert.Equal(t, "/tmp/repos/core", repo.Path) - assert.Equal(t, reg, repo.registry) -} - -func TestLoadRegistry_Good_WithDefaults(t *testing.T) { - m := io.NewMockMedium() - yaml := ` -version: 1 -org: host-uk -base_path: /tmp/repos -defaults: - ci: github-actions - license: EUPL-1.2 - branch: main -repos: - core-php: - type: foundation - description: Foundation - core-admin: - type: module - description: Admin panel -` - _ = m.Write("/tmp/repos.yaml", yaml) - - reg, err := LoadRegistry(m, "/tmp/repos.yaml") - require.NoError(t, err) - - php, ok := reg.Get("core-php") - require.True(t, ok) - assert.Equal(t, "github-actions", php.CI) - - admin, ok := reg.Get("core-admin") - require.True(t, ok) - assert.Equal(t, "github-actions", admin.CI) -} - -func TestLoadRegistry_Good_CustomRepoPath(t *testing.T) { - m := io.NewMockMedium() - yaml := ` -version: 1 -org: host-uk -base_path: /tmp/repos -repos: - special: - type: module - path: /opt/special-repo -` - _ = m.Write("/tmp/repos.yaml", yaml) - - reg, err := LoadRegistry(m, "/tmp/repos.yaml") - require.NoError(t, err) - - repo, ok := reg.Get("special") - require.True(t, ok) - assert.Equal(t, "/opt/special-repo", repo.Path) -} - -func TestLoadRegistry_Good_CIOverride(t *testing.T) { - m := io.NewMockMedium() - yaml := ` -version: 1 -org: test -base_path: /tmp -defaults: - ci: default-ci -repos: - a: - type: module - b: - type: module - ci: custom-ci -` - _ = m.Write("/tmp/repos.yaml", yaml) - - reg, err := LoadRegistry(m, "/tmp/repos.yaml") - require.NoError(t, err) - - a, _ := reg.Get("a") - assert.Equal(t, "default-ci", a.CI) - - b, _ := reg.Get("b") - assert.Equal(t, "custom-ci", b.CI) -} - -func TestLoadRegistry_Bad_FileNotFound(t *testing.T) { - m := io.NewMockMedium() - _, err := LoadRegistry(m, "/nonexistent/repos.yaml") - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to read") -} - -func TestLoadRegistry_Bad_InvalidYAML(t *testing.T) { - m := io.NewMockMedium() - _ = m.Write("/tmp/bad.yaml", "{{{{not yaml at all") - - _, err := LoadRegistry(m, "/tmp/bad.yaml") - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to parse") -} - -// ── List / Get / ByType ──────────────────────────────────────────── - -func newTestRegistry(t *testing.T) *Registry { - t.Helper() - m := io.NewMockMedium() - yaml := ` -version: 1 -org: host-uk -base_path: /tmp/repos -repos: - core-php: - type: foundation - description: Foundation - core-admin: - type: module - depends_on: [core-php] - description: Admin - core-tenant: - type: module - depends_on: [core-php] - description: Tenancy - core-bio: - type: product - depends_on: [core-php, core-tenant] - description: Bio product -` - _ = m.Write("/tmp/repos.yaml", yaml) - reg, err := LoadRegistry(m, "/tmp/repos.yaml") - require.NoError(t, err) - return reg -} - -func TestRegistry_List_Good(t *testing.T) { - reg := newTestRegistry(t) - repos := reg.List() - assert.Len(t, repos, 4) -} - -func TestRegistry_Get_Good(t *testing.T) { - reg := newTestRegistry(t) - repo, ok := reg.Get("core-php") - assert.True(t, ok) - assert.Equal(t, "core-php", repo.Name) -} - -func TestRegistry_Get_Bad_NotFound(t *testing.T) { - reg := newTestRegistry(t) - _, ok := reg.Get("nonexistent") - assert.False(t, ok) -} - -func TestRegistry_ByType_Good(t *testing.T) { - reg := newTestRegistry(t) - - foundations := reg.ByType("foundation") - assert.Len(t, foundations, 1) - assert.Equal(t, "core-php", foundations[0].Name) - - modules := reg.ByType("module") - assert.Len(t, modules, 2) - - products := reg.ByType("product") - assert.Len(t, products, 1) -} - -func TestRegistry_ByType_Good_NoMatch(t *testing.T) { - reg := newTestRegistry(t) - templates := reg.ByType("template") - assert.Empty(t, templates) -} - -// ── TopologicalOrder ─────────────────────────────────────────────── - -func TestTopologicalOrder_Good(t *testing.T) { - reg := newTestRegistry(t) - order, err := TopologicalOrder(reg) - require.NoError(t, err) - assert.Len(t, order, 4) - - // core-php must come before everything that depends on it. - phpIdx := -1 - for i, r := range order { - if r.Name == "core-php" { - phpIdx = i - break - } - } - require.GreaterOrEqual(t, phpIdx, 0, "core-php not found") - - for i, r := range order { - for _, dep := range r.DependsOn { - depIdx := -1 - for j, d := range order { - if d.Name == dep { - depIdx = j - break - } - } - assert.Less(t, depIdx, i, "%s should come before %s", dep, r.Name) - } - } -} - -func TopologicalOrder(reg *Registry) ([]*Repo, error) { - return reg.TopologicalOrder() -} - -func TestTopologicalOrder_Bad_CircularDep(t *testing.T) { - m := io.NewMockMedium() - yaml := ` -version: 1 -org: test -base_path: /tmp -repos: - a: - type: module - depends_on: [b] - b: - type: module - depends_on: [a] -` - _ = m.Write("/tmp/repos.yaml", yaml) - reg, err := LoadRegistry(m, "/tmp/repos.yaml") - require.NoError(t, err) - - _, err = reg.TopologicalOrder() - assert.Error(t, err) - assert.Contains(t, err.Error(), "circular dependency") -} - -func TestTopologicalOrder_Bad_UnknownDep(t *testing.T) { - m := io.NewMockMedium() - yaml := ` -version: 1 -org: test -base_path: /tmp -repos: - a: - type: module - depends_on: [nonexistent] -` - _ = m.Write("/tmp/repos.yaml", yaml) - reg, err := LoadRegistry(m, "/tmp/repos.yaml") - require.NoError(t, err) - - _, err = reg.TopologicalOrder() - assert.Error(t, err) - assert.Contains(t, err.Error(), "unknown repo") -} - -func TestTopologicalOrder_Good_NoDeps(t *testing.T) { - m := io.NewMockMedium() - yaml := ` -version: 1 -org: test -base_path: /tmp -repos: - a: - type: module - b: - type: module -` - _ = m.Write("/tmp/repos.yaml", yaml) - reg, err := LoadRegistry(m, "/tmp/repos.yaml") - require.NoError(t, err) - - order, err := reg.TopologicalOrder() - require.NoError(t, err) - assert.Len(t, order, 2) -} - -// ── ScanDirectory ────────────────────────────────────────────────── - -func TestScanDirectory_Good(t *testing.T) { - m := io.NewMockMedium() - - // Create mock repos with .git dirs. - _ = m.EnsureDir("/workspace/repo-a/.git") - _ = m.EnsureDir("/workspace/repo-b/.git") - _ = m.EnsureDir("/workspace/not-a-repo") // No .git - - // Write a file (not a dir) at top level. - _ = m.Write("/workspace/README.md", "hello") - - reg, err := ScanDirectory(m, "/workspace") - require.NoError(t, err) - - assert.Len(t, reg.Repos, 2) - - a, ok := reg.Repos["repo-a"] - assert.True(t, ok) - assert.Equal(t, "/workspace/repo-a", a.Path) - assert.Equal(t, "module", a.Type) // Default type. - - _, ok = reg.Repos["not-a-repo"] - assert.False(t, ok) -} - -func TestScanDirectory_Good_DetectsGitHubOrg(t *testing.T) { - m := io.NewMockMedium() - - _ = m.EnsureDir("/workspace/my-repo/.git") - _ = m.Write("/workspace/my-repo/.git/config", `[core] - repositoryformatversion = 0 -[remote "origin"] - url = git@github.com:host-uk/my-repo.git - fetch = +refs/heads/*:refs/remotes/origin/* -`) - - reg, err := ScanDirectory(m, "/workspace") - require.NoError(t, err) - assert.Equal(t, "host-uk", reg.Org) -} - -func TestScanDirectory_Good_DetectsHTTPSOrg(t *testing.T) { - m := io.NewMockMedium() - - _ = m.EnsureDir("/workspace/my-repo/.git") - _ = m.Write("/workspace/my-repo/.git/config", `[remote "origin"] - url = https://github.com/lethean-io/my-repo.git -`) - - reg, err := ScanDirectory(m, "/workspace") - require.NoError(t, err) - assert.Equal(t, "lethean-io", reg.Org) -} - -func TestScanDirectory_Good_EmptyDir(t *testing.T) { - m := io.NewMockMedium() - _ = m.EnsureDir("/empty") - - reg, err := ScanDirectory(m, "/empty") - require.NoError(t, err) - assert.Empty(t, reg.Repos) - assert.Equal(t, "", reg.Org) -} - -func TestScanDirectory_Bad_InvalidDir(t *testing.T) { - m := io.NewMockMedium() - _, err := ScanDirectory(m, "/nonexistent") - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to read directory") -} - -// ── detectOrg ────────────────────────────────────────────────────── - -func TestDetectOrg_Good_SSHRemote(t *testing.T) { - m := io.NewMockMedium() - _ = m.Write("/repo/.git/config", `[remote "origin"] - url = git@github.com:host-uk/core.git -`) - assert.Equal(t, "host-uk", detectOrg(m, "/repo")) -} - -func TestDetectOrg_Good_HTTPSRemote(t *testing.T) { - m := io.NewMockMedium() - _ = m.Write("/repo/.git/config", `[remote "origin"] - url = https://github.com/snider/project.git -`) - assert.Equal(t, "snider", detectOrg(m, "/repo")) -} - -func TestDetectOrg_Bad_NoConfig(t *testing.T) { - m := io.NewMockMedium() - assert.Equal(t, "", detectOrg(m, "/nonexistent")) -} - -func TestDetectOrg_Bad_NoRemote(t *testing.T) { - m := io.NewMockMedium() - _ = m.Write("/repo/.git/config", `[core] - repositoryformatversion = 0 -`) - assert.Equal(t, "", detectOrg(m, "/repo")) -} - -func TestDetectOrg_Bad_NonGitHubRemote(t *testing.T) { - m := io.NewMockMedium() - _ = m.Write("/repo/.git/config", `[remote "origin"] - url = ssh://git@forge.lthn.ai:2223/core/go.git -`) - assert.Equal(t, "", detectOrg(m, "/repo")) -} - -// ── expandPath ───────────────────────────────────────────────────── - -func TestExpandPath_Good_Tilde(t *testing.T) { - got := expandPath("~/Code/repos") - assert.NotContains(t, got, "~") - assert.Contains(t, got, "Code/repos") -} - -func TestExpandPath_Good_NoTilde(t *testing.T) { - assert.Equal(t, "/absolute/path", expandPath("/absolute/path")) - assert.Equal(t, "relative/path", expandPath("relative/path")) -} - -// ── Repo.Exists / IsGitRepo ─────────────────────────────────────── - -func TestRepo_Exists_Good(t *testing.T) { - m := io.NewMockMedium() - reg := &Registry{ - medium: m, - BasePath: "/tmp/repos", - Repos: make(map[string]*Repo), - } - repo := &Repo{ - Name: "core", - Path: "/tmp/repos/core", - registry: reg, - } - - assert.False(t, repo.Exists()) - - _ = m.EnsureDir("/tmp/repos/core") - assert.True(t, repo.Exists()) -} - -func TestRepo_IsGitRepo_Good(t *testing.T) { - m := io.NewMockMedium() - reg := &Registry{ - medium: m, - BasePath: "/tmp/repos", - Repos: make(map[string]*Repo), - } - repo := &Repo{ - Name: "core", - Path: "/tmp/repos/core", - registry: reg, - } - - assert.False(t, repo.IsGitRepo()) - - _ = m.EnsureDir("/tmp/repos/core/.git") - assert.True(t, repo.IsGitRepo()) -} - -// ── getMedium fallback ───────────────────────────────────────────── - -func TestGetMedium_Good_FallbackToLocal(t *testing.T) { - repo := &Repo{Name: "orphan", Path: "/tmp/orphan"} - // No registry set — should fall back to io.Local. - m := repo.getMedium() - assert.Equal(t, io.Local, m) -} - -func TestGetMedium_Good_NilMediumFallback(t *testing.T) { - reg := &Registry{} // medium is nil. - repo := &Repo{Name: "test", registry: reg} - m := repo.getMedium() - assert.Equal(t, io.Local, m) -} diff --git a/pkg/session/html.go b/pkg/session/html.go deleted file mode 100644 index e666ef0..0000000 --- a/pkg/session/html.go +++ /dev/null @@ -1,257 +0,0 @@ -package session - -import ( - "fmt" - "html" - "os" - "strings" - "time" -) - -// RenderHTML generates a self-contained HTML timeline from a session. -func RenderHTML(sess *Session, outputPath string) error { - f, err := os.Create(outputPath) - if err != nil { - return fmt.Errorf("create html: %w", err) - } - defer f.Close() - - duration := sess.EndTime.Sub(sess.StartTime) - toolCount := 0 - errorCount := 0 - for _, e := range sess.Events { - if e.Type == "tool_use" { - toolCount++ - if !e.Success { - errorCount++ - } - } - } - - fmt.Fprintf(f, ` - - - - -Session %s - - - -
-

Session %s

-
-
- %s - Duration: %s - %d tool calls`, - shortID(sess.ID), shortID(sess.ID), - sess.StartTime.Format("2006-01-02 15:04:05"), - formatDuration(duration), - toolCount) - - if errorCount > 0 { - fmt.Fprintf(f, ` - %d errors`, errorCount) - } - - fmt.Fprintf(f, ` -
-
- -
-
-`) - - for i, evt := range sess.Events { - toolClass := strings.ToLower(evt.Tool) - if evt.Type == "user" { - toolClass = "user" - } else if evt.Type == "assistant" { - toolClass = "assistant" - } - - errorClass := "" - if !evt.Success && evt.Type == "tool_use" { - errorClass = " error" - } - - statusIcon := "" - if evt.Type == "tool_use" { - if evt.Success { - statusIcon = `` - } else { - statusIcon = `` - } - } - - toolLabel := evt.Tool - if evt.Type == "user" { - toolLabel = "User" - } else if evt.Type == "assistant" { - toolLabel = "Claude" - } - - durStr := "" - if evt.Duration > 0 { - durStr = formatDuration(evt.Duration) - } - - fmt.Fprintf(f, `
-
- - %s - %s - %s - %s - %s -
-
-`, - errorClass, - evt.Type, - evt.Tool, - html.EscapeString(strings.ToLower(evt.Input+" "+evt.Output)), - i, - i, - evt.Timestamp.Format("15:04:05"), - toolClass, - html.EscapeString(toolLabel), - html.EscapeString(truncate(evt.Input, 120)), - durStr, - statusIcon) - - if evt.Input != "" { - label := "Command" - if evt.Type == "user" { - label = "Message" - } else if evt.Type == "assistant" { - label = "Response" - } else if evt.Tool == "Read" || evt.Tool == "Glob" || evt.Tool == "Grep" { - label = "Target" - } else if evt.Tool == "Edit" || evt.Tool == "Write" { - label = "File" - } - fmt.Fprintf(f, `
%s
%s
-`, label, html.EscapeString(evt.Input)) - } - - if evt.Output != "" { - outClass := "output" - if !evt.Success { - outClass = "output err" - } - fmt.Fprintf(f, `
Output
%s
-`, outClass, html.EscapeString(evt.Output)) - } - - fmt.Fprint(f, `
-
-`) - } - - fmt.Fprint(f, `
- - - -`) - - return nil -} - -func shortID(id string) string { - if len(id) > 8 { - return id[:8] - } - return id -} - -func formatDuration(d time.Duration) string { - if d < time.Second { - return fmt.Sprintf("%dms", d.Milliseconds()) - } - if d < time.Minute { - return fmt.Sprintf("%.1fs", d.Seconds()) - } - if d < time.Hour { - return fmt.Sprintf("%dm%ds", int(d.Minutes()), int(d.Seconds())%60) - } - return fmt.Sprintf("%dh%dm", int(d.Hours()), int(d.Minutes())%60) -} diff --git a/pkg/session/html_test.go b/pkg/session/html_test.go deleted file mode 100644 index 9b0a98d..0000000 --- a/pkg/session/html_test.go +++ /dev/null @@ -1,194 +0,0 @@ -package session - -import ( - "os" - "path/filepath" - "strings" - "testing" - "time" -) - -func TestRenderHTML_Good_BasicSession(t *testing.T) { - dir := t.TempDir() - out := filepath.Join(dir, "session.html") - - sess := &Session{ - ID: "f3fb074c-8c72-4da6-a15a-85bae652ccaa", - StartTime: time.Date(2026, 2, 24, 10, 0, 0, 0, time.UTC), - EndTime: time.Date(2026, 2, 24, 10, 5, 0, 0, time.UTC), - Events: []Event{ - { - Timestamp: time.Date(2026, 2, 24, 10, 0, 5, 0, time.UTC), - Type: "tool_use", - Tool: "Bash", - Input: "go test ./...", - Output: "ok forge.lthn.ai/core/go 1.2s", - Duration: time.Second, - Success: true, - }, - { - Timestamp: time.Date(2026, 2, 24, 10, 1, 0, 0, time.UTC), - Type: "tool_use", - Tool: "Read", - Input: "/tmp/test.go", - Output: "package main", - Duration: 200 * time.Millisecond, - Success: true, - }, - { - Timestamp: time.Date(2026, 2, 24, 10, 2, 0, 0, time.UTC), - Type: "user", - Input: "looks good", - }, - }, - } - - if err := RenderHTML(sess, out); err != nil { - t.Fatal(err) - } - - data, err := os.ReadFile(out) - if err != nil { - t.Fatal(err) - } - - html := string(data) - if !strings.Contains(html, "f3fb074c") { - t.Fatal("missing session ID") - } - if !strings.Contains(html, "go test ./...") { - t.Fatal("missing bash command") - } - if !strings.Contains(html, "2 tool calls") { - t.Fatal("missing tool count") - } - if !strings.Contains(html, "filterEvents") { - t.Fatal("missing JS filter function") - } -} - -func TestRenderHTML_Good_WithErrors(t *testing.T) { - dir := t.TempDir() - out := filepath.Join(dir, "errors.html") - - sess := &Session{ - ID: "err-session", - StartTime: time.Date(2026, 2, 24, 10, 0, 0, 0, time.UTC), - EndTime: time.Date(2026, 2, 24, 10, 1, 0, 0, time.UTC), - Events: []Event{ - { - Type: "tool_use", Tool: "Bash", - Timestamp: time.Date(2026, 2, 24, 10, 0, 0, 0, time.UTC), - Input: "bad command", Output: "error", Success: false, - }, - }, - } - - if err := RenderHTML(sess, out); err != nil { - t.Fatal(err) - } - - data, _ := os.ReadFile(out) - html := string(data) - if !strings.Contains(html, "1 errors") { - t.Fatal("missing error count") - } - if !strings.Contains(html, `class="event error"`) { - t.Fatal("missing error class") - } - if !strings.Contains(html, "✗") { - t.Fatal("missing failure icon") - } -} - -func TestRenderHTML_Good_AssistantEvent(t *testing.T) { - dir := t.TempDir() - out := filepath.Join(dir, "asst.html") - - sess := &Session{ - ID: "asst-test", - StartTime: time.Date(2026, 2, 24, 10, 0, 0, 0, time.UTC), - EndTime: time.Date(2026, 2, 24, 10, 0, 5, 0, time.UTC), - Events: []Event{ - { - Type: "assistant", - Timestamp: time.Date(2026, 2, 24, 10, 0, 0, 0, time.UTC), - Input: "Let me check that.", - }, - }, - } - - if err := RenderHTML(sess, out); err != nil { - t.Fatal(err) - } - - data, _ := os.ReadFile(out) - if !strings.Contains(string(data), "Claude") { - t.Fatal("missing Claude label for assistant") - } -} - -func TestRenderHTML_Good_EmptySession(t *testing.T) { - dir := t.TempDir() - out := filepath.Join(dir, "empty.html") - - sess := &Session{ - ID: "empty", - StartTime: time.Now(), - EndTime: time.Now(), - } - - if err := RenderHTML(sess, out); err != nil { - t.Fatal(err) - } - - info, err := os.Stat(out) - if err != nil { - t.Fatal(err) - } - if info.Size() == 0 { - t.Fatal("HTML file is empty") - } -} - -func TestRenderHTML_Bad_InvalidPath(t *testing.T) { - sess := &Session{ID: "test", StartTime: time.Now(), EndTime: time.Now()} - err := RenderHTML(sess, "/nonexistent/dir/out.html") - if err == nil { - t.Fatal("expected error for invalid path") - } -} - -func TestRenderHTML_Good_XSSEscaping(t *testing.T) { - dir := t.TempDir() - out := filepath.Join(dir, "xss.html") - - sess := &Session{ - ID: "xss-test", - StartTime: time.Now(), - EndTime: time.Now(), - Events: []Event{ - { - Type: "tool_use", - Tool: "Bash", - Timestamp: time.Now(), - Input: `echo ""`, - Output: ``, - Success: true, - }, - }, - } - - if err := RenderHTML(sess, out); err != nil { - t.Fatal(err) - } - - data, _ := os.ReadFile(out) - html := string(data) - if strings.Contains(html, "