Compare commits

...

15 commits
v0.1.4 ... dev

Author SHA1 Message Date
Snider
309de8d719 fix: migrate module paths from forge.lthn.ai to dappco.re
Some checks failed
Security Scan / security (push) Has been cancelled
Test / test (push) Has been cancelled
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 16:21:14 +01:00
Virgil
1b0e197d71 feat(frontend): expose ide route
All checks were successful
Security Scan / security (push) Successful in 11s
Test / test (push) Successful in 6m56s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 09:54:40 +00:00
Virgil
7ab16075c4 feat(ide): add DuckDB brain recall cache
Some checks failed
Test / test (push) Successful in 4m43s
Security Scan / security (push) Failing after 13m58s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 19:11:01 +00:00
Virgil
efed2fc3ec feat(ide): add package marketplace tools
All checks were successful
Security Scan / security (push) Successful in 13s
Test / test (push) Successful in 6m35s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 19:02:37 +00:00
Virgil
9f97d256cf feat(ide): add workspace context tools
Some checks failed
Security Scan / security (push) Successful in 10s
Test / test (push) Has been cancelled
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 18:52:45 +00:00
Virgil
f361fd69f6 feat(runtime): align provider lifecycle with AX requirements
Some checks failed
Security Scan / security (push) Successful in 11s
Test / test (push) Failing after 1m52s
Improve provider discovery dedupe, startup/shutdown robustness, and health-check cancellation semantics.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-30 07:57:55 +00:00
Snider
0669feb69b merged
Some checks failed
Security Scan / security (push) Successful in 9s
Test / test (push) Failing after 2m0s
Security Scan / security (pull_request) Successful in 10s
Test / test (pull_request) Failing after 16m42s
2026-03-24 10:48:31 +00:00
Claude
16dcb1643d
chore: update dependencies to dappco.re tagged versions
Some checks failed
Security Scan / security (push) Successful in 10s
Test / test (push) Failing after 1m56s
Security Scan / security (pull_request) Successful in 13s
Test / test (pull_request) Failing after 2m5s
Migrate scm and log imports to dappco.re vanity paths:
- forge.lthn.ai/core/go-scm → dappco.re/go/core/scm v0.4.0
- forge.lthn.ai/core/go-log → dappco.re/go/core/log v0.1.0
- Pin dappco.re/go/core/io v0.2.0 (transitive dep)

api, core/go, go-ws, go-process kept as forge.lthn.ai — gui/mcp
are not yet migrated and require forge concrete types.

Also fix bare type assertion in findFreePort (comma-ok pattern).

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-22 01:43:01 +00:00
2059e8c955 Merge pull request 'feat(ide): migrate to Options{} API + CI workflow' (#3) from dev into main
Some checks failed
Security Scan / security (push) Successful in 13s
Test / test (push) Failing after 22s
2026-03-22 00:59:44 +00:00
Snider
daa8b1e477 Merge branch 'agent/fix-ide-main-go-to-compile-with-current' of /Users/snider/Code/.core/workspace/ide-1774137860574616000/src into dev
Some checks failed
Security Scan / security (push) Successful in 14s
Test / test (push) Failing after 20s
Security Scan / security (pull_request) Successful in 11s
Test / test (pull_request) Failing after 25s
# Conflicts:
#	go.mod
#	main.go
2026-03-22 00:42:16 +00:00
Snider
ff717ef516 fix(core): update main.go to compile with current Core and MCP APIs
Some checks failed
Security Scan / security (pull_request) Successful in 13s
Test / test (pull_request) Failing after 1m54s
Replace deprecated patterns with current API conventions:
- core.WithName/WithService → core.New() + RegisterService + RegisterAction
- mcp.WithWorkspaceRoot/WithWSHub/WithSubsystem → mcp.New(mcp.Options{})
- core.ServiceFor[T] → direct service reference
- Add cancellable context and ServiceShutdown to GUI mode
- Handle previously-discarded errors from api.New and config.New

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-22 00:40:33 +00:00
Snider
800b75ee90 Merge remote-tracking branch 'origin/main'
Some checks failed
Security Scan / security (push) Successful in 19s
Test / test (push) Failing after 52s
2026-03-22 00:29:29 +00:00
Claude
cc7504892f
chore: migrate to dappco.re vanity import path
Some checks failed
Security Scan / security (push) Successful in 10s
Test / test (push) Failing after 2m0s
Change module declaration from forge.lthn.ai/core/ide to
dappco.re/go/core/ide. Update the self-referencing icons import
accordingly. External dependency imports remain on forge.lthn.ai
paths until those sibling modules publish dappco.re-aware versions,
avoiding dual-path conflicts in the Go module graph.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 23:55:49 +00:00
98483cb7af Merge pull request '[agent/claude:opus] DX audit and fix. 1) Review CLAUDE.md — update any outdate...' (#1) from agent/dx-audit-and-fix--1--review-claude-md into main
Some checks failed
Security Scan / security (push) Successful in 13s
Test / test (push) Failing after 1m46s
2026-03-17 09:00:39 +00:00
Snider
e1024744a4 fix(ide): replace fmt.Errorf with coreerr.E(), add unit tests
Some checks failed
Security Scan / security (pull_request) Successful in 11s
Test / test (pull_request) Failing after 1m56s
Replace all fmt.Errorf calls in runtime.go with structured errors via
coreerr.E() from go-log, ensuring every error carries operation context
for structured logging and tracing.

Add unit tests for runtime utilities (findFreePort, waitForHealth,
defaultProvidersDir), RuntimeManager (List, StopAll, StartAll),
ProvidersAPI (Name, BasePath, list endpoint), guiEnabled, and
staticAssetGroup. Coverage: 27.1%.

No os.ReadFile/os.WriteFile violations found. CLAUDE.md reviewed —
no outdated commands.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-17 09:00:18 +00:00
18 changed files with 2361 additions and 143 deletions

467
brain_direct.go Normal file
View file

@ -0,0 +1,467 @@
// SPDX-License-Identifier: EUPL-1.2
package main
import (
"bytes"
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
coreerr "dappco.re/go/core/log"
coremcp "forge.lthn.ai/core/mcp/pkg/mcp"
brainpkg "forge.lthn.ai/core/mcp/pkg/mcp/brain"
_ "github.com/marcboeker/go-duckdb"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
const (
defaultBrainAPIURL = "https://api.lthn.sh"
defaultBrainCacheTTL = 5 * time.Minute
defaultBrainCacheDriver = "duckdb"
)
type (
RememberInput = brainpkg.RememberInput
RememberOutput = brainpkg.RememberOutput
RecallInput = brainpkg.RecallInput
RecallFilter = brainpkg.RecallFilter
RecallOutput = brainpkg.RecallOutput
Memory = brainpkg.Memory
ForgetInput = brainpkg.ForgetInput
ForgetOutput = brainpkg.ForgetOutput
)
// BrainDirectSubsystem implements the OpenBrain MCP tools over the direct HTTP API.
// brain_recall is cached locally in DuckDB to avoid repeated semantic searches.
type BrainDirectSubsystem struct {
workspaceRoot string
apiURL string
apiKey string
client *http.Client
cache *brainRecallCache
}
var _ coremcp.Subsystem = (*BrainDirectSubsystem)(nil)
var _ coremcp.SubsystemWithShutdown = (*BrainDirectSubsystem)(nil)
// NewCachedBrainDirect creates the direct OpenBrain subsystem with a DuckDB-backed
// cache for brain_recall responses.
func NewCachedBrainDirect(workspaceRoot string) (*BrainDirectSubsystem, error) {
apiURL := strings.TrimSpace(os.Getenv("CORE_BRAIN_URL"))
if apiURL == "" {
apiURL = defaultBrainAPIURL
}
apiKey := strings.TrimSpace(os.Getenv("CORE_BRAIN_KEY"))
if apiKey == "" {
if home, err := os.UserHomeDir(); err == nil {
keyPath := filepath.Join(home, ".claude", "brain.key")
if data, readErr := os.ReadFile(keyPath); readErr == nil {
apiKey = strings.TrimSpace(string(data))
}
}
}
cacheTTL := defaultBrainCacheTTL
if raw := strings.TrimSpace(os.Getenv("CORE_BRAIN_RECALL_CACHE_TTL")); raw != "" {
if parsed, err := time.ParseDuration(raw); err == nil && parsed > 0 {
cacheTTL = parsed
}
}
cache, err := newBrainRecallCache(workspaceRoot, apiURL, apiKey, cacheTTL)
if err != nil {
return nil, err
}
return &BrainDirectSubsystem{
workspaceRoot: workspaceRoot,
apiURL: apiURL,
apiKey: apiKey,
client: &http.Client{Timeout: 30 * time.Second},
cache: cache,
}, nil
}
// Name implements mcp.Subsystem.
func (s *BrainDirectSubsystem) Name() string { return "brain" }
// RegisterTools implements mcp.Subsystem.
func (s *BrainDirectSubsystem) RegisterTools(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{
Name: "brain_remember",
Description: "Store a memory in OpenBrain. Types: fact, decision, observation, plan, convention, architecture, research, documentation, service, bug, pattern, context, procedure.",
}, s.brainRemember)
mcp.AddTool(server, &mcp.Tool{
Name: "brain_recall",
Description: "Semantic search across OpenBrain memories. Returns memories ranked by similarity. Use agent_id 'cladius' for Cladius's memories.",
}, s.brainRecall)
mcp.AddTool(server, &mcp.Tool{
Name: "brain_forget",
Description: "Remove a memory from OpenBrain by ID.",
}, s.brainForget)
}
// Shutdown implements mcp.SubsystemWithShutdown.
func (s *BrainDirectSubsystem) Shutdown(ctx context.Context) error {
if s.cache != nil {
return s.cache.Close()
}
return nil
}
func (s *BrainDirectSubsystem) apiCall(ctx context.Context, method, path string, body any) (map[string]any, error) {
if s.apiKey == "" {
return nil, coreerr.E("brain.apiCall", "no API key (set CORE_BRAIN_KEY or create ~/.claude/brain.key)", nil)
}
var reqBody io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return nil, coreerr.E("brain.apiCall", "marshal request", err)
}
reqBody = bytes.NewReader(data)
}
req, err := http.NewRequestWithContext(ctx, method, s.apiURL+path, reqBody)
if err != nil {
return nil, coreerr.E("brain.apiCall", "create request", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+s.apiKey)
resp, err := s.client.Do(req)
if err != nil {
return nil, coreerr.E("brain.apiCall", "API call failed", err)
}
defer resp.Body.Close()
respData, err := io.ReadAll(resp.Body)
if err != nil {
return nil, coreerr.E("brain.apiCall", "read response", err)
}
if resp.StatusCode >= 400 {
return nil, coreerr.E("brain.apiCall", "API returned "+string(respData), nil)
}
var result map[string]any
if err := json.Unmarshal(respData, &result); err != nil {
return nil, coreerr.E("brain.apiCall", "parse response", err)
}
return result, nil
}
func (s *BrainDirectSubsystem) brainRemember(ctx context.Context, _ *mcp.CallToolRequest, input RememberInput) (*mcp.CallToolResult, RememberOutput, error) {
result, err := s.apiCall(ctx, "POST", "/v1/brain/remember", map[string]any{
"content": input.Content,
"type": input.Type,
"tags": input.Tags,
"project": input.Project,
"agent_id": "cladius",
})
if err != nil {
return nil, RememberOutput{}, err
}
if s.cache != nil {
_ = s.cache.clear(ctx)
}
id, _ := result["id"].(string)
return nil, RememberOutput{
Success: true,
MemoryID: id,
Timestamp: time.Now(),
}, nil
}
func (s *BrainDirectSubsystem) brainRecall(ctx context.Context, _ *mcp.CallToolRequest, input RecallInput) (*mcp.CallToolResult, RecallOutput, error) {
request := s.normalisedRecallRequest(input)
cacheKey, err := s.cacheKey(request)
if err != nil {
return nil, RecallOutput{}, err
}
if s.cache != nil {
if cached, ok, err := s.cache.get(ctx, cacheKey); err != nil {
return nil, RecallOutput{}, err
} else if ok {
return nil, cached, nil
}
}
body := map[string]any{
"query": request.Query,
"top_k": request.TopK,
"agent_id": request.AgentID,
}
if request.Project != "" {
body["project"] = request.Project
}
if request.Type != nil {
body["type"] = request.Type
}
result, err := s.apiCall(ctx, "POST", "/v1/brain/recall", body)
if err != nil {
return nil, RecallOutput{}, err
}
output := brainRecallResult(result)
if s.cache != nil {
_ = s.cache.set(ctx, cacheKey, output)
}
return nil, output, nil
}
func (s *BrainDirectSubsystem) brainForget(ctx context.Context, _ *mcp.CallToolRequest, input ForgetInput) (*mcp.CallToolResult, ForgetOutput, error) {
_, err := s.apiCall(ctx, "DELETE", "/v1/brain/forget/"+input.ID, nil)
if err != nil {
return nil, ForgetOutput{}, err
}
if s.cache != nil {
_ = s.cache.clear(ctx)
}
return nil, ForgetOutput{
Success: true,
Forgotten: input.ID,
Timestamp: time.Now(),
}, nil
}
type brainRecallRequest struct {
WorkspaceRoot string `json:"workspace_root"`
APIURL string `json:"api_url"`
APIKeyHash string `json:"api_key_hash"`
Query string `json:"query"`
TopK int `json:"top_k"`
AgentID string `json:"agent_id"`
Project string `json:"project,omitempty"`
Type any `json:"type,omitempty"`
}
func (s *BrainDirectSubsystem) normalisedRecallRequest(input RecallInput) brainRecallRequest {
topK := input.TopK
if topK <= 0 {
topK = 10
}
return brainRecallRequest{
WorkspaceRoot: strings.TrimSpace(s.workspaceRoot),
APIURL: strings.TrimSpace(s.apiURL),
APIKeyHash: hashString(s.apiKey),
Query: strings.TrimSpace(input.Query),
TopK: topK,
AgentID: "cladius",
Project: strings.TrimSpace(input.Filter.Project),
Type: input.Filter.Type,
}
}
func (s *BrainDirectSubsystem) cacheKey(request brainRecallRequest) (string, error) {
data, err := json.Marshal(request)
if err != nil {
return "", coreerr.E("brain.cacheKey", "marshal recall request", err)
}
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:]), nil
}
func brainRecallResult(result map[string]any) RecallOutput {
var memories []Memory
if mems, ok := result["memories"].([]any); ok {
for _, m := range mems {
mm, ok := m.(map[string]any)
if !ok {
continue
}
mem := Memory{
Content: fmt.Sprintf("%v", mm["content"]),
Type: fmt.Sprintf("%v", mm["type"]),
Project: fmt.Sprintf("%v", mm["project"]),
AgentID: fmt.Sprintf("%v", mm["agent_id"]),
CreatedAt: fmt.Sprintf("%v", mm["created_at"]),
}
if id, ok := mm["id"].(string); ok {
mem.ID = id
}
if score, ok := mm["score"].(float64); ok {
mem.Confidence = score
}
if source, ok := mm["source"].(string); ok {
mem.Tags = append(mem.Tags, "source:"+source)
}
memories = append(memories, mem)
}
}
return RecallOutput{
Success: true,
Count: len(memories),
Memories: memories,
}
}
func hashString(value string) string {
sum := sha256.Sum256([]byte(value))
return hex.EncodeToString(sum[:])
}
type brainRecallCache struct {
db *sql.DB
ttl time.Duration
path string
}
func newBrainRecallCache(workspaceRoot, apiURL, apiKey string, ttl time.Duration) (*brainRecallCache, error) {
cachePath, err := brainRecallCachePath(workspaceRoot, apiURL, apiKey)
if err != nil {
return nil, err
}
db, err := sql.Open(defaultBrainCacheDriver, cachePath)
if err != nil {
return nil, coreerr.E("brain.cache.open", "open duckdb cache", err)
}
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
schema := `
CREATE TABLE IF NOT EXISTS brain_recall_cache (
cache_key TEXT PRIMARY KEY,
payload TEXT NOT NULL,
created_at_unix_ms BIGINT NOT NULL,
expires_at_unix_ms BIGINT NOT NULL
);
`
if _, err := db.Exec(schema); err != nil {
_ = db.Close()
return nil, coreerr.E("brain.cache.schema", "initialise duckdb cache", err)
}
return &brainRecallCache{
db: db,
ttl: ttl,
path: cachePath,
}, nil
}
func brainRecallCachePath(workspaceRoot, apiURL, apiKey string) (string, error) {
baseDir, err := os.UserCacheDir()
if err != nil || baseDir == "" {
baseDir = os.TempDir()
}
identity := strings.Join([]string{
strings.TrimSpace(workspaceRoot),
strings.TrimSpace(apiURL),
hashString(apiKey),
}, "\x00")
sum := sha256.Sum256([]byte(identity))
fileName := "brain-recall-" + hex.EncodeToString(sum[:]) + ".duckdb"
cacheDir := filepath.Join(baseDir, "core-ide", "brain")
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
return "", coreerr.E("brain.cache.path", "create cache directory", err)
}
return filepath.Join(cacheDir, fileName), nil
}
func (c *brainRecallCache) Close() error {
if c == nil || c.db == nil {
return nil
}
return c.db.Close()
}
func (c *brainRecallCache) get(ctx context.Context, key string) (RecallOutput, bool, error) {
if c == nil || c.db == nil {
return RecallOutput{}, false, nil
}
var payload string
var expires int64
err := c.db.QueryRowContext(ctx, `SELECT payload, expires_at_unix_ms FROM brain_recall_cache WHERE cache_key = ?`, key).Scan(&payload, &expires)
if err == sql.ErrNoRows {
return RecallOutput{}, false, nil
}
if err != nil {
return RecallOutput{}, false, coreerr.E("brain.cache.get", "read recall cache", err)
}
if time.Now().UTC().UnixMilli() >= expires {
_, _ = c.db.ExecContext(ctx, `DELETE FROM brain_recall_cache WHERE cache_key = ?`, key)
return RecallOutput{}, false, nil
}
var out RecallOutput
if err := json.Unmarshal([]byte(payload), &out); err != nil {
return RecallOutput{}, false, coreerr.E("brain.cache.get", "decode cached recall output", err)
}
return out, true, nil
}
func (c *brainRecallCache) set(ctx context.Context, key string, value RecallOutput) error {
if c == nil || c.db == nil {
return nil
}
payload, err := json.Marshal(value)
if err != nil {
return coreerr.E("brain.cache.set", "encode recall output", err)
}
now := time.Now().UTC().UnixMilli()
expires := now + int64(c.ttl/time.Millisecond)
if expires <= now {
expires = now + 1
}
_, err = c.db.ExecContext(ctx, `
INSERT INTO brain_recall_cache (cache_key, payload, created_at_unix_ms, expires_at_unix_ms)
VALUES (?, ?, ?, ?)
ON CONFLICT(cache_key) DO UPDATE SET
payload = excluded.payload,
created_at_unix_ms = excluded.created_at_unix_ms,
expires_at_unix_ms = excluded.expires_at_unix_ms
`, key, string(payload), now, expires)
if err != nil {
return coreerr.E("brain.cache.set", "store recall cache", err)
}
return nil
}
func (c *brainRecallCache) clear(ctx context.Context) error {
if c == nil || c.db == nil {
return nil
}
if _, err := c.db.ExecContext(ctx, `DELETE FROM brain_recall_cache`); err != nil {
return coreerr.E("brain.cache.clear", "clear recall cache", err)
}
return nil
}

148
brain_direct_test.go Normal file
View file

@ -0,0 +1,148 @@
package main
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBrainRecallCache_Good_StoresAndExpires(t *testing.T) {
cache, err := newBrainRecallCache(t.TempDir(), "https://example.com", "secret", 25*time.Millisecond)
require.NoError(t, err)
defer func() {
require.NoError(t, cache.Close())
}()
want := RecallOutput{
Success: true,
Count: 1,
Memories: []Memory{
{ID: "m-1", Content: "remember this", CreatedAt: "2026-03-31T00:00:00Z"},
},
}
require.NoError(t, cache.set(context.Background(), "cache-key", want))
got, ok, err := cache.get(context.Background(), "cache-key")
require.NoError(t, err)
require.True(t, ok)
assert.Equal(t, want, got)
time.Sleep(50 * time.Millisecond)
got, ok, err = cache.get(context.Background(), "cache-key")
require.NoError(t, err)
assert.False(t, ok)
assert.Empty(t, got)
}
func TestBrainDirectSubsystem_RecallCachesResults(t *testing.T) {
var calls atomic.Int32
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "/v1/brain/recall", r.URL.Path)
var body map[string]any
require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
assert.Equal(t, "alpha", body["query"])
assert.Equal(t, float64(5), body["top_k"])
assert.Equal(t, "cladius", body["agent_id"])
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"memories": []map[string]any{
{
"id": "m-1",
"content": "alpha memory",
"type": "decision",
"project": "ide",
"agent_id": "cladius",
"created_at": "2026-03-31T00:00:00Z",
"score": 0.91,
"source": "laravel",
},
},
})
}))
defer upstream.Close()
cache, err := newBrainRecallCache(t.TempDir(), upstream.URL, "secret", time.Hour)
require.NoError(t, err)
defer func() {
require.NoError(t, cache.Close())
}()
sub := &BrainDirectSubsystem{
workspaceRoot: "/workspace",
apiURL: upstream.URL,
apiKey: "secret",
client: upstream.Client(),
cache: cache,
}
_, first, err := sub.brainRecall(context.Background(), nil, RecallInput{Query: "alpha", TopK: 5})
require.NoError(t, err)
assert.True(t, first.Success)
require.Equal(t, int32(1), calls.Load())
_, second, err := sub.brainRecall(context.Background(), nil, RecallInput{Query: "alpha", TopK: 5})
require.NoError(t, err)
assert.True(t, second.Success)
require.Equal(t, int32(1), calls.Load())
}
func TestBrainDirectSubsystem_ForgetClearsCache(t *testing.T) {
var recallCalls atomic.Int32
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && r.URL.Path == "/v1/brain/recall":
recallCalls.Add(1)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"memories": []map[string]any{
{"id": "m-1", "content": "alpha", "type": "decision", "created_at": "2026-03-31T00:00:00Z"},
},
})
case r.Method == http.MethodDelete && r.URL.Path == "/v1/brain/forget/m-1":
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"deleted": true})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer upstream.Close()
cache, err := newBrainRecallCache(t.TempDir(), upstream.URL, "secret", time.Hour)
require.NoError(t, err)
defer func() {
require.NoError(t, cache.Close())
}()
sub := &BrainDirectSubsystem{
workspaceRoot: "/workspace",
apiURL: upstream.URL,
apiKey: "secret",
client: upstream.Client(),
cache: cache,
}
_, _, err = sub.brainRecall(context.Background(), nil, RecallInput{Query: "alpha", TopK: 5})
require.NoError(t, err)
require.Equal(t, int32(1), recallCalls.Load())
_, _, err = sub.brainForget(context.Background(), nil, ForgetInput{ID: "m-1"})
require.NoError(t, err)
_, _, err = sub.brainRecall(context.Background(), nil, RecallInput{Query: "alpha", TopK: 5})
require.NoError(t, err)
require.Equal(t, int32(2), recallCalls.Load())
}

View file

@ -1,4 +1,4 @@
//go:build !ios
//go:build ignore
package main
@ -7,4 +7,4 @@ import "github.com/wailsapp/wails/v3/pkg/application"
// modifyOptionsForIOS is a no-op on non-iOS platforms
func modifyOptionsForIOS(opts *application.Options) {
// No modifications needed for non-iOS platforms
}
}

View file

@ -3,6 +3,7 @@
import { Routes } from '@angular/router';
import {
ApplicationFrameComponent,
IdeComponent,
ProviderHostComponent,
SystemTrayFrameComponent,
} from '@core/gui-ui';
@ -11,6 +12,9 @@ export const routes: Routes = [
// System tray panel — standalone compact UI (380x480 frameless)
{ path: 'tray', component: SystemTrayFrameComponent },
// Full IDE layout with sidebar, dashboard, explorer, and terminal panes
{ path: 'ide', component: IdeComponent },
// Main application frame with HLCRF layout
{
path: '',

110
go.mod
View file

@ -1,23 +1,68 @@
module forge.lthn.ai/core/ide
module dappco.re/go/core/ide
go 1.26.0
require (
forge.lthn.ai/core/api v0.1.5
forge.lthn.ai/core/config v0.1.8
forge.lthn.ai/core/go v0.3.3
forge.lthn.ai/core/go-process v0.2.9
forge.lthn.ai/core/go-scm v0.3.6
forge.lthn.ai/core/go-ws v0.2.5
forge.lthn.ai/core/gui v0.1.5
forge.lthn.ai/core/mcp v0.3.4
dappco.re/go/core/scm v0.4.0
dappco.re/go/core/api v0.1.5
dappco.re/go/core/config v0.1.8
dappco.re/go/core v0.3.3
dappco.re/go/core/process v0.2.7
dappco.re/go/core/ws v0.2.3
dappco.re/go/core/gui v0.1.3
dappco.re/go/core/mcp v0.3.2
github.com/marcboeker/go-duckdb v1.8.5
github.com/stretchr/testify v1.11.1
github.com/wailsapp/wails/v3 v3.0.0-alpha.74
)
require (
dario.cat/mergo v1.0.2 // indirect
dappco.re/go/core/log v0.0.4 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.4.0 // indirect
github.com/adrg/xdg v0.5.3 // indirect
github.com/apache/arrow-go/v18 v18.5.2 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.8.0 // indirect
github.com/go-git/go-git/v5 v5.17.0 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/flatbuffers v25.12.19+incompatible // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
github.com/kevinburke/ssh_config v1.6.0 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/leaanthony/u v1.1.1 // indirect
github.com/lmittmann/tint v1.1.3 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pierrec/lz4/v4 v4.1.26 // indirect
github.com/pjbgf/sha1cd v0.5.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.53.0 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/skeema/knownhosts v1.3.2 // indirect
github.com/wailsapp/go-webview2 v1.0.23 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/zeebo/xxh3 v1.1.0 // indirect
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
golang.org/x/telemetry v0.0.0-20260312161427-1546bf4b83fe // indirect
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
@ -25,21 +70,17 @@ require (
)
require (
dario.cat/mergo v1.0.2 // indirect
forge.lthn.ai/core/go-ai v0.1.12 // indirect
forge.lthn.ai/core/go-io v0.1.7 // indirect
forge.lthn.ai/core/go-log v0.0.4 // indirect
forge.lthn.ai/core/go-rag v0.1.11 // indirect
forge.lthn.ai/core/go-webview v0.1.7 // indirect
dappco.re/go/core/io v0.2.0 // indirect
dappco.re/go/core/log v0.1.0
dappco.re/go/core/ai v0.1.11 // indirect
dappco.re/go/core/io v0.1.7 // indirect
dappco.re/go/core/rag v0.1.11 // indirect
dappco.re/go/core/webview v0.1.5 // indirect
github.com/99designs/gqlgen v0.17.88 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.4.0 // indirect
github.com/adrg/xdg v0.5.3 // indirect
github.com/agnivade/levenshtein v1.2.1 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/bytedance/gopkg v0.1.4 // indirect
@ -48,14 +89,9 @@ require (
github.com/casbin/casbin/v2 v2.135.0 // indirect
github.com/casbin/govaluate v1.10.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/coreos/go-oidc/v3 v3.17.0 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gin-contrib/authz v1.0.6 // indirect
@ -72,13 +108,9 @@ require (
github.com/gin-contrib/static v1.1.5 // indirect
github.com/gin-contrib/timeout v1.1.0 // indirect
github.com/gin-gonic/gin v1.12.0
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.8.0 // indirect
github.com/go-git/go-git/v5 v5.17.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-openapi/jsonpointer v0.22.5 // indirect
github.com/go-openapi/jsonreference v0.21.5 // indirect
github.com/go-openapi/spec v0.22.4 // indirect
@ -95,8 +127,6 @@ require (
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/context v1.1.2 // indirect
@ -104,36 +134,23 @@ require (
github.com/gorilla/sessions v1.4.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/kevinburke/ssh_config v1.6.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/leaanthony/u v1.1.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lmittmann/tint v1.1.3 // indirect
github.com/mailru/easyjson v0.9.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modelcontextprotocol/go-sdk v1.4.1 // indirect
github.com/modelcontextprotocol/go-sdk v1.4.1
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/ollama/ollama v0.18.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pjbgf/sha1cd v0.5.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/qdrant/go-client v1.17.1 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/redis/go-redis/v9 v9.18.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/samber/lo v1.53.0 // indirect
github.com/segmentio/asm v1.2.1 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/skeema/knownhosts v1.3.2 // indirect
github.com/sosodev/duration v1.4.0 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
@ -146,9 +163,7 @@ require (
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/vektah/gqlparser/v2 v2.5.32 // indirect
github.com/wailsapp/go-webview2 v1.0.23 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
@ -171,6 +186,5 @@ require (
google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect
google.golang.org/grpc v1.79.2 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gopkg.in/yaml.v3 v3.0.1
)

58
go.sum
View file

@ -1,3 +1,9 @@
dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4=
dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E=
dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc=
dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs=
dappco.re/go/core/scm v0.4.0 h1:Fpi+AcDicezZ82HH6s2yuk6RjBXT0w/kynKHecNpmgc=
dappco.re/go/core/scm v0.4.0/go.mod h1:ufb7si6HBkaT6zC8L67kLm8zzBaD1aQoTn4OsVAM1aI=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
forge.lthn.ai/core/api v0.1.5 h1:NwZrcOyBjaiz5/cn0n0tnlMUodi8Or6FHMx59C7Kv2o=
@ -6,26 +12,24 @@ forge.lthn.ai/core/config v0.1.8 h1:xP2hys7T94QGVF/OTh84/Zr5Dm/dL/0vzjht8zi+LOg=
forge.lthn.ai/core/config v0.1.8/go.mod h1:8epZrkwoCt+5ayrqdinOUU/+w6UoxOyv9ZrdgVOgYfQ=
forge.lthn.ai/core/go v0.3.3 h1:kYYZ2nRYy0/Be3cyuLJspRjLqTMxpckVyhb/7Sw2gd0=
forge.lthn.ai/core/go v0.3.3/go.mod h1:Cp4ac25pghvO2iqOu59t1GyngTKVOzKB5/VPdhRi9CQ=
forge.lthn.ai/core/go-ai v0.1.12 h1:OHt0bUABlyhvgxZxyMwueRoh8rS3YKWGFY6++zCAwC8=
forge.lthn.ai/core/go-ai v0.1.12/go.mod h1:5Pc9lszxgkO7Aj2Z3dtq4L9Xk9l/VNN+Baj1t///OCM=
forge.lthn.ai/core/go-ai v0.1.11 h1:EJ3XIVg7NcLSPoOCX8I1YGso+uxtVVujafRyShXPAEA=
forge.lthn.ai/core/go-ai v0.1.11/go.mod h1:5Pc9lszxgkO7Aj2Z3dtq4L9Xk9l/VNN+Baj1t///OCM=
forge.lthn.ai/core/go-io v0.1.7 h1:Tdb6sqh+zz1lsGJaNX9RFWM6MJ/RhSAyxfulLXrJsbk=
forge.lthn.ai/core/go-io v0.1.7/go.mod h1:8lRLFk4Dnp5cR/Cyzh9WclD5566TbpdRgwcH7UZLWn4=
forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0=
forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
forge.lthn.ai/core/go-process v0.2.9 h1:Wql+5TUF+lfU2oJ9I+S764MkTqJhBsuyMM0v1zsfZC4=
forge.lthn.ai/core/go-process v0.2.9/go.mod h1:NIzZOF5IVYYCjHkcNIGcg1mZH+bzGoie4SlZUDYOKIM=
forge.lthn.ai/core/go-process v0.2.7 h1:yl7jOxzDqWpJd/ZvJ/Ff6bHgPFLA1ZYU5UDcsz3AzLM=
forge.lthn.ai/core/go-process v0.2.7/go.mod h1:I6x11UNaZbU3k0FWUaSlPRTE4YZk/lWIjiODm/8Jr9c=
forge.lthn.ai/core/go-rag v0.1.11 h1:KXTOtnOdrx8YKmvnj0EOi2EI/+cKjE8w2PpJCQIrSd8=
forge.lthn.ai/core/go-rag v0.1.11/go.mod h1:vIlOKVD1SdqqjkJ2XQyXPuKPtiajz/STPLCaDpqOzk8=
forge.lthn.ai/core/go-scm v0.3.6 h1:LFNx8Fs82mrpxro/MPUM6tMiD4DqPmdu83UknXztQjc=
forge.lthn.ai/core/go-scm v0.3.6/go.mod h1:IWFIYDfRH0mtRdqY5zV06l/RkmkPpBM6FcbKWhg1Qa8=
forge.lthn.ai/core/go-webview v0.1.7 h1:9+aEHeAvNcPX8Zwr+UGu0/T+menRm5T1YOmqZ9dawDc=
forge.lthn.ai/core/go-webview v0.1.7/go.mod h1:5n1tECD1wBV/uFZRY9ZjfPFO5TYZrlaR3mQFwvO2nek=
forge.lthn.ai/core/go-ws v0.2.5 h1:ZIV7Yrv01R/xpJUogA5vrfP9yB9li1w7EV3eZFMt8h0=
forge.lthn.ai/core/go-ws v0.2.5/go.mod h1:C3riJyLLcV6QhLvYlq3P/XkGTsN598qQeGBoLdoHBU4=
forge.lthn.ai/core/gui v0.1.5 h1:qelZQQ/6zKvZEKKJ/x9EodjIeFxUW+Z1c7t242U7E3A=
forge.lthn.ai/core/gui v0.1.5/go.mod h1:4lB4gdMbLvNBDrxHkIc+Tmb4KURiKSCDQb555HrPkhc=
forge.lthn.ai/core/mcp v0.3.4 h1:I4ubEW8c4Rz+6cfUwwH+6LcuCdvMKKE17LrxXOjCUV8=
forge.lthn.ai/core/mcp v0.3.4/go.mod h1:5Pqn4PBUNOrXj2PPrPzyyjTDp55StAoZgDVCPsVEzvE=
forge.lthn.ai/core/go-webview v0.1.5 h1:tr6HJvDLfrF6GoDo0aT/kIdKtZCV9Qky6xI0TI4vEH8=
forge.lthn.ai/core/go-webview v0.1.5/go.mod h1:5n1tECD1wBV/uFZRY9ZjfPFO5TYZrlaR3mQFwvO2nek=
forge.lthn.ai/core/go-ws v0.2.3 h1:qTeMtJQjtTdTwfPvtbOBdch2Dmbde+Aso8Ow1qvg/bk=
forge.lthn.ai/core/go-ws v0.2.3/go.mod h1:C3riJyLLcV6QhLvYlq3P/XkGTsN598qQeGBoLdoHBU4=
forge.lthn.ai/core/gui v0.1.3 h1:73OcHQ3zWC21OxofWG/Jf6kV3TffZB+N3e2zdSDzS0g=
forge.lthn.ai/core/gui v0.1.3/go.mod h1:oW9C/opgYU0j3gIaZTbUIU6GNZo4A1NKsTB9W0I6REI=
forge.lthn.ai/core/mcp v0.3.2 h1:+wtlQolyUJpwaWzWQFBhX5gSfrqLapw4EhLUa3AqR5U=
forge.lthn.ai/core/mcp v0.3.2/go.mod h1:ZKcV57TPUFiLSbOam1nBHlQJo5rlskiJKFV/uJ9Gkco=
github.com/99designs/gqlgen v0.17.88 h1:neMQDgehMwT1vYIOx/w5ZYPUU/iMNAJzRO44I5Intoc=
github.com/99designs/gqlgen v0.17.88/go.mod h1:qeqYFEgOeSKqWedOjogPizimp2iu4E23bdPvl4jTYic=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
@ -49,6 +53,10 @@ github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kk
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/apache/arrow-go/v18 v18.5.2 h1:3uoHjoaEie5eVsxx/Bt64hKwZx4STb+beAkqKOlq/lY=
github.com/apache/arrow-go/v18 v18.5.2/go.mod h1:yNoizNTT4peTciJ7V01d2EgOkE1d0fQ1vZcFOsVtFsw=
github.com/apache/thrift v0.22.0 h1:r7mTJdj51TMDe6RtcmNdQxgn9XcyfGDOzegMDRg47uc=
github.com/apache/thrift v0.22.0/go.mod h1:1e7J/O1Ae6ZQMTYdy9xa3w9k+XHWPfRvdPyJeynQ+/g=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
@ -211,6 +219,10 @@ github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/flatbuffers v25.12.19+incompatible h1:haMV2JRRJCe1998HeW/p0X9UaMTK6SDo0ffLn2+DbLs=
github.com/google/flatbuffers v25.12.19+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@ -240,6 +252,10 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY=
github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
github.com/klauspost/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4=
github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
@ -259,6 +275,8 @@ github.com/lmittmann/tint v1.1.3 h1:Hv4EaHWXQr+GTFnOU4VKf8UvAtZgn0VuKT+G0wFlO3I=
github.com/lmittmann/tint v1.1.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M=
github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/marcboeker/go-duckdb v1.8.5 h1:tkYp+TANippy0DaIOP5OEfBEwbUINqiFqgwMQ44jME0=
github.com/marcboeker/go-duckdb v1.8.5/go.mod h1:6mK7+WQE4P4u5AFLvVBmhFxY5fvhymFptghgJX6B+/8=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
@ -266,6 +284,10 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 h1:AMFGa4R4MiIpspGNG7Z948v4n35fFGB3RR3G/ry4FWs=
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY=
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 h1:+n/aFZefKZp7spd8DFdX7uMikMLXX4oubIzJF4kv/wI=
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE=
github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc=
github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@ -281,6 +303,8 @@ github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
@ -368,6 +392,8 @@ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3i
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
@ -439,6 +465,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20260312161427-1546bf4b83fe h1:MaXjBsxue6l0hflXDwJ/XBfUJRjiyX1PwLd7F3lYDXA=
golang.org/x/telemetry v0.0.0-20260312161427-1546bf4b83fe/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@ -458,6 +486,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 h1:aJmi6DVGGIStN9Mobk/tZOOQUBbj0BPjZjjnOdoZKts=

26
gui_enabled.go Normal file
View file

@ -0,0 +1,26 @@
package main
import (
"os"
"runtime"
"forge.lthn.ai/core/config"
)
// guiEnabled checks whether the GUI should start.
// Returns false if config says gui.enabled: false, or if no display is available.
func guiEnabled(cfg *config.Config) bool {
if cfg != nil {
var guiCfg struct {
Enabled *bool `mapstructure:"enabled"`
}
if err := cfg.Get("gui", &guiCfg); err == nil && guiCfg.Enabled != nil {
return *guiCfg.Enabled
}
}
// Fall back to display detection.
if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
return true
}
return os.Getenv("DISPLAY") != "" || os.Getenv("WAYLAND_DISPLAY") != ""
}

35
main.go
View file

@ -1,3 +1,5 @@
//go:build !linux
package main
import (
@ -11,6 +13,7 @@ import (
"runtime"
"syscall"
"dappco.re/go/core/ide/icons"
"forge.lthn.ai/core/api"
"forge.lthn.ai/core/api/pkg/provider"
"forge.lthn.ai/core/config"
@ -18,9 +21,8 @@ import (
processapi "forge.lthn.ai/core/go-process/pkg/api"
"forge.lthn.ai/core/go-ws"
"forge.lthn.ai/core/go/pkg/core"
guiMCP "forge.lthn.ai/core/gui/pkg/mcp"
"forge.lthn.ai/core/gui/pkg/display"
"forge.lthn.ai/core/ide/icons"
guiMCP "forge.lthn.ai/core/gui/pkg/mcp"
"forge.lthn.ai/core/mcp/pkg/mcp"
"forge.lthn.ai/core/mcp/pkg/mcp/agentic"
"forge.lthn.ai/core/mcp/pkg/mcp/brain"
@ -61,6 +63,11 @@ func main() {
}
bridge := ide.NewBridge(hub, bridgeCfg)
brainDirect, err := NewCachedBrainDirect(cwd)
if err != nil {
log.Fatalf("failed to initialise brain cache: %v", err)
}
// ── Service Provider Registry ──────────────────────────────
reg := provider.NewRegistry()
reg.Add(processapi.NewProvider(process.DefaultRegistry(), hub))
@ -85,6 +92,8 @@ func main() {
// ── Providers API ─────────────────────────────────────────
// Exposes GET /api/v1/providers for the Angular frontend
engine.Register(NewProvidersAPI(reg, rm))
engine.Register(NewWorkspaceAPI(cwd))
engine.Register(NewPackageToolsAPI(nil))
// ── Core framework ─────────────────────────────────────────
c, err := core.New(
@ -96,7 +105,7 @@ func main() {
return mcp.New(
mcp.WithWorkspaceRoot(cwd),
mcp.WithWSHub(hub),
mcp.WithSubsystem(brain.NewDirect()),
mcp.WithSubsystem(brainDirect),
mcp.WithSubsystem(agentic.NewPrep()),
mcp.WithSubsystem(guiMCP.New(c)),
)
@ -242,7 +251,7 @@ func main() {
Title: "Core IDE",
Width: 1280,
Height: 800,
URL: "/",
URL: "/ide",
Hidden: true,
BackgroundColour: application.NewRGB(26, 27, 38),
})
@ -298,21 +307,3 @@ func main() {
log.Fatal(err)
}
}
// guiEnabled checks whether the GUI should start.
// Returns false if config says gui.enabled: false, or if no display is available.
func guiEnabled(cfg *config.Config) bool {
if cfg != nil {
var guiCfg struct {
Enabled *bool `mapstructure:"enabled"`
}
if err := cfg.Get("gui", &guiCfg); err == nil && guiCfg.Enabled != nil {
return *guiCfg.Enabled
}
}
// Fall back to display detection
if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
return true
}
return os.Getenv("DISPLAY") != "" || os.Getenv("WAYLAND_DISPLAY") != ""
}

142
main_linux.go Normal file
View file

@ -0,0 +1,142 @@
//go:build linux
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"forge.lthn.ai/core/api"
"forge.lthn.ai/core/api/pkg/provider"
"forge.lthn.ai/core/config"
process "forge.lthn.ai/core/go-process"
processapi "forge.lthn.ai/core/go-process/pkg/api"
"forge.lthn.ai/core/go-ws"
"forge.lthn.ai/core/go/pkg/core"
"forge.lthn.ai/core/mcp/pkg/mcp"
"forge.lthn.ai/core/mcp/pkg/mcp/agentic"
"forge.lthn.ai/core/mcp/pkg/mcp/brain"
"forge.lthn.ai/core/mcp/pkg/mcp/ide"
)
func main() {
mcpOnly := false
for _, arg := range os.Args[1:] {
if arg == "--mcp" {
mcpOnly = true
}
}
cfg, _ := config.New()
cwd, err := os.Getwd()
if err != nil {
log.Fatalf("failed to get working directory: %v", err)
}
hub := ws.NewHub()
bridgeCfg := ide.DefaultConfig()
bridgeCfg.WorkspaceRoot = cwd
if url := os.Getenv("CORE_API_URL"); url != "" {
bridgeCfg.LaravelWSURL = url
}
if token := os.Getenv("CORE_API_TOKEN"); token != "" {
bridgeCfg.Token = token
}
bridge := ide.NewBridge(hub, bridgeCfg)
brainDirect, err := NewCachedBrainDirect(cwd)
if err != nil {
log.Fatalf("failed to initialise brain cache: %v", err)
}
reg := provider.NewRegistry()
reg.Add(processapi.NewProvider(process.DefaultRegistry(), hub))
reg.Add(brain.NewProvider(bridge, hub))
apiAddr := ":9880"
if addr := os.Getenv("CORE_API_ADDR"); addr != "" {
apiAddr = addr
}
engine, _ := api.New(
api.WithAddr(apiAddr),
api.WithCORS("*"),
api.WithWSHandler(http.Handler(hub.Handler())),
api.WithSwagger("Core IDE", "Service Provider API", "0.1.0"),
)
reg.MountAll(engine)
rm := NewRuntimeManager(engine)
engine.Register(NewProvidersAPI(reg, rm))
engine.Register(NewWorkspaceAPI(cwd))
engine.Register(NewPackageToolsAPI(nil))
c, err := core.New(
core.WithName("ws", func(c *core.Core) (any, error) {
return hub, nil
}),
core.WithName("mcp", func(c *core.Core) (any, error) {
return mcp.New(
mcp.WithWorkspaceRoot(cwd),
mcp.WithWSHub(hub),
mcp.WithSubsystem(brainDirect),
mcp.WithSubsystem(agentic.NewPrep()),
)
}),
)
if err != nil {
log.Fatalf("failed to create core: %v", err)
}
mcpSvc, err := core.ServiceFor[*mcp.Service](c, "mcp")
if err != nil {
log.Fatalf("failed to get MCP service: %v", err)
}
if guiEnabled(cfg) {
log.Printf("GUI mode is unavailable in this Linux build; running headless instead")
}
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
if err := c.ServiceStartup(ctx, nil); err != nil {
log.Fatalf("core startup failed: %v", err)
}
bridge.Start(ctx)
go hub.Run(ctx)
if err := rm.StartAll(ctx); err != nil {
log.Printf("runtime provider error: %v", err)
}
go func() {
log.Printf("API server listening on %s", apiAddr)
if err := engine.Serve(ctx); err != nil {
log.Printf("API server error: %v", err)
}
}()
if mcpOnly {
if err := mcpSvc.ServeStdio(ctx); err != nil {
log.Printf("MCP stdio error: %v", err)
}
} else {
go func() {
if err := mcpSvc.Run(ctx); err != nil {
log.Printf("MCP error: %v", err)
}
}()
<-ctx.Done()
}
rm.StopAll()
shutdownCtx := context.Background()
_ = mcpSvc.Shutdown(shutdownCtx)
_ = c.ServiceShutdown(shutdownCtx)
}

33
main_test.go Normal file
View file

@ -0,0 +1,33 @@
package main
import (
"testing"
"forge.lthn.ai/core/config"
"github.com/stretchr/testify/assert"
)
func TestGuiEnabled_Good_NilConfig(t *testing.T) {
// nil config should fall through to display detection.
result := guiEnabled(nil)
// On macOS/Windows this returns true; on Linux it depends on DISPLAY.
// Just verify it doesn't panic.
_ = result
}
func TestGuiEnabled_Good_WithConfig(t *testing.T) {
cfg, _ := config.New()
// Fresh config has no gui.enabled key — should fall through to OS detection.
result := guiEnabled(cfg)
_ = result
}
func TestStaticAssetGroup_Good(t *testing.T) {
s := &staticAssetGroup{
name: "test-assets",
basePath: "/assets/test",
dir: "/tmp",
}
assert.Equal(t, "test-assets", s.Name())
assert.Equal(t, "/assets/test", s.BasePath())
}

318
packages.go Normal file
View file

@ -0,0 +1,318 @@
// SPDX-Licence-Identifier: EUPL-1.2
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
"dappco.re/go/core/scm/marketplace"
"forge.lthn.ai/core/api"
"github.com/gin-gonic/gin"
)
const defaultMarketplaceAPIURL = "http://127.0.0.1:9880/api/v1/scm"
// PackageToolsAPI proxies package marketplace lookups and installs to the
// upstream go-scm SCM provider.
type PackageToolsAPI struct {
client *MarketplaceClient
}
// MarketplaceClient talks to the upstream SCM provider marketplace API.
type MarketplaceClient struct {
baseURL *url.URL
client *http.Client
}
// PackageInstallResult summarises a successful install.
type PackageInstallResult struct {
Installed bool `json:"installed"`
Code string `json:"code"`
}
// PackageSearchResponse groups query metadata with search results.
type PackageSearchResponse struct {
Query string `json:"query,omitempty"`
Category string `json:"category,omitempty"`
Packages []marketplace.Module `json:"packages"`
}
// PackageInfoResponse returns details for a single marketplace package.
type PackageInfoResponse struct {
Package marketplace.Module `json:"package"`
}
type marketplaceAPIError struct {
Code string
Message string
Details any
}
func (e *marketplaceAPIError) Error() string {
if e == nil {
return "marketplace request failed"
}
if e.Details != nil {
return fmt.Sprintf("%s: %s (%v)", e.Code, e.Message, e.Details)
}
return fmt.Sprintf("%s: %s", e.Code, e.Message)
}
func NewMarketplaceClient(baseURL string) *MarketplaceClient {
if baseURL == "" {
baseURL = os.Getenv("CORE_SCM_API_URL")
}
if baseURL == "" {
baseURL = defaultMarketplaceAPIURL
}
parsed, err := url.Parse(baseURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
parsed, _ = url.Parse(defaultMarketplaceAPIURL)
}
return &MarketplaceClient{
baseURL: parsed,
client: &http.Client{
Timeout: 10 * time.Second,
},
}
}
func NewPackageToolsAPI(client *MarketplaceClient) *PackageToolsAPI {
if client == nil {
client = NewMarketplaceClient("")
}
return &PackageToolsAPI{client: client}
}
func (p *PackageToolsAPI) Name() string { return "pkg-tools-api" }
func (p *PackageToolsAPI) BasePath() string { return "/api/v1/pkg" }
func (p *PackageToolsAPI) RegisterRoutes(rg *gin.RouterGroup) {
rg.GET("/search", p.search)
rg.GET("/info/:code", p.info)
rg.POST("/install/:code", p.install)
}
func (p *PackageToolsAPI) search(c *gin.Context) {
query := strings.TrimSpace(c.Query("q"))
category := strings.TrimSpace(c.Query("category"))
results, err := p.client.Search(c.Request.Context(), query, category)
if err != nil {
c.JSON(http.StatusBadGateway, api.Fail("marketplace_unavailable", err.Error()))
return
}
c.JSON(http.StatusOK, api.OK(PackageSearchResponse{
Query: query,
Category: category,
Packages: results,
}))
}
func (p *PackageToolsAPI) info(c *gin.Context) {
code := strings.TrimSpace(c.Param("code"))
if code == "" {
c.JSON(http.StatusBadRequest, api.Fail("invalid_request", "code is required"))
return
}
pkg, err := p.client.Info(c.Request.Context(), code)
if err != nil {
c.JSON(marketplaceErrorStatus(err), api.Fail(marketplaceErrorCode(err, "marketplace_lookup_failed"), marketplaceErrorMessage(err)))
return
}
c.JSON(http.StatusOK, api.OK(PackageInfoResponse{Package: pkg}))
}
func (p *PackageToolsAPI) install(c *gin.Context) {
code := strings.TrimSpace(c.Param("code"))
if code == "" {
c.JSON(http.StatusBadRequest, api.Fail("invalid_request", "code is required"))
return
}
result, err := p.client.Install(c.Request.Context(), code)
if err != nil {
c.JSON(marketplaceErrorStatus(err), api.Fail(marketplaceErrorCode(err, "marketplace_install_failed"), marketplaceErrorMessage(err)))
return
}
c.JSON(http.StatusOK, api.OK(result))
}
func (c *MarketplaceClient) Search(ctx context.Context, query, category string) ([]marketplace.Module, error) {
var resp api.Response[[]marketplace.Module]
if err := c.get(ctx, "/marketplace", map[string]string{
"q": query,
"category": category,
}, &resp); err != nil {
return nil, err
}
return resp.Data, nil
}
func (c *MarketplaceClient) Info(ctx context.Context, code string) (marketplace.Module, error) {
var resp api.Response[marketplace.Module]
if err := c.get(ctx, "/marketplace/"+url.PathEscape(code), nil, &resp); err != nil {
return marketplace.Module{}, err
}
return resp.Data, nil
}
func (c *MarketplaceClient) Install(ctx context.Context, code string) (PackageInstallResult, error) {
var resp api.Response[PackageInstallResult]
if err := c.post(ctx, "/marketplace/"+url.PathEscape(code)+"/install", nil, &resp); err != nil {
return PackageInstallResult{}, err
}
if resp.Data.Code == "" {
resp.Data.Code = code
}
return resp.Data, nil
}
func (c *MarketplaceClient) get(ctx context.Context, path string, query map[string]string, out any) error {
return c.request(ctx, http.MethodGet, path, query, nil, out)
}
func (c *MarketplaceClient) post(ctx context.Context, path string, query map[string]string, out any) error {
return c.request(ctx, http.MethodPost, path, query, nil, out)
}
func (c *MarketplaceClient) request(ctx context.Context, method, path string, query map[string]string, body any, out any) error {
if c == nil || c.baseURL == nil {
return fmt.Errorf("marketplace client is not configured")
}
u := *c.baseURL
u.Path = strings.TrimRight(u.Path, "/") + path
q := u.Query()
for key, value := range query {
if strings.TrimSpace(value) == "" {
continue
}
q.Set(key, value)
}
u.RawQuery = q.Encode()
var reqBody *strings.Reader
if body == nil {
reqBody = strings.NewReader("")
} else {
encoded, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("encode request body: %w", err)
}
reqBody = strings.NewReader(string(encoded))
}
req, err := http.NewRequestWithContext(ctx, method, u.String(), reqBody)
if err != nil {
return err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
res, err := c.client.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
raw, err := io.ReadAll(res.Body)
if err != nil {
return err
}
if out == nil {
if res.StatusCode != http.StatusOK {
return fmt.Errorf("marketplace API returned %s", res.Status)
}
return nil
}
if err := json.Unmarshal(raw, out); err != nil {
return err
}
if res.StatusCode != http.StatusOK {
if err := responseEnvelopeError(out); err != nil {
return err
}
return fmt.Errorf("marketplace API returned %s", res.Status)
}
return responseEnvelopeError(out)
}
func responseEnvelopeError(out any) error {
if resp, ok := out.(*api.Response[[]marketplace.Module]); ok && !resp.Success {
return responseError(resp.Error)
}
if resp, ok := out.(*api.Response[marketplace.Module]); ok && !resp.Success {
return responseError(resp.Error)
}
if resp, ok := out.(*api.Response[PackageInstallResult]); ok && !resp.Success {
return responseError(resp.Error)
}
return nil
}
func responseError(errObj *api.Error) error {
if errObj == nil {
return fmt.Errorf("marketplace request failed")
}
return &marketplaceAPIError{
Code: errObj.Code,
Message: errObj.Message,
Details: errObj.Details,
}
}
func marketplaceErrorCode(err error, fallback string) string {
if err == nil {
return fallback
}
if apiErr, ok := err.(*marketplaceAPIError); ok && apiErr.Code != "" {
return apiErr.Code
}
return fallback
}
func marketplaceErrorMessage(err error) string {
if err == nil {
return "marketplace request failed"
}
if apiErr, ok := err.(*marketplaceAPIError); ok {
if apiErr.Details != nil {
return fmt.Sprintf("%s (%v)", apiErr.Message, apiErr.Details)
}
return apiErr.Message
}
return err.Error()
}
func marketplaceErrorStatus(err error) int {
if apiErr, ok := err.(*marketplaceAPIError); ok {
if apiErr.Code == "not_found" {
return http.StatusNotFound
}
}
msg := strings.ToLower(err.Error())
if strings.Contains(msg, "not found") || strings.Contains(msg, "404") {
return http.StatusNotFound
}
return http.StatusBadGateway
}

142
packages_test.go Normal file
View file

@ -0,0 +1,142 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"dappco.re/go/core/scm/marketplace"
"forge.lthn.ai/core/api"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPackageToolsAPI_Name(t *testing.T) {
apiSvc := NewPackageToolsAPI(nil)
assert.Equal(t, "pkg-tools-api", apiSvc.Name())
assert.Equal(t, "/api/v1/pkg", apiSvc.BasePath())
}
func TestPackageToolsAPI_Search_Good(t *testing.T) {
gin.SetMode(gin.TestMode)
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/scm/marketplace", r.URL.Path)
assert.Equal(t, "alpha", r.URL.Query().Get("q"))
assert.Equal(t, "tool", r.URL.Query().Get("category"))
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(api.OK([]marketplace.Module{
{Code: "alpha", Name: "Alpha", Category: "tool"},
}))
}))
defer upstream.Close()
router := gin.New()
apiSvc := NewPackageToolsAPI(NewMarketplaceClient(upstream.URL + "/api/v1/scm"))
rg := router.Group(apiSvc.BasePath())
apiSvc.RegisterRoutes(rg)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/api/v1/pkg/search?q=alpha&category=tool", nil)
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var resp api.Response[PackageSearchResponse]
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
require.True(t, resp.Success)
require.Len(t, resp.Data.Packages, 1)
assert.Equal(t, "alpha", resp.Data.Packages[0].Code)
}
func TestPackageToolsAPI_Info_Good(t *testing.T) {
gin.SetMode(gin.TestMode)
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/scm/marketplace/alpha", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(api.OK(marketplace.Module{
Code: "alpha",
Name: "Alpha",
}))
}))
defer upstream.Close()
router := gin.New()
apiSvc := NewPackageToolsAPI(NewMarketplaceClient(upstream.URL + "/api/v1/scm"))
rg := router.Group(apiSvc.BasePath())
apiSvc.RegisterRoutes(rg)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/api/v1/pkg/info/alpha", nil)
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var resp api.Response[PackageInfoResponse]
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
require.True(t, resp.Success)
assert.Equal(t, "alpha", resp.Data.Package.Code)
}
func TestPackageToolsAPI_Info_NotFound(t *testing.T) {
gin.SetMode(gin.TestMode)
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(api.Fail("not_found", "provider not found in marketplace"))
}))
defer upstream.Close()
router := gin.New()
apiSvc := NewPackageToolsAPI(NewMarketplaceClient(upstream.URL + "/api/v1/scm"))
rg := router.Group(apiSvc.BasePath())
apiSvc.RegisterRoutes(rg)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/api/v1/pkg/info/missing", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
var resp api.Response[PackageInfoResponse]
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.False(t, resp.Success)
require.NotNil(t, resp.Error)
assert.Equal(t, "not_found", resp.Error.Code)
}
func TestPackageToolsAPI_Install_Good(t *testing.T) {
gin.SetMode(gin.TestMode)
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "/api/v1/scm/marketplace/alpha/install", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(api.OK(PackageInstallResult{
Installed: true,
Code: "alpha",
}))
}))
defer upstream.Close()
router := gin.New()
apiSvc := NewPackageToolsAPI(NewMarketplaceClient(upstream.URL + "/api/v1/scm"))
rg := router.Group(apiSvc.BasePath())
apiSvc.RegisterRoutes(rg)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, "/api/v1/pkg/install/alpha", nil)
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var resp api.Response[PackageInstallResult]
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
require.True(t, resp.Success)
assert.True(t, resp.Data.Installed)
assert.Equal(t, "alpha", resp.Data.Code)
}

View file

@ -3,6 +3,7 @@
package main
import (
"fmt"
"net/http"
"forge.lthn.ai/core/api/pkg/provider"
@ -40,15 +41,26 @@ func (p *ProvidersAPI) list(c *gin.Context) {
registryInfo := p.registry.Info()
runtimeInfo := p.runtime.List()
// Merge runtime provider info with registry info
// Merge registry and runtime provider data without duplication.
providers := make([]providerDTO, 0, len(registryInfo)+len(runtimeInfo))
seen := make(map[string]struct{}, len(registryInfo)+len(runtimeInfo))
providerKey := func(name, namespace string) string {
return fmt.Sprintf("%s|%s", name, namespace)
}
for _, info := range registryInfo {
key := providerKey(info.Name, info.BasePath)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
dto := providerDTO{
Name: info.Name,
BasePath: info.BasePath,
Channels: info.Channels,
Status: "active",
Channels: info.Channels,
}
if info.Element != nil {
dto.Element = &elementDTO{
@ -61,20 +73,20 @@ func (p *ProvidersAPI) list(c *gin.Context) {
// Add runtime providers not already in registry
for _, ri := range runtimeInfo {
found := false
for _, p := range providers {
if p.Name == ri.Code {
found = true
break
}
}
if !found {
providers = append(providers, providerDTO{
Name: ri.Code,
BasePath: ri.Namespace,
Status: "active",
})
key := providerKey(ri.Code, ri.Namespace)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
providers = append(providers, providerDTO{
Name: ri.Code,
BasePath: ri.Namespace,
Status: ri.Status,
Code: ri.Code,
Version: ri.Version,
Namespace: ri.Namespace,
})
}
c.JSON(http.StatusOK, providersResponse{Providers: providers})
@ -85,11 +97,14 @@ type providersResponse struct {
}
type providerDTO struct {
Name string `json:"name"`
BasePath string `json:"basePath"`
Status string `json:"status,omitempty"`
Element *elementDTO `json:"element,omitempty"`
Channels []string `json:"channels,omitempty"`
Name string `json:"name"`
BasePath string `json:"basePath"`
Status string `json:"status,omitempty"`
Code string `json:"code,omitempty"`
Version string `json:"version,omitempty"`
Namespace string `json:"namespace,omitempty"`
Element *elementDTO `json:"element,omitempty"`
Channels []string `json:"channels,omitempty"`
}
type elementDTO struct {

86
providers_test.go Normal file
View file

@ -0,0 +1,86 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"dappco.re/go/core/scm/manifest"
"forge.lthn.ai/core/api/pkg/provider"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestProvidersAPI_Name(t *testing.T) {
api := NewProvidersAPI(nil, nil)
assert.Equal(t, "providers-api", api.Name())
}
func TestProvidersAPI_BasePath(t *testing.T) {
api := NewProvidersAPI(nil, nil)
assert.Equal(t, "/api/v1/providers", api.BasePath())
}
func TestProvidersAPI_List_Good_Empty(t *testing.T) {
gin.SetMode(gin.TestMode)
reg := provider.NewRegistry()
rm := NewRuntimeManager(nil)
api := NewProvidersAPI(reg, rm)
router := gin.New()
rg := router.Group(api.BasePath())
api.RegisterRoutes(rg)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/providers", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp providersResponse
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
assert.Empty(t, resp.Providers)
}
func TestProvidersAPI_List_Good_WithRuntimeProviders(t *testing.T) {
gin.SetMode(gin.TestMode)
reg := provider.NewRegistry()
rm := NewRuntimeManager(nil)
// Simulate a runtime provider.
rm.providers = append(rm.providers, &RuntimeProvider{
Dir: "/tmp/test",
Port: 9999,
Manifest: &manifest.Manifest{
Code: "test-provider",
Name: "Test Provider",
Version: "0.1.0",
Namespace: "test",
},
})
api := NewProvidersAPI(reg, rm)
router := gin.New()
rg := router.Group(api.BasePath())
api.RegisterRoutes(rg)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/providers", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp providersResponse
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
require.Len(t, resp.Providers, 1)
assert.Equal(t, "test-provider", resp.Providers[0].Name)
assert.Equal(t, "test", resp.Providers[0].BasePath)
assert.Equal(t, "running", resp.Providers[0].Status)
}

View file

@ -13,10 +13,11 @@ import (
"sync"
"time"
coreerr "dappco.re/go/core/log"
"dappco.re/go/core/scm/manifest"
"dappco.re/go/core/scm/marketplace"
"forge.lthn.ai/core/api"
"forge.lthn.ai/core/api/pkg/provider"
"forge.lthn.ai/core/go-scm/manifest"
"forge.lthn.ai/core/go-scm/marketplace"
"github.com/gin-gonic/gin"
)
@ -57,13 +58,10 @@ func defaultProvidersDir() string {
// Providers that fail to start are logged and skipped — they do not prevent
// other providers from starting.
func (rm *RuntimeManager) StartAll(ctx context.Context) error {
rm.mu.Lock()
defer rm.mu.Unlock()
dir := defaultProvidersDir()
discovered, err := marketplace.DiscoverProviders(dir)
if err != nil {
return fmt.Errorf("runtime: discover providers: %w", err)
return coreerr.E("runtime.StartAll", "discover providers", err)
}
if len(discovered) == 0 {
@ -72,28 +70,44 @@ func (rm *RuntimeManager) StartAll(ctx context.Context) error {
}
log.Printf("runtime: discovered %d provider(s) in %s", len(discovered), dir)
started := make([]*RuntimeProvider, 0, len(discovered))
seen := make(map[string]struct{}, len(discovered))
for _, dp := range discovered {
key := fmt.Sprintf("%s|%s", dp.Manifest.Code, dp.Manifest.Namespace)
if _, ok := seen[key]; ok {
log.Printf("runtime: skipped duplicate provider discovery for %s", dp.Manifest.Code)
continue
}
rp, err := rm.startProvider(ctx, dp)
if err != nil {
log.Printf("runtime: failed to start %s: %v", dp.Manifest.Code, err)
continue
}
rm.providers = append(rm.providers, rp)
seen[key] = struct{}{}
started = append(started, rp)
log.Printf("runtime: started %s on port %d", dp.Manifest.Code, rp.Port)
}
rm.mu.Lock()
rm.providers = append(rm.providers, started...)
rm.mu.Unlock()
return nil
}
// startProvider starts a single provider binary and registers its proxy.
func (rm *RuntimeManager) startProvider(ctx context.Context, dp marketplace.DiscoveredProvider) (*RuntimeProvider, error) {
m := dp.Manifest
if rm.engine == nil {
return nil, coreerr.E("runtime.startProvider", "runtime engine not configured", nil)
}
// Assign a free port.
port, err := findFreePort()
if err != nil {
return nil, fmt.Errorf("find free port: %w", err)
return nil, coreerr.E("runtime.startProvider", "find free port", err)
}
// Resolve binary path.
@ -114,15 +128,15 @@ func (rm *RuntimeManager) startProvider(ctx context.Context, dp marketplace.Disc
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("start binary %s: %w", binaryPath, err)
return nil, coreerr.E("runtime.startProvider", fmt.Sprintf("start binary %s", binaryPath), err)
}
// Wait for health check.
healthURL := fmt.Sprintf("http://127.0.0.1:%d/health", port)
if err := waitForHealth(healthURL, 10*time.Second); err != nil {
// Kill the process if health check fails.
_ = cmd.Process.Kill()
return nil, fmt.Errorf("health check failed for %s: %w", m.Code, err)
if err := waitForHealth(ctx, healthURL, 10*time.Second); err != nil {
// Stop the process if health check fails.
stopProviderProcess(cmd, 2*time.Second)
return nil, coreerr.E("runtime.startProvider", fmt.Sprintf("health check failed for %s", m.Code), err)
}
// Register proxy provider.
@ -170,27 +184,20 @@ func (rm *RuntimeManager) startProvider(ctx context.Context, dp marketplace.Disc
// StopAll terminates all running provider processes.
func (rm *RuntimeManager) StopAll() {
rm.mu.Lock()
defer rm.mu.Unlock()
providers := rm.providers
rm.providers = nil
rm.mu.Unlock()
for _, rp := range rm.providers {
for _, rp := range providers {
if rp.Cmd != nil && rp.Cmd.Process != nil {
log.Printf("runtime: stopping %s (pid %d)", rp.Manifest.Code, rp.Cmd.Process.Pid)
_ = rp.Cmd.Process.Signal(os.Interrupt)
// Give the process 5 seconds to exit gracefully.
done := make(chan error, 1)
go func() { done <- rp.Cmd.Wait() }()
select {
case <-done:
// Exited cleanly.
case <-time.After(5 * time.Second):
_ = rp.Cmd.Process.Kill()
name := ""
if rp.Manifest != nil {
name = rp.Manifest.Code
}
log.Printf("runtime: stopping provider (%s) pid %d", name, rp.Cmd.Process.Pid)
stopProviderProcess(rp.Cmd, 5*time.Second)
}
}
rm.providers = nil
}
// List returns a copy of all running provider info.
@ -207,6 +214,7 @@ func (rm *RuntimeManager) List() []RuntimeProviderInfo {
Namespace: rp.Manifest.Namespace,
Port: rp.Port,
Dir: rp.Dir,
Status: "running",
})
}
return infos
@ -220,6 +228,7 @@ type RuntimeProviderInfo struct {
Namespace string `json:"namespace"`
Port int `json:"port"`
Dir string `json:"dir"`
Status string `json:"status"`
}
// findFreePort asks the OS for an available TCP port on 127.0.0.1.
@ -229,26 +238,89 @@ func findFreePort() (int, error) {
return 0, err
}
defer l.Close()
return l.Addr().(*net.TCPAddr).Port, nil
tcpAddr, ok := l.Addr().(*net.TCPAddr)
if !ok {
return 0, coreerr.E("runtime.findFreePort", "unexpected address type", nil)
}
return tcpAddr.Port, nil
}
// waitForHealth polls a health URL until it returns 200 or the timeout expires.
func waitForHealth(url string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
client := &http.Client{Timeout: 2 * time.Second}
func waitForHealth(ctx context.Context, url string, timeout time.Duration) error {
if ctx == nil {
ctx = context.Background()
}
if timeout <= 0 {
timeout = 5 * time.Second
}
for time.Now().Before(deadline) {
resp, err := client.Get(url)
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
client := &http.Client{Timeout: 2 * time.Second}
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
req, err := http.NewRequestWithContext(timeoutCtx, http.MethodGet, url, nil)
if err != nil {
return coreerr.E("runtime.waitForHealth", "create health request", err)
}
resp, err := client.Do(req)
if err == nil {
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return nil
}
}
time.Sleep(100 * time.Millisecond)
select {
case <-timeoutCtx.Done():
return coreerr.E(
"runtime.waitForHealth",
fmt.Sprintf("timed out after %s: %s", timeout, url),
timeoutCtx.Err(),
)
case <-ticker.C:
// Keep polling until timeout.
}
}
}
func stopProviderProcess(cmd *exec.Cmd, timeout time.Duration) {
if cmd == nil || cmd.Process == nil {
return
}
return fmt.Errorf("health check timed out after %s: %s", timeout, url)
if timeout <= 0 {
timeout = 1 * time.Second
}
_ = cmd.Process.Signal(os.Interrupt)
if stopProviderProcessWait(cmd, timeout) {
return
}
_ = cmd.Process.Kill()
stopProviderProcessWait(cmd, 2*time.Second)
}
func stopProviderProcessWait(cmd *exec.Cmd, timeout time.Duration) bool {
done := make(chan struct{})
go func() {
_ = cmd.Wait()
close(done)
}()
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case <-done:
return true
case <-timer.C:
return false
}
}
// staticAssetGroup is a simple RouteGroup that serves static files.

131
runtime_test.go Normal file
View file

@ -0,0 +1,131 @@
package main
import (
"context"
"net/http"
"net/http/httptest"
"os/exec"
"testing"
"time"
"dappco.re/go/core/scm/manifest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFindFreePort_Good(t *testing.T) {
port, err := findFreePort()
require.NoError(t, err)
assert.Greater(t, port, 0)
assert.Less(t, port, 65536)
}
func TestFindFreePort_UniquePerCall(t *testing.T) {
port1, err := findFreePort()
require.NoError(t, err)
port2, err := findFreePort()
require.NoError(t, err)
// Two consecutive calls should very likely return different ports.
// (Not guaranteed, but effectively always true.)
assert.NotEqual(t, port1, port2)
}
func TestWaitForHealth_Good(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
err := waitForHealth(context.Background(), srv.URL, 5*time.Second)
assert.NoError(t, err)
}
func TestWaitForHealth_Bad_Timeout(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
}))
defer srv.Close()
err := waitForHealth(context.Background(), srv.URL, 500*time.Millisecond)
require.Error(t, err)
assert.Contains(t, err.Error(), "timed out")
}
func TestWaitForHealth_Bad_NoServer(t *testing.T) {
err := waitForHealth(context.Background(), "http://127.0.0.1:1", 500*time.Millisecond)
require.Error(t, err)
assert.Contains(t, err.Error(), "timed out")
}
func TestDefaultProvidersDir_Good(t *testing.T) {
dir := defaultProvidersDir()
assert.Contains(t, dir, ".core")
assert.Contains(t, dir, "providers")
}
func TestRuntimeManager_List_Good_Empty(t *testing.T) {
rm := NewRuntimeManager(nil)
infos := rm.List()
assert.Empty(t, infos)
}
func TestRuntimeManager_List_Good_WithProviders(t *testing.T) {
rm := NewRuntimeManager(nil)
rm.providers = []*RuntimeProvider{
{
Dir: "/tmp/test-provider",
Port: 12345,
Manifest: &manifest.Manifest{
Code: "test-svc",
Name: "Test Service",
Version: "1.0.0",
Namespace: "test",
},
},
}
infos := rm.List()
require.Len(t, infos, 1)
assert.Equal(t, "test-svc", infos[0].Code)
assert.Equal(t, "Test Service", infos[0].Name)
assert.Equal(t, "1.0.0", infos[0].Version)
assert.Equal(t, "test", infos[0].Namespace)
assert.Equal(t, 12345, infos[0].Port)
assert.Equal(t, "/tmp/test-provider", infos[0].Dir)
assert.Equal(t, "running", infos[0].Status)
}
func TestRuntimeManager_StopAll_Good_Empty(t *testing.T) {
rm := NewRuntimeManager(nil)
// Should not panic with no providers.
rm.StopAll()
assert.Empty(t, rm.providers)
}
func TestRuntimeManager_StopAll_Good_WithProcess(t *testing.T) {
// Start a real process so we can test graceful stop.
cmd := exec.CommandContext(context.Background(), "sleep", "60")
require.NoError(t, cmd.Start())
rm := NewRuntimeManager(nil)
rm.providers = []*RuntimeProvider{
{
Manifest: &manifest.Manifest{Code: "sleeper"},
Cmd: cmd,
},
}
rm.StopAll()
assert.Nil(t, rm.providers)
}
func TestRuntimeManager_StartAll_Good_EmptyDir(t *testing.T) {
rm := NewRuntimeManager(nil)
// StartAll with a non-existent providers dir should return an error
// because the default dir won't have providers (at most it logs and returns nil).
err := rm.StartAll(context.Background())
// Depending on whether ~/.core/providers/ exists, this either returns
// nil (no providers found) or an error (dir doesn't exist).
// Either outcome is acceptable — no panic.
_ = err
}

501
workspace.go Normal file
View file

@ -0,0 +1,501 @@
// SPDX-Licence-Identifier: EUPL-1.2
package main
import (
"bufio"
"errors"
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/gin-gonic/gin"
"gopkg.in/yaml.v3"
)
// WorkspaceAPI exposes project context derived from .core/ and git status.
type WorkspaceAPI struct {
root string
}
func NewWorkspaceAPI(root string) *WorkspaceAPI {
return &WorkspaceAPI{root: root}
}
func (w *WorkspaceAPI) Name() string { return "workspace-api" }
func (w *WorkspaceAPI) BasePath() string { return "/api/v1/workspace" }
func (w *WorkspaceAPI) RegisterRoutes(rg *gin.RouterGroup) {
rg.GET("/status", w.status)
rg.GET("/conventions", w.conventions)
rg.GET("/impact", w.impact)
}
type workspaceStatusResponse struct {
Root string `json:"root"`
Git gitStatusSummary `json:"git"`
CoreFiles []workspaceFile `json:"coreFiles"`
Counts workspaceFileCount `json:"counts"`
UpdatedAt string `json:"updatedAt"`
}
type workspaceConventionsResponse struct {
Root string `json:"root"`
Sources []string `json:"sources"`
Build buildConfigSummary `json:"build"`
Conventions []string `json:"conventions"`
Notes []string `json:"notes"`
}
type workspaceImpactResponse struct {
Root string `json:"root"`
Git gitStatusSummary `json:"git"`
ImpactedAreas []string `json:"impactedAreas"`
SuggestedChecks []string `json:"suggestedChecks"`
Notes []string `json:"notes"`
}
type workspaceFileCount struct {
Total int `json:"total"`
Text int `json:"text"`
}
type workspaceFile struct {
Path string `json:"path"`
Size int64 `json:"size"`
Modified string `json:"modified"`
Preview string `json:"preview,omitempty"`
Content string `json:"content,omitempty"`
IsText bool `json:"isText"`
}
type gitStatusSummary struct {
Branch string `json:"branch,omitempty"`
RawHeader string `json:"rawHeader,omitempty"`
Clean bool `json:"clean"`
Changes []gitChange `json:"changes,omitempty"`
ChangeCounts gitChangeCounts `json:"changeCounts"`
}
type gitChangeCounts struct {
Staged int `json:"staged"`
Unstaged int `json:"unstaged"`
Untracked int `json:"untracked"`
}
type gitChange struct {
Code string `json:"code"`
Path string `json:"path"`
}
type buildConfigSummary struct {
Version string `json:"version,omitempty"`
ProjectName string `json:"projectName,omitempty"`
ProjectDescription string `json:"projectDescription,omitempty"`
Binary string `json:"binary,omitempty"`
BuildType string `json:"buildType,omitempty"`
CGO bool `json:"cgo"`
Flags []string `json:"flags,omitempty"`
Ldflags []string `json:"ldflags,omitempty"`
Targets []buildTarget `json:"targets,omitempty"`
RawFiles []string `json:"rawFiles,omitempty"`
}
type buildTarget struct {
OS string `json:"os,omitempty"`
Arch string `json:"arch,omitempty"`
}
func (w *WorkspaceAPI) status(c *gin.Context) {
snapshot, err := collectWorkspaceSnapshot(w.root)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
c.JSON(200, workspaceStatusResponse{
Root: snapshot.Root,
Git: snapshot.Git,
CoreFiles: snapshot.CoreFiles,
Counts: snapshot.Counts,
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
})
}
func (w *WorkspaceAPI) conventions(c *gin.Context) {
snapshot, err := collectWorkspaceSnapshot(w.root)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
conventions := []string{
"Use UK English in documentation and user-facing strings.",
"Use conventional commits: type(scope): description.",
"Go code lives in package main for this module.",
"Prefer core build for production builds and core go test for Go tests.",
}
notes := []string{
"Design input was derived from CLAUDE.md, docs/development.md, and .core/build.yaml.",
}
if !snapshot.Git.Clean {
notes = append(notes, "The worktree is dirty, so any new work should account for local changes.")
}
c.JSON(200, workspaceConventionsResponse{
Root: snapshot.Root,
Sources: snapshot.SourceFiles,
Build: snapshot.Build,
Conventions: conventions,
Notes: notes,
})
}
func (w *WorkspaceAPI) impact(c *gin.Context) {
snapshot, err := collectWorkspaceSnapshot(w.root)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
impactedAreas := classifyImpact(snapshot.Git.Changes, snapshot.SourceFiles)
suggestedChecks := []string{"go build ./...", "go vet ./...", "go test ./... -count=1 -timeout 120s", "go test -cover ./..."}
if hasPathPrefix(snapshot.SourceFiles, ".core/") {
suggestedChecks = append([]string{"core build"}, suggestedChecks...)
}
if hasAnyImpact(snapshot.Git.Changes, "frontend/") {
suggestedChecks = append(suggestedChecks, "cd frontend && npm test")
}
notes := []string{
"Impact categories are inferred from changed paths and .core configuration files.",
}
if snapshot.Git.Clean {
notes = append(notes, "The worktree is clean, so there is no active change set to assess.")
}
c.JSON(200, workspaceImpactResponse{
Root: snapshot.Root,
Git: snapshot.Git,
ImpactedAreas: impactedAreas,
SuggestedChecks: uniqueStrings(suggestedChecks),
Notes: notes,
})
}
type workspaceSnapshot struct {
Root string
Git gitStatusSummary
CoreFiles []workspaceFile
Counts workspaceFileCount
Build buildConfigSummary
SourceFiles []string
}
func collectWorkspaceSnapshot(root string) (*workspaceSnapshot, error) {
if root == "" {
root = "."
}
absRoot, err := filepath.Abs(root)
if err != nil {
return nil, fmt.Errorf("resolve workspace root: %w", err)
}
coreFiles, counts, sourceFiles, err := readCoreFiles(absRoot)
if err != nil {
return nil, err
}
gitStatus, err := readGitStatus(absRoot)
if err != nil {
return nil, err
}
buildSummary := parseBuildConfig(coreFiles)
return &workspaceSnapshot{
Root: absRoot,
Git: gitStatus,
CoreFiles: coreFiles,
Counts: counts,
Build: buildSummary,
SourceFiles: sourceFiles,
}, nil
}
func readCoreFiles(root string) ([]workspaceFile, workspaceFileCount, []string, error) {
coreDir := filepath.Join(root, ".core")
entries := []workspaceFile{}
sourceFiles := []string{}
counts := workspaceFileCount{}
info, err := os.Stat(coreDir)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return entries, counts, sourceFiles, nil
}
return nil, counts, sourceFiles, fmt.Errorf("inspect .core directory: %w", err)
}
if !info.IsDir() {
return nil, counts, sourceFiles, fmt.Errorf("%s is not a directory", coreDir)
}
err = filepath.WalkDir(coreDir, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
return nil
}
stat, err := d.Info()
if err != nil {
return err
}
rel, err := filepath.Rel(root, path)
if err != nil {
return err
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
text := isLikelyText(data)
if text {
counts.Text++
}
counts.Total++
entries = append(entries, workspaceFile{
Path: filepath.ToSlash(rel),
Size: stat.Size(),
Modified: stat.ModTime().UTC().Format(time.RFC3339),
Preview: filePreview(data, 8, 480),
Content: fileContent(data, 64*1024),
IsText: text,
})
sourceFiles = append(sourceFiles, filepath.ToSlash(rel))
return nil
})
if err != nil {
return nil, counts, sourceFiles, fmt.Errorf("read .core files: %w", err)
}
return entries, counts, sourceFiles, nil
}
func filePreview(data []byte, maxLines int, maxBytes int) string {
if len(data) == 0 {
return ""
}
if len(data) > maxBytes {
data = data[:maxBytes]
}
scanner := bufio.NewScanner(strings.NewReader(string(data)))
lines := make([]string, 0, maxLines)
for scanner.Scan() {
lines = append(lines, scanner.Text())
if len(lines) >= maxLines {
break
}
}
return strings.TrimSpace(strings.Join(lines, "\n"))
}
func fileContent(data []byte, maxBytes int) string {
if len(data) == 0 {
return ""
}
if len(data) > maxBytes {
data = data[:maxBytes]
}
return strings.TrimSpace(string(data))
}
func isLikelyText(data []byte) bool {
for _, b := range data {
if b == 0 {
return false
}
}
return true
}
func readGitStatus(root string) (gitStatusSummary, error) {
cmd := exec.Command("git", "-C", root, "status", "--short", "--branch", "--untracked-files=all")
out, err := cmd.Output()
if err != nil {
return gitStatusSummary{}, fmt.Errorf("git status: %w", err)
}
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
summary := gitStatusSummary{Clean: true}
if len(lines) == 1 && lines[0] == "" {
return summary, nil
}
for i, line := range lines {
if line == "" {
continue
}
if i == 0 && strings.HasPrefix(line, "## ") {
summary.RawHeader = strings.TrimSpace(strings.TrimPrefix(line, "## "))
summary.Branch = parseGitBranch(summary.RawHeader)
continue
}
if len(line) < 3 {
continue
}
change := gitChange{Code: line[:2], Path: strings.TrimSpace(line[3:])}
summary.Changes = append(summary.Changes, change)
summary.Clean = false
switch change.Code {
case "??":
summary.ChangeCounts.Untracked++
default:
if change.Code[0] != ' ' {
summary.ChangeCounts.Staged++
}
if change.Code[1] != ' ' {
summary.ChangeCounts.Unstaged++
}
}
}
return summary, nil
}
func parseGitBranch(header string) string {
if header == "" {
return ""
}
branch := header
if idx := strings.Index(branch, "..."); idx >= 0 {
branch = branch[:idx]
}
branch = strings.TrimSpace(branch)
branch = strings.TrimPrefix(branch, "(detached from ")
branch = strings.TrimSuffix(branch, ")")
return branch
}
func parseBuildConfig(files []workspaceFile) buildConfigSummary {
summary := buildConfigSummary{}
for _, file := range files {
if filepath.Base(file.Path) != "build.yaml" {
continue
}
summary.RawFiles = append(summary.RawFiles, file.Path)
content := file.Content
if content == "" {
content = file.Preview
}
var raw struct {
Version any `yaml:"version"`
Project struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
Binary string `yaml:"binary"`
} `yaml:"project"`
Build struct {
Type string `yaml:"type"`
CGO bool `yaml:"cgo"`
Flags []string `yaml:"flags"`
Ldflags []string `yaml:"ldflags"`
} `yaml:"build"`
Targets []buildTarget `yaml:"targets"`
}
if err := yaml.Unmarshal([]byte(content), &raw); err != nil {
continue
}
summary.Version = fmt.Sprint(raw.Version)
summary.ProjectName = raw.Project.Name
summary.ProjectDescription = raw.Project.Description
summary.Binary = raw.Project.Binary
summary.BuildType = raw.Build.Type
summary.CGO = raw.Build.CGO
summary.Flags = append(summary.Flags, raw.Build.Flags...)
summary.Ldflags = append(summary.Ldflags, raw.Build.Ldflags...)
summary.Targets = append(summary.Targets, raw.Targets...)
break
}
return summary
}
func classifyImpact(changes []gitChange, sourceFiles []string) []string {
areas := []string{}
for _, change := range changes {
path := change.Path
switch {
case strings.HasPrefix(path, ".core/"):
areas = append(areas, "build configuration")
case path == "go.mod" || path == "go.sum":
areas = append(areas, "dependency graph")
case strings.HasSuffix(path, ".go"):
areas = append(areas, "Go backend")
case strings.HasPrefix(path, "frontend/"):
areas = append(areas, "Angular frontend")
case strings.HasPrefix(path, "docs/") || strings.HasSuffix(path, ".md"):
areas = append(areas, "documentation")
case strings.HasPrefix(path, "build/"):
areas = append(areas, "packaging and platform build files")
default:
areas = append(areas, "general project context")
}
}
if hasPathPrefix(sourceFiles, ".core/") {
areas = append(areas, "workspace metadata")
}
if len(changes) == 0 {
areas = append(areas, "no active changes")
}
return uniqueStrings(areas)
}
func hasPathPrefix(paths []string, prefix string) bool {
for _, path := range paths {
if strings.HasPrefix(path, prefix) {
return true
}
}
return false
}
func hasAnyImpact(changes []gitChange, prefix string) bool {
for _, change := range changes {
if strings.HasPrefix(change.Path, prefix) {
return true
}
}
return false
}
func uniqueStrings(values []string) []string {
seen := make(map[string]struct{}, len(values))
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}

98
workspace_test.go Normal file
View file

@ -0,0 +1,98 @@
package main
import (
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestReadCoreFiles_Good(t *testing.T) {
root := t.TempDir()
require.NoError(t, os.Mkdir(filepath.Join(root, ".core"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(root, ".core", "build.yaml"), []byte("version: 1\nproject:\n name: demo\n"), 0o644))
files, counts, sourceFiles, err := readCoreFiles(root)
require.NoError(t, err)
require.Len(t, files, 1)
assert.Equal(t, 1, counts.Total)
assert.Equal(t, 1, counts.Text)
assert.Equal(t, []string{".core/build.yaml"}, sourceFiles)
assert.Equal(t, ".core/build.yaml", files[0].Path)
assert.True(t, files[0].IsText)
}
func TestParseBuildConfig_Good(t *testing.T) {
content, err := os.ReadFile(filepath.Join(".core", "build.yaml"))
require.NoError(t, err)
files := []workspaceFile{{Path: ".core/build.yaml", Content: string(content)}}
summary := parseBuildConfig(files)
assert.Equal(t, "1", summary.Version)
assert.Equal(t, "core-ide", summary.ProjectName)
assert.Equal(t, "Core IDE - Development Environment", summary.ProjectDescription)
assert.Equal(t, "core-ide", summary.Binary)
assert.Equal(t, "wails", summary.BuildType)
assert.True(t, summary.CGO)
assert.Equal(t, []string{"-trimpath"}, summary.Flags)
assert.Equal(t, []string{"-s", "-w"}, summary.Ldflags)
require.Len(t, summary.Targets, 3)
assert.Equal(t, "darwin", summary.Targets[0].OS)
assert.Equal(t, "arm64", summary.Targets[0].Arch)
assert.Equal(t, "linux", summary.Targets[1].OS)
assert.Equal(t, "amd64", summary.Targets[1].Arch)
assert.Equal(t, "windows", summary.Targets[2].OS)
assert.Equal(t, "amd64", summary.Targets[2].Arch)
}
func TestReadGitStatus_Good(t *testing.T) {
root := t.TempDir()
runGit(t, root, "init")
runGit(t, root, "config", "user.email", "test@example.com")
runGit(t, root, "config", "user.name", "Test User")
require.NoError(t, os.WriteFile(filepath.Join(root, "README.md"), []byte("hello\n"), 0o644))
runGit(t, root, "add", "README.md")
runGit(t, root, "commit", "-m", "initial")
require.NoError(t, os.WriteFile(filepath.Join(root, "README.md"), []byte("hello world\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(root, "new.txt"), []byte("new\n"), 0o644))
status, err := readGitStatus(root)
require.NoError(t, err)
assert.False(t, status.Clean)
assert.NotEmpty(t, status.Branch)
assert.GreaterOrEqual(t, len(status.Changes), 2)
assert.GreaterOrEqual(t, status.ChangeCounts.Unstaged, 1)
assert.GreaterOrEqual(t, status.ChangeCounts.Untracked, 1)
}
func TestClassifyImpact_Good(t *testing.T) {
areas := classifyImpact([]gitChange{
{Path: ".core/build.yaml"},
{Path: "main.go"},
{Path: "frontend/src/app/app.ts"},
{Path: "docs/index.md"},
}, []string{".core/build.yaml"})
assert.Contains(t, areas, "build configuration")
assert.Contains(t, areas, "Go backend")
assert.Contains(t, areas, "Angular frontend")
assert.Contains(t, areas, "documentation")
assert.Contains(t, areas, "workspace metadata")
}
func TestWorkspaceAPI_BasePath(t *testing.T) {
api := NewWorkspaceAPI(".")
assert.Equal(t, "workspace-api", api.Name())
assert.Equal(t, "/api/v1/workspace", api.BasePath())
}
func runGit(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
out, err := cmd.CombinedOutput()
require.NoError(t, err, string(out))
}