Compare commits
15 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
309de8d719 | ||
|
|
1b0e197d71 | ||
|
|
7ab16075c4 | ||
|
|
efed2fc3ec | ||
|
|
9f97d256cf | ||
|
|
f361fd69f6 | ||
|
|
0669feb69b | ||
|
|
16dcb1643d | ||
| 2059e8c955 | |||
|
|
daa8b1e477 | ||
|
|
ff717ef516 | ||
|
|
800b75ee90 | ||
|
|
cc7504892f | ||
| 98483cb7af | |||
|
|
e1024744a4 |
18 changed files with 2361 additions and 143 deletions
467
brain_direct.go
Normal file
467
brain_direct.go
Normal 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
148
brain_direct_test.go
Normal 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())
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
110
go.mod
|
|
@ -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
58
go.sum
|
|
@ -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
26
gui_enabled.go
Normal 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
35
main.go
|
|
@ -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
142
main_linux.go
Normal 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
33
main_test.go
Normal 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
318
packages.go
Normal 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
142
packages_test.go
Normal 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)
|
||||
}
|
||||
55
providers.go
55
providers.go
|
|
@ -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
86
providers_test.go
Normal 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)
|
||||
}
|
||||
146
runtime.go
146
runtime.go
|
|
@ -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
131
runtime_test.go
Normal 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
501
workspace.go
Normal 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
98
workspace_test.go
Normal 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))
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue