Compare commits

..

6 commits
main ... 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
17 changed files with 2078 additions and 123 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 package main
@ -7,4 +7,4 @@ import "github.com/wailsapp/wails/v3/pkg/application"
// modifyOptionsForIOS is a no-op on non-iOS platforms // modifyOptionsForIOS is a no-op on non-iOS platforms
func modifyOptionsForIOS(opts *application.Options) { func modifyOptionsForIOS(opts *application.Options) {
// No modifications needed for non-iOS platforms // No modifications needed for non-iOS platforms
} }

View file

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

101
go.mod
View file

@ -4,24 +4,65 @@ go 1.26.0
require ( require (
dappco.re/go/core/scm v0.4.0 dappco.re/go/core/scm v0.4.0
forge.lthn.ai/core/api v0.1.5 dappco.re/go/core/api v0.1.5
forge.lthn.ai/core/config v0.1.8 dappco.re/go/core/config v0.1.8
forge.lthn.ai/core/go v0.3.3 dappco.re/go/core v0.3.3
forge.lthn.ai/core/go-process v0.2.7 dappco.re/go/core/process v0.2.7
forge.lthn.ai/core/go-ws v0.2.3 dappco.re/go/core/ws v0.2.3
forge.lthn.ai/core/gui v0.1.3 dappco.re/go/core/gui v0.1.3
forge.lthn.ai/core/mcp v0.3.2 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/stretchr/testify v1.11.1
github.com/wailsapp/wails/v3 v3.0.0-alpha.74 github.com/wailsapp/wails/v3 v3.0.0-alpha.74
) )
require ( require (
forge.lthn.ai/core/go-log v0.0.4 // indirect 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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dustin/go-humanize v1.0.1 // 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/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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect
@ -31,20 +72,15 @@ require (
require ( require (
dappco.re/go/core/io v0.2.0 // indirect dappco.re/go/core/io v0.2.0 // indirect
dappco.re/go/core/log v0.1.0 dappco.re/go/core/log v0.1.0
dario.cat/mergo v1.0.2 // indirect dappco.re/go/core/ai v0.1.11 // indirect
forge.lthn.ai/core/go-ai v0.1.11 // indirect dappco.re/go/core/io v0.1.7 // indirect
forge.lthn.ai/core/go-io v0.1.7 // indirect dappco.re/go/core/rag v0.1.11 // indirect
forge.lthn.ai/core/go-rag v0.1.11 // indirect dappco.re/go/core/webview v0.1.5 // indirect
forge.lthn.ai/core/go-webview v0.1.5 // indirect
github.com/99designs/gqlgen v0.17.88 // indirect github.com/99designs/gqlgen v0.17.88 // indirect
github.com/KyleBanks/depth v1.2.1 // 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/agnivade/levenshtein v1.2.1 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect github.com/andybalholm/brotli v1.2.0 // indirect
github.com/bahlo/generic-list-go v0.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/bmatcuk/doublestar/v4 v4.10.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect github.com/buger/jsonparser v1.1.1 // indirect
github.com/bytedance/gopkg v0.1.4 // indirect github.com/bytedance/gopkg v0.1.4 // indirect
@ -53,14 +89,9 @@ require (
github.com/casbin/casbin/v2 v2.135.0 // indirect github.com/casbin/casbin/v2 v2.135.0 // indirect
github.com/casbin/govaluate v1.10.0 // indirect github.com/casbin/govaluate v1.10.0 // indirect
github.com/cespare/xxhash/v2 v2.3.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/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/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/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/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gin-contrib/authz v1.0.6 // indirect github.com/gin-contrib/authz v1.0.6 // indirect
@ -77,13 +108,9 @@ require (
github.com/gin-contrib/static v1.1.5 // indirect github.com/gin-contrib/static v1.1.5 // indirect
github.com/gin-contrib/timeout v1.1.0 // indirect github.com/gin-contrib/timeout v1.1.0 // indirect
github.com/gin-gonic/gin v1.12.0 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-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // 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/jsonpointer v0.22.5 // indirect
github.com/go-openapi/jsonreference v0.21.5 // indirect github.com/go-openapi/jsonreference v0.21.5 // indirect
github.com/go-openapi/spec v0.22.4 // indirect github.com/go-openapi/spec v0.22.4 // indirect
@ -100,8 +127,6 @@ require (
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-yaml v1.19.2 // 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/jsonschema-go v0.4.2 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/context v1.1.2 // indirect github.com/gorilla/context v1.1.2 // indirect
@ -109,36 +134,23 @@ require (
github.com/gorilla/sessions v1.4.0 // indirect github.com/gorilla/sessions v1.4.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect github.com/gorilla/websocket v1.5.3 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // 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/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/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/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/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/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/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/ollama/ollama v0.18.1 // indirect github.com/ollama/ollama v0.18.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // 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/qdrant/go-client v1.17.1 // indirect
github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect
github.com/redis/go-redis/v9 v9.18.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/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/asm v1.2.1 // indirect
github.com/segmentio/encoding v0.5.4 // 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/sosodev/duration v1.4.0 // indirect
github.com/spf13/afero v1.15.0 // indirect github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect github.com/spf13/cast v1.10.0 // indirect
@ -151,9 +163,7 @@ require (
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect
github.com/vektah/gqlparser/v2 v2.5.32 // 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/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 github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect
@ -176,6 +186,5 @@ require (
google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect
google.golang.org/grpc v1.79.2 // indirect google.golang.org/grpc v1.79.2 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v3 v3.0.1
gopkg.in/yaml.v3 v3.0.1 // indirect
) )

26
go.sum
View file

@ -53,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/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 h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= 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 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= 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= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
@ -215,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/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 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 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= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@ -244,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/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 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY=
github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= 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 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
@ -263,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/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 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M=
github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= 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.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 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
@ -270,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-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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc=
github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s= 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= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@ -285,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/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 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 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 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
@ -372,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 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= 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/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 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= 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= go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
@ -443,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.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 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 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-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.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@ -462,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 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= 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-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 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= 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= 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") != ""
}

31
main.go
View file

@ -1,3 +1,5 @@
//go:build !linux
package main package main
import ( import (
@ -61,6 +63,11 @@ func main() {
} }
bridge := ide.NewBridge(hub, bridgeCfg) bridge := ide.NewBridge(hub, bridgeCfg)
brainDirect, err := NewCachedBrainDirect(cwd)
if err != nil {
log.Fatalf("failed to initialise brain cache: %v", err)
}
// ── Service Provider Registry ────────────────────────────── // ── Service Provider Registry ──────────────────────────────
reg := provider.NewRegistry() reg := provider.NewRegistry()
reg.Add(processapi.NewProvider(process.DefaultRegistry(), hub)) reg.Add(processapi.NewProvider(process.DefaultRegistry(), hub))
@ -85,6 +92,8 @@ func main() {
// ── Providers API ───────────────────────────────────────── // ── Providers API ─────────────────────────────────────────
// Exposes GET /api/v1/providers for the Angular frontend // Exposes GET /api/v1/providers for the Angular frontend
engine.Register(NewProvidersAPI(reg, rm)) engine.Register(NewProvidersAPI(reg, rm))
engine.Register(NewWorkspaceAPI(cwd))
engine.Register(NewPackageToolsAPI(nil))
// ── Core framework ───────────────────────────────────────── // ── Core framework ─────────────────────────────────────────
c, err := core.New( c, err := core.New(
@ -96,7 +105,7 @@ func main() {
return mcp.New( return mcp.New(
mcp.WithWorkspaceRoot(cwd), mcp.WithWorkspaceRoot(cwd),
mcp.WithWSHub(hub), mcp.WithWSHub(hub),
mcp.WithSubsystem(brain.NewDirect()), mcp.WithSubsystem(brainDirect),
mcp.WithSubsystem(agentic.NewPrep()), mcp.WithSubsystem(agentic.NewPrep()),
mcp.WithSubsystem(guiMCP.New(c)), mcp.WithSubsystem(guiMCP.New(c)),
) )
@ -242,7 +251,7 @@ func main() {
Title: "Core IDE", Title: "Core IDE",
Width: 1280, Width: 1280,
Height: 800, Height: 800,
URL: "/", URL: "/ide",
Hidden: true, Hidden: true,
BackgroundColour: application.NewRGB(26, 27, 38), BackgroundColour: application.NewRGB(26, 27, 38),
}) })
@ -298,21 +307,3 @@ func main() {
log.Fatal(err) 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)
}

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

View file

@ -82,5 +82,5 @@ func TestProvidersAPI_List_Good_WithRuntimeProviders(t *testing.T) {
require.Len(t, resp.Providers, 1) require.Len(t, resp.Providers, 1)
assert.Equal(t, "test-provider", resp.Providers[0].Name) assert.Equal(t, "test-provider", resp.Providers[0].Name)
assert.Equal(t, "test", resp.Providers[0].BasePath) assert.Equal(t, "test", resp.Providers[0].BasePath)
assert.Equal(t, "active", resp.Providers[0].Status) assert.Equal(t, "running", resp.Providers[0].Status)
} }

View file

@ -58,9 +58,6 @@ func defaultProvidersDir() string {
// Providers that fail to start are logged and skipped — they do not prevent // Providers that fail to start are logged and skipped — they do not prevent
// other providers from starting. // other providers from starting.
func (rm *RuntimeManager) StartAll(ctx context.Context) error { func (rm *RuntimeManager) StartAll(ctx context.Context) error {
rm.mu.Lock()
defer rm.mu.Unlock()
dir := defaultProvidersDir() dir := defaultProvidersDir()
discovered, err := marketplace.DiscoverProviders(dir) discovered, err := marketplace.DiscoverProviders(dir)
if err != nil { if err != nil {
@ -73,23 +70,39 @@ func (rm *RuntimeManager) StartAll(ctx context.Context) error {
} }
log.Printf("runtime: discovered %d provider(s) in %s", len(discovered), dir) 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 { 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) rp, err := rm.startProvider(ctx, dp)
if err != nil { if err != nil {
log.Printf("runtime: failed to start %s: %v", dp.Manifest.Code, err) log.Printf("runtime: failed to start %s: %v", dp.Manifest.Code, err)
continue 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) 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 return nil
} }
// startProvider starts a single provider binary and registers its proxy. // startProvider starts a single provider binary and registers its proxy.
func (rm *RuntimeManager) startProvider(ctx context.Context, dp marketplace.DiscoveredProvider) (*RuntimeProvider, error) { func (rm *RuntimeManager) startProvider(ctx context.Context, dp marketplace.DiscoveredProvider) (*RuntimeProvider, error) {
m := dp.Manifest m := dp.Manifest
if rm.engine == nil {
return nil, coreerr.E("runtime.startProvider", "runtime engine not configured", nil)
}
// Assign a free port. // Assign a free port.
port, err := findFreePort() port, err := findFreePort()
@ -120,9 +133,9 @@ func (rm *RuntimeManager) startProvider(ctx context.Context, dp marketplace.Disc
// Wait for health check. // Wait for health check.
healthURL := fmt.Sprintf("http://127.0.0.1:%d/health", port) healthURL := fmt.Sprintf("http://127.0.0.1:%d/health", port)
if err := waitForHealth(healthURL, 10*time.Second); err != nil { if err := waitForHealth(ctx, healthURL, 10*time.Second); err != nil {
// Kill the process if health check fails. // Stop the process if health check fails.
_ = cmd.Process.Kill() stopProviderProcess(cmd, 2*time.Second)
return nil, coreerr.E("runtime.startProvider", fmt.Sprintf("health check failed for %s", m.Code), err) return nil, coreerr.E("runtime.startProvider", fmt.Sprintf("health check failed for %s", m.Code), err)
} }
@ -171,27 +184,20 @@ func (rm *RuntimeManager) startProvider(ctx context.Context, dp marketplace.Disc
// StopAll terminates all running provider processes. // StopAll terminates all running provider processes.
func (rm *RuntimeManager) StopAll() { func (rm *RuntimeManager) StopAll() {
rm.mu.Lock() 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 { if rp.Cmd != nil && rp.Cmd.Process != nil {
log.Printf("runtime: stopping %s (pid %d)", rp.Manifest.Code, rp.Cmd.Process.Pid) name := ""
_ = rp.Cmd.Process.Signal(os.Interrupt) if rp.Manifest != nil {
name = rp.Manifest.Code
// 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()
} }
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. // List returns a copy of all running provider info.
@ -208,6 +214,7 @@ func (rm *RuntimeManager) List() []RuntimeProviderInfo {
Namespace: rp.Manifest.Namespace, Namespace: rp.Manifest.Namespace,
Port: rp.Port, Port: rp.Port,
Dir: rp.Dir, Dir: rp.Dir,
Status: "running",
}) })
} }
return infos return infos
@ -221,6 +228,7 @@ type RuntimeProviderInfo struct {
Namespace string `json:"namespace"` Namespace string `json:"namespace"`
Port int `json:"port"` Port int `json:"port"`
Dir string `json:"dir"` Dir string `json:"dir"`
Status string `json:"status"`
} }
// findFreePort asks the OS for an available TCP port on 127.0.0.1. // findFreePort asks the OS for an available TCP port on 127.0.0.1.
@ -238,22 +246,81 @@ func findFreePort() (int, error) {
} }
// waitForHealth polls a health URL until it returns 200 or the timeout expires. // waitForHealth polls a health URL until it returns 200 or the timeout expires.
func waitForHealth(url string, timeout time.Duration) error { func waitForHealth(ctx context.Context, url string, timeout time.Duration) error {
deadline := time.Now().Add(timeout) if ctx == nil {
client := &http.Client{Timeout: 2 * time.Second} ctx = context.Background()
}
if timeout <= 0 {
timeout = 5 * time.Second
}
for time.Now().Before(deadline) { timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
resp, err := client.Get(url) 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 { if err == nil {
resp.Body.Close() resp.Body.Close()
if resp.StatusCode == http.StatusOK { if resp.StatusCode == http.StatusOK {
return nil 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 coreerr.E("runtime.waitForHealth", fmt.Sprintf("timed out after %s: %s", timeout, url), nil) 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. // staticAssetGroup is a simple RouteGroup that serves static files.

View file

@ -36,7 +36,7 @@ func TestWaitForHealth_Good(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
err := waitForHealth(srv.URL, 5*time.Second) err := waitForHealth(context.Background(), srv.URL, 5*time.Second)
assert.NoError(t, err) assert.NoError(t, err)
} }
@ -46,13 +46,13 @@ func TestWaitForHealth_Bad_Timeout(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
err := waitForHealth(srv.URL, 500*time.Millisecond) err := waitForHealth(context.Background(), srv.URL, 500*time.Millisecond)
require.Error(t, err) require.Error(t, err)
assert.Contains(t, err.Error(), "timed out") assert.Contains(t, err.Error(), "timed out")
} }
func TestWaitForHealth_Bad_NoServer(t *testing.T) { func TestWaitForHealth_Bad_NoServer(t *testing.T) {
err := waitForHealth("http://127.0.0.1:1", 500*time.Millisecond) err := waitForHealth(context.Background(), "http://127.0.0.1:1", 500*time.Millisecond)
require.Error(t, err) require.Error(t, err)
assert.Contains(t, err.Error(), "timed out") assert.Contains(t, err.Error(), "timed out")
} }
@ -79,7 +79,7 @@ func TestRuntimeManager_List_Good_WithProviders(t *testing.T) {
Code: "test-svc", Code: "test-svc",
Name: "Test Service", Name: "Test Service",
Version: "1.0.0", Version: "1.0.0",
Namespace: "test", Namespace: "test",
}, },
}, },
} }
@ -92,6 +92,7 @@ func TestRuntimeManager_List_Good_WithProviders(t *testing.T) {
assert.Equal(t, "test", infos[0].Namespace) assert.Equal(t, "test", infos[0].Namespace)
assert.Equal(t, 12345, infos[0].Port) assert.Equal(t, 12345, infos[0].Port)
assert.Equal(t, "/tmp/test-provider", infos[0].Dir) assert.Equal(t, "/tmp/test-provider", infos[0].Dir)
assert.Equal(t, "running", infos[0].Status)
} }
func TestRuntimeManager_StopAll_Good_Empty(t *testing.T) { func TestRuntimeManager_StopAll_Good_Empty(t *testing.T) {

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))
}