Merge pull request '[agent/codex:gpt-5.4-mini] Read ~/spec/code/core/ide/RFC.md §3.2 (Workspace Tools). Im...' (#9) from agent/read---spec-code-core-ide-rfc-md--3-2--w into dev
This commit is contained in:
commit
7518c53bfa
8 changed files with 800 additions and 54 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
66
go.mod
66
go.mod
|
|
@ -16,12 +16,44 @@ require (
|
|||
)
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
forge.lthn.ai/core/go-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/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/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/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/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
|
||||
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
|
||||
|
|
@ -31,20 +63,15 @@ require (
|
|||
require (
|
||||
dappco.re/go/core/io v0.2.0 // indirect
|
||||
dappco.re/go/core/log v0.1.0
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
forge.lthn.ai/core/go-ai v0.1.11 // indirect
|
||||
forge.lthn.ai/core/go-io v0.1.7 // indirect
|
||||
forge.lthn.ai/core/go-rag v0.1.11 // indirect
|
||||
forge.lthn.ai/core/go-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
|
||||
|
|
@ -53,14 +80,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
|
||||
|
|
@ -77,13 +99,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
|
||||
|
|
@ -100,8 +118,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
|
||||
|
|
@ -109,36 +125,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/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
|
||||
|
|
@ -151,9 +154,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
|
||||
|
|
@ -176,6 +177,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
|
||||
)
|
||||
|
|
|
|||
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") != ""
|
||||
}
|
||||
21
main.go
21
main.go
|
|
@ -1,3 +1,5 @@
|
|||
//go:build !linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
|
|
@ -85,6 +87,7 @@ func main() {
|
|||
// ── Providers API ─────────────────────────────────────────
|
||||
// Exposes GET /api/v1/providers for the Angular frontend
|
||||
engine.Register(NewProvidersAPI(reg, rm))
|
||||
engine.Register(NewWorkspaceAPI(cwd))
|
||||
|
||||
// ── Core framework ─────────────────────────────────────────
|
||||
c, err := core.New(
|
||||
|
|
@ -298,21 +301,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") != ""
|
||||
}
|
||||
|
|
|
|||
136
main_linux.go
Normal file
136
main_linux.go
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
//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)
|
||||
|
||||
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))
|
||||
|
||||
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(brain.NewDirect()),
|
||||
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)
|
||||
}
|
||||
|
|
@ -82,5 +82,5 @@ func TestProvidersAPI_List_Good_WithRuntimeProviders(t *testing.T) {
|
|||
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, "active", resp.Providers[0].Status)
|
||||
assert.Equal(t, "running", resp.Providers[0].Status)
|
||||
}
|
||||
|
|
|
|||
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