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(`Run `)
- 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(`%s `, 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(`%.1f `,
- 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(` %d/%d `,
- color, cat, bc.correct, bc.total, bc.accuracy, icon, bc.correct, bc.total))
- } else {
- sb.WriteString(` `)
- }
- }
- sb.WriteString(` `)
- }
- sb.WriteString(`
`)
- 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}}
-
-
-
-
-
Gold Generation
- {{if .Training.GoldAvailable}}
-
{{pct .Training.GoldPercent}}%
-
-
{{.Training.GoldGenerated}} / {{.Training.GoldTarget}}
- {{else}}
-
Unavailable
-
M3 Ultra unreachable
- {{end}}
-
-
-
-{{if .Commits}}
-Recent Activity
-
-
- Repo Message Author Time
-
- {{range .Commits}}
-
- {{.Repo}}
- {{shortMsg .Message}}
- {{.Author}}
- {{timeAgo .Timestamp}}
-
- {{end}}
-
-
-
-{{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 .Dataset.Available}}
-
DuckDB Tables
-
-
- Table Rows Size
-
- {{$total := totalRows .Dataset.Tables}}
- {{range .Dataset.Tables}}
-
- {{.Name}}
- {{fmtInt .Rows}}
-
-
-
-
- {{end}}
-
-
-
-{{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
-
-
- Worker Generations
-
- {{range .GoldenSet.Workers}}
-
- {{.Worker}}
- {{fmtInt .Count}}
-
- {{end}}
-
-
-
-
-{{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
-
-
- Domain Count Avg Gen Time Coverage
-
- {{range .GoldenSet.DomainStats}}
-
- {{.Domain}}
- {{.Count}}
- {{pct .AvgGenTime}}s
-
-
-
-
- {{end}}
-
-
-
-
-{{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
-
-
- Voice Count Avg Chars Avg Gen Time
-
- {{range .GoldenSet.VoiceStats}}
-
- {{.Voice}}
- {{.Count}}
- {{pct .AvgChars}}
- {{pct .AvgGenTime}}s
-
- {{end}}
-
-
-
-
-{{else}}
-
-{{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:
-
- Format Command Use
-
-
- JSONL (MLX)
- lem export --format jsonl
- MLX LoRA training (train/valid/test splits)
-
-
- Parquet
- lem export --format parquet
- HuggingFace dataset upload
-
-
- CSV
- lem export --format csv
- Spreadsheet 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
-
-
- Worker Generations
-
- {{range .GoldenSet.Workers}}
-
- {{.Worker}}
- {{.Count}}
-
- {{end}}
-
-
-
-{{end}}
-
-{{if .GoldenSet.VoiceStats}}
-Voice Distribution
-
- {{voiceChart .GoldenSet.VoiceStats}}
-
-{{end}}
-
-{{if .GoldenSet.DomainStats}}
-Domain Breakdown (top 25)
-
- {{domainChart .GoldenSet.DomainStats}}
-
-
-All Domains
-
-
- Domain Count Avg Gen Time Coverage
-
- {{range .GoldenSet.DomainStats}}
-
- {{.Domain}}
- {{.Count}}
- {{pct .AvgGenTime}}s
-
-
-
-
- {{end}}
-
-
-
-{{end}}
-
-{{if .GoldenSet.VoiceStats}}
-Voice Details
-
-
- Voice Count Avg Chars Avg Gen Time
-
- {{range .GoldenSet.VoiceStats}}
-
- {{.Voice}}
- {{.Count}}
- {{pct .AvgChars}}
- {{pct .AvgGenTime}}s
-
- {{end}}
-
-
-
-{{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"}}
-
- LEM.Lab
-
-
-{{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}}
-
-
- Model Downloads Likes Pipeline Updated
-
- {{range .Models}}
-
- {{.ModelID}}
- {{.Downloads}}
- {{.Likes}}
- {{if .PipelineTag}}{{.PipelineTag}} {{else}}-{{end}}
- {{timeAgo .LastModified}}
-
- {{end}}
-
-
-
-{{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}}
-
-
-
-
- {{/* 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 -- */}}
-
-
-{{/* -- 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}}
-
-
-
-{{/* 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"}}
-
-{{end}}
-{{end}}
-{{end}}
-
-{{/* All benchmark runs for this model -- collect data for tabs */}}
-{{$runs := runsForModel $b $sel}}
-
-{{/* Tabbed charts */}}
-
- {{if anyContent $runs $b.Content}} Content {{end}}
- {{if anyCap $runs $b.Capability}} Capability {{end}}
- {{if anyCap $runs $b.Capability}} Categories {{end}}
- {{if anyLoss $runs $b.Loss}} 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
-
-
-
-
-
-`)
-
- 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, `
-
-
-`,
- 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, `
-`, label, html.EscapeString(evt.Input))
- }
-
- if evt.Output != "" {
- outClass := "output"
- if !evt.Success {
- outClass = "output err"
- }
- fmt.Fprintf(f, `
-`, 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, "