feat(ui): add maintenance page for task management
Some checks failed
CI / build-and-test (push) Failing after -31m53s
Some checks failed
CI / build-and-test (push) Failing after -31m53s
* Implement maintenance page with task and log display * Add backfill and metadata retry functionality * Integrate grid component for project display in thoughts page * Update types for maintenance tasks and logs * Enhance sidebar and shell for new maintenance navigation
This commit is contained in:
18
Makefile
18
Makefile
@@ -20,10 +20,26 @@ LDFLAGS := -s -w \
|
||||
-X $(BUILDINFO_PKG).Commit=$(COMMIT_SHA) \
|
||||
-X $(BUILDINFO_PKG).BuildDate=$(BUILD_DATE)
|
||||
|
||||
.PHONY: all build clean migrate release-version test generate-migrations generate-models check-schema-drift build-cli ui-install ui-build ui-dev ui-check
|
||||
.PHONY: all build clean migrate release-version test generate-migrations generate-models check-schema-drift build-cli ui-install ui-build ui-dev ui-check help
|
||||
|
||||
all: build
|
||||
|
||||
help:
|
||||
@echo "Available targets:"
|
||||
@echo " build Build server binary (includes UI build)"
|
||||
@echo " build-cli Build CLI binary"
|
||||
@echo " test Run all tests (includes UI check)"
|
||||
@echo " clean Remove build artifacts"
|
||||
@echo " migrate Run database migrations"
|
||||
@echo " release-version Tag and push a new patch release (PATCH_INCREMENT=N)"
|
||||
@echo " generate-migrations Generate SQL migration from DBML schema files"
|
||||
@echo " generate-models Generate Go models from DBML schema"
|
||||
@echo " check-schema-drift Verify generated migration matches current schema"
|
||||
@echo " ui-install Install UI dependencies"
|
||||
@echo " ui-build Build UI assets"
|
||||
@echo " ui-dev Start UI dev server"
|
||||
@echo " ui-check Run UI type checks"
|
||||
|
||||
build: ui-build
|
||||
@mkdir -p $(BIN_DIR)
|
||||
go build -ldflags "$(LDFLAGS)" -o $(SERVER_BIN) $(CMD_SERVER)
|
||||
|
||||
34
README.md
34
README.md
@@ -31,6 +31,9 @@ The AMCS directory is used to store configuration and code for the Advanced Modu
|
||||
| `get_project_context` | Recent + semantic context for a project; uses explicit `project` or the active session project |
|
||||
| `set_active_project` | Set session project scope; requires a stateful MCP session |
|
||||
| `get_active_project` | Get current session project |
|
||||
| `add_learning` | Create a curated learning record distinct from raw thoughts |
|
||||
| `get_learning` | Retrieve a structured learning by ID |
|
||||
| `list_learnings` | List structured learnings by project/category/area/status/priority/tag/query |
|
||||
| `summarize_thoughts` | LLM prose summary over a filtered set |
|
||||
| `recall_context` | Semantic + recency context block for injection |
|
||||
| `link_thoughts` | Create a typed relationship between thoughts |
|
||||
@@ -66,6 +69,17 @@ The AMCS directory is used to store configuration and code for the Advanced Modu
|
||||
| `describe_tools` | List all available MCP tools with names, descriptions, categories, and model-authored usage notes; call this at the start of a session to orient yourself |
|
||||
| `annotate_tool` | Persist your own usage notes for a specific tool; notes are returned by `describe_tools` in future sessions |
|
||||
|
||||
## Learnings
|
||||
|
||||
Learnings are curated, structured memory records for durable insights you want to keep distinct from raw thoughts. Use them for normalized lessons, decisions, and evidence-backed findings that should be easy to retrieve and review over time.
|
||||
|
||||
Compared with `capture_thought`, learnings are more explicit and reviewable: they include a required `summary`, optional `details`, and structured fields like `category`, `area`, `status`, `priority`, `confidence`, and `tags`, plus optional links to a `project`, `related_thought_id`, or `related_skill_id`.
|
||||
|
||||
Use:
|
||||
- `add_learning` to create a curated learning.
|
||||
- `get_learning` to fetch one by ID.
|
||||
- `list_learnings` to filter curated learnings across project and status dimensions.
|
||||
|
||||
## Self-Documenting Tools
|
||||
|
||||
AMCS includes a built-in tool directory that models can read and annotate.
|
||||
@@ -598,6 +612,26 @@ Run the SQL migrations against a local database with:
|
||||
|
||||
The web UI now lives in the top-level `ui/` module and is embedded into the Go binary at build time with `go:embed`.
|
||||
|
||||
### Admin UI deployment model
|
||||
|
||||
AMCS uses a **lightweight embedded SPA panel** model:
|
||||
|
||||
- the Svelte admin app is compiled to static assets
|
||||
- assets are embedded in the server binary and served from `/`
|
||||
- backend APIs (`/api/status`, `/api/rs/*`, admin action routes, OAuth endpoints) stay on the same origin
|
||||
- auth is enforced server-side for all sensitive API routes
|
||||
|
||||
This keeps deployment simple (single binary/container) while preserving SPA ergonomics for operator workflows.
|
||||
|
||||
### UI stack baseline
|
||||
|
||||
The admin frontend baseline is:
|
||||
|
||||
- Svelte 5 for the app shell and pages
|
||||
- ResolveSpec-backed APIs for data access
|
||||
- `@warkypublic/svelix` for admin UX components (including `GridlerFull` and form controllers)
|
||||
- `@warkypublic/artemis-kit` as the default JavaScript tooling dependency baseline in `ui/package.json`
|
||||
|
||||
**Use `pnpm` for all UI work in this repo.**
|
||||
|
||||
- `make build` — runs the real UI build first, then compiles the Go server
|
||||
|
||||
79
internal/app/admin_actions.go
Normal file
79
internal/app/admin_actions.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"git.warky.dev/wdevs/amcs/internal/tools"
|
||||
)
|
||||
|
||||
type adminActions struct {
|
||||
backfill *tools.BackfillTool
|
||||
retry *tools.EnrichmentRetryer
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func newAdminActions(backfill *tools.BackfillTool, retry *tools.EnrichmentRetryer, logger *slog.Logger) *adminActions {
|
||||
return &adminActions{
|
||||
backfill: backfill,
|
||||
retry: retry,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *adminActions) backfillHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", http.MethodPost)
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var in tools.BackfillInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
_, out, err := a.backfill.Handle(r.Context(), nil, in)
|
||||
if err != nil {
|
||||
if a.logger != nil {
|
||||
a.logger.Warn("admin backfill failed", slog.String("error", err.Error()))
|
||||
}
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(out)
|
||||
})
|
||||
}
|
||||
|
||||
func (a *adminActions) retryMetadataHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", http.MethodPost)
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var in tools.RetryEnrichmentInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
_, out, err := a.retry.Handle(r.Context(), nil, in)
|
||||
if err != nil {
|
||||
if a.logger != nil {
|
||||
a.logger.Warn("admin metadata retry failed", slog.String("error", err.Error()))
|
||||
}
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(out)
|
||||
})
|
||||
}
|
||||
@@ -191,6 +191,7 @@ func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *st
|
||||
filesTool := tools.NewFilesTool(db, activeProjects)
|
||||
enrichmentRetryer := tools.NewEnrichmentRetryer(context.Background(), db, bgMetadata, cfg.Capture, cfg.AI.Metadata.Timeout, activeProjects, logger)
|
||||
backfillTool := tools.NewBackfillTool(db, bgEmbeddings, activeProjects, logger)
|
||||
adminActions := newAdminActions(backfillTool, enrichmentRetryer, logger)
|
||||
|
||||
toolSet := mcpserver.ToolSet{
|
||||
Capture: tools.NewCaptureTool(db, embeddings, cfg.Capture, activeProjects, enrichmentRetryer, backfillTool),
|
||||
@@ -236,6 +237,8 @@ func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *st
|
||||
mux.HandleFunc("/api/oauth/register", oauthRegisterHandler(dynClients, logger))
|
||||
mux.HandleFunc("/api/oauth/authorize", oauthAuthorizeHandler(dynClients, authCodes, logger))
|
||||
mux.HandleFunc("/api/oauth/token", oauthTokenHandler(oauthRegistry, tokenStore, authCodes, logger))
|
||||
mux.Handle("/api/admin/actions/backfill", authMiddleware(adminActions.backfillHandler()))
|
||||
mux.Handle("/api/admin/actions/retry-metadata", authMiddleware(adminActions.retryMetadataHandler()))
|
||||
mux.HandleFunc("/favicon.ico", serveFavicon)
|
||||
mux.HandleFunc("/images/project.jpg", serveHomeImage)
|
||||
mux.HandleFunc("/images/icon.png", serveIcon)
|
||||
|
||||
@@ -61,6 +61,7 @@ func TestResolveSpecGuardAllowsSupportedMutations(t *testing.T) {
|
||||
entity string
|
||||
operation string
|
||||
}{
|
||||
{name: "learnings read", entity: "learnings", operation: "read"},
|
||||
{name: "projects create", entity: "projects", operation: "create"},
|
||||
{name: "thoughts update", entity: "thoughts", operation: "update"},
|
||||
{name: "thoughts delete", entity: "thoughts", operation: "delete"},
|
||||
@@ -119,6 +120,13 @@ func TestResolveSpecGuardBlocksUnsupportedMutations(t *testing.T) {
|
||||
wantCode: http.StatusForbidden,
|
||||
wantMessageIn: `operation "delete" is not allowed for public.stored_files`,
|
||||
},
|
||||
{
|
||||
name: "mutations blocked for learnings",
|
||||
entity: "learnings",
|
||||
operation: "delete",
|
||||
wantCode: http.StatusForbidden,
|
||||
wantMessageIn: `operation "delete" is not allowed for public.learnings`,
|
||||
},
|
||||
{
|
||||
name: "unknown operation is rejected",
|
||||
entity: "projects",
|
||||
@@ -152,3 +160,13 @@ func TestResolveSpecGuardBlocksUnsupportedMutations(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSpecModelsIncludeLearnings(t *testing.T) {
|
||||
models := resolveSpecModels()
|
||||
for _, model := range models {
|
||||
if model.schema == "public" && model.entity == "learnings" {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("resolveSpecModels() missing public.learnings")
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ type statusAPIResponse struct {
|
||||
TotalKnown int `json:"total_known"`
|
||||
ConnectedWindow string `json:"connected_window"`
|
||||
Entries []auth.AccessSnapshot `json:"entries"`
|
||||
Metrics auth.AccessMetrics `json:"metrics"`
|
||||
OAuthEnabled bool `json:"oauth_enabled"`
|
||||
}
|
||||
|
||||
@@ -40,6 +41,7 @@ func statusSnapshot(info buildinfo.Info, tracker *auth.AccessTracker, oauthEnabl
|
||||
TotalKnown: len(entries),
|
||||
ConnectedWindow: "last 10 minutes",
|
||||
Entries: entries,
|
||||
Metrics: tracker.Metrics(20),
|
||||
OAuthEnabled: oauthEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,15 @@ func TestStatusSnapshotShowsTrackedAccess(t *testing.T) {
|
||||
if snapshot.Entries[0].KeyID != "client-a" || snapshot.Entries[0].LastPath != "/files" {
|
||||
t.Fatalf("entry = %+v, want keyID client-a and path /files", snapshot.Entries[0])
|
||||
}
|
||||
if snapshot.Metrics.TotalRequests != 1 {
|
||||
t.Fatalf("Metrics.TotalRequests = %d, want 1", snapshot.Metrics.TotalRequests)
|
||||
}
|
||||
if snapshot.Metrics.UniqueIPs != 1 {
|
||||
t.Fatalf("Metrics.UniqueIPs = %d, want 1", snapshot.Metrics.UniqueIPs)
|
||||
}
|
||||
if snapshot.Metrics.UniqueAgents != 1 {
|
||||
t.Fatalf("Metrics.UniqueAgents = %d, want 1", snapshot.Metrics.UniqueAgents)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusAPIHandlerReturnsJSON(t *testing.T) {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -16,12 +18,19 @@ type AccessSnapshot struct {
|
||||
}
|
||||
|
||||
type AccessTracker struct {
|
||||
mu sync.RWMutex
|
||||
entries map[string]AccessSnapshot
|
||||
mu sync.RWMutex
|
||||
entries map[string]AccessSnapshot
|
||||
ipCounts map[string]int
|
||||
agentCounts map[string]int
|
||||
totalRequests int
|
||||
}
|
||||
|
||||
func NewAccessTracker() *AccessTracker {
|
||||
return &AccessTracker{entries: make(map[string]AccessSnapshot)}
|
||||
return &AccessTracker{
|
||||
entries: make(map[string]AccessSnapshot),
|
||||
ipCounts: make(map[string]int),
|
||||
agentCounts: make(map[string]int),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *AccessTracker) Record(keyID, path, remoteAddr, userAgent string, now time.Time) {
|
||||
@@ -32,14 +41,36 @@ func (t *AccessTracker) Record(keyID, path, remoteAddr, userAgent string, now ti
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
normalizedRemoteAddr := normalizeRemoteAddr(remoteAddr)
|
||||
|
||||
entry := t.entries[keyID]
|
||||
entry.KeyID = keyID
|
||||
entry.LastPath = path
|
||||
entry.RemoteAddr = remoteAddr
|
||||
entry.RemoteAddr = normalizedRemoteAddr
|
||||
entry.UserAgent = userAgent
|
||||
entry.LastAccessedAt = now.UTC()
|
||||
entry.RequestCount++
|
||||
t.entries[keyID] = entry
|
||||
t.totalRequests++
|
||||
|
||||
if normalizedRemoteAddr != "" {
|
||||
t.ipCounts[normalizedRemoteAddr]++
|
||||
}
|
||||
if userAgent != "" {
|
||||
t.agentCounts[userAgent]++
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeRemoteAddr(value string) string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
host, _, err := net.SplitHostPort(trimmed)
|
||||
if err == nil {
|
||||
return host
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func (t *AccessTracker) Snapshot() []AccessSnapshot {
|
||||
@@ -79,3 +110,55 @@ func (t *AccessTracker) ConnectedCount(now time.Time, window time.Duration) int
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
type RequestAggregate struct {
|
||||
Key string `json:"key"`
|
||||
RequestCount int `json:"request_count"`
|
||||
}
|
||||
|
||||
type AccessMetrics struct {
|
||||
TotalRequests int `json:"total_requests"`
|
||||
UniquePrincipals int `json:"unique_principals"`
|
||||
UniqueIPs int `json:"unique_ips"`
|
||||
UniqueAgents int `json:"unique_agents"`
|
||||
TopIPs []RequestAggregate `json:"top_ips"`
|
||||
TopAgents []RequestAggregate `json:"top_agents"`
|
||||
}
|
||||
|
||||
func (t *AccessTracker) Metrics(topN int) AccessMetrics {
|
||||
if t == nil {
|
||||
return AccessMetrics{}
|
||||
}
|
||||
if topN <= 0 {
|
||||
topN = 10
|
||||
}
|
||||
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
|
||||
return AccessMetrics{
|
||||
TotalRequests: t.totalRequests,
|
||||
UniquePrincipals: len(t.entries),
|
||||
UniqueIPs: len(t.ipCounts),
|
||||
UniqueAgents: len(t.agentCounts),
|
||||
TopIPs: topAggregates(t.ipCounts, topN),
|
||||
TopAgents: topAggregates(t.agentCounts, topN),
|
||||
}
|
||||
}
|
||||
|
||||
func topAggregates(items map[string]int, topN int) []RequestAggregate {
|
||||
out := make([]RequestAggregate, 0, len(items))
|
||||
for key, count := range items {
|
||||
out = append(out, RequestAggregate{Key: key, RequestCount: count})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].RequestCount == out[j].RequestCount {
|
||||
return out[i].Key < out[j].Key
|
||||
}
|
||||
return out[i].RequestCount > out[j].RequestCount
|
||||
})
|
||||
if len(out) > topN {
|
||||
out = out[:topN]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -30,6 +30,9 @@ func TestAccessTrackerRecordAndSnapshot(t *testing.T) {
|
||||
if snap[0].UserAgent != "agent-a2" {
|
||||
t.Fatalf("snapshot[0].UserAgent = %q, want agent-a2", snap[0].UserAgent)
|
||||
}
|
||||
if snap[0].RemoteAddr != "10.0.0.1" {
|
||||
t.Fatalf("snapshot[0].RemoteAddr = %q, want 10.0.0.1", snap[0].RemoteAddr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessTrackerConnectedCount(t *testing.T) {
|
||||
@@ -43,3 +46,42 @@ func TestAccessTrackerConnectedCount(t *testing.T) {
|
||||
t.Fatalf("ConnectedCount() = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessTrackerMetrics(t *testing.T) {
|
||||
tracker := NewAccessTracker()
|
||||
now := time.Date(2026, 4, 4, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
tracker.Record("client-a", "/mcp", "10.0.0.1:1234", "agent-a", now)
|
||||
tracker.Record("client-a", "/mcp", "10.0.0.1:1234", "agent-a", now.Add(1*time.Second))
|
||||
tracker.Record("client-b", "/files", "10.0.0.2:5678", "agent-b", now.Add(2*time.Second))
|
||||
tracker.Record("client-c", "/files", "10.0.0.2:5678", "agent-b", now.Add(3*time.Second))
|
||||
|
||||
metrics := tracker.Metrics(5)
|
||||
if metrics.TotalRequests != 4 {
|
||||
t.Fatalf("TotalRequests = %d, want 4", metrics.TotalRequests)
|
||||
}
|
||||
if metrics.UniquePrincipals != 3 {
|
||||
t.Fatalf("UniquePrincipals = %d, want 3", metrics.UniquePrincipals)
|
||||
}
|
||||
if metrics.UniqueIPs != 2 {
|
||||
t.Fatalf("UniqueIPs = %d, want 2", metrics.UniqueIPs)
|
||||
}
|
||||
if metrics.UniqueAgents != 2 {
|
||||
t.Fatalf("UniqueAgents = %d, want 2", metrics.UniqueAgents)
|
||||
}
|
||||
if len(metrics.TopIPs) != 2 {
|
||||
t.Fatalf("len(TopIPs) = %d, want 2", len(metrics.TopIPs))
|
||||
}
|
||||
if metrics.TopIPs[0].RequestCount != 2 || metrics.TopIPs[1].RequestCount != 2 {
|
||||
t.Fatalf("TopIPs counts = %+v, want both counts to be 2", metrics.TopIPs)
|
||||
}
|
||||
if metrics.TopIPs[0].Key != "10.0.0.1" && metrics.TopIPs[0].Key != "10.0.0.2" {
|
||||
t.Fatalf("TopIPs[0].Key = %q, want normalized IP", metrics.TopIPs[0].Key)
|
||||
}
|
||||
if len(metrics.TopAgents) != 2 {
|
||||
t.Fatalf("len(TopAgents) = %d, want 2", len(metrics.TopAgents))
|
||||
}
|
||||
if metrics.TopAgents[0].RequestCount != 2 || metrics.TopAgents[1].RequestCount != 2 {
|
||||
t.Fatalf("TopAgents counts = %+v, want both counts to be 2", metrics.TopAgents)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +105,86 @@ func TestStreamableHTTPReturnsStructuredToolErrors(t *testing.T) {
|
||||
t.Fatalf("build_date = %#v, want %q", got["build_date"], "2026-03-31T00:00:00Z")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("add_learning_requires_summary", func(t *testing.T) {
|
||||
_, err := cs.CallTool(context.Background(), &mcp.CallToolParams{
|
||||
Name: "add_learning",
|
||||
Arguments: map[string]any{},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("CallTool(add_learning) error = nil, want error")
|
||||
}
|
||||
|
||||
rpcErr, data := requireWireError(t, err)
|
||||
if rpcErr.Code != jsonrpc.CodeInvalidParams {
|
||||
t.Fatalf("add_learning code = %d, want %d", rpcErr.Code, jsonrpc.CodeInvalidParams)
|
||||
}
|
||||
if data.Type != mcperrors.TypeInvalidArguments {
|
||||
t.Fatalf("add_learning data.type = %q, want %q", data.Type, mcperrors.TypeInvalidArguments)
|
||||
}
|
||||
if data.Field != "summary" {
|
||||
t.Fatalf("add_learning data.field = %q, want %q", data.Field, "summary")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get_learning_requires_id", func(t *testing.T) {
|
||||
_, err := cs.CallTool(context.Background(), &mcp.CallToolParams{
|
||||
Name: "get_learning",
|
||||
Arguments: map[string]any{},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("CallTool(get_learning) error = nil, want error")
|
||||
}
|
||||
|
||||
rpcErr, data := requireWireError(t, err)
|
||||
if rpcErr.Code != jsonrpc.CodeInvalidParams {
|
||||
t.Fatalf("get_learning code = %d, want %d", rpcErr.Code, jsonrpc.CodeInvalidParams)
|
||||
}
|
||||
if data.Type != mcperrors.TypeInvalidArguments {
|
||||
t.Fatalf("get_learning data.type = %q, want %q", data.Type, mcperrors.TypeInvalidArguments)
|
||||
}
|
||||
if data.Field != "id" {
|
||||
t.Fatalf("get_learning data.field = %q, want %q", data.Field, "id")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("add_learning_unconfigured_returns_structured_error", func(t *testing.T) {
|
||||
_, err := cs.CallTool(context.Background(), &mcp.CallToolParams{
|
||||
Name: "add_learning",
|
||||
Arguments: map[string]any{
|
||||
"summary": "Learning with configured check",
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("CallTool(add_learning) error = nil, want error")
|
||||
}
|
||||
|
||||
rpcErr, data := requireWireError(t, err)
|
||||
if rpcErr.Code != jsonrpc.CodeInvalidParams {
|
||||
t.Fatalf("add_learning code = %d, want %d", rpcErr.Code, jsonrpc.CodeInvalidParams)
|
||||
}
|
||||
if data.Type != mcperrors.TypeInvalidInput {
|
||||
t.Fatalf("add_learning data.type = %q, want %q", data.Type, mcperrors.TypeInvalidInput)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list_learnings_unconfigured_returns_structured_error", func(t *testing.T) {
|
||||
_, err := cs.CallTool(context.Background(), &mcp.CallToolParams{
|
||||
Name: "list_learnings",
|
||||
Arguments: map[string]any{},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("CallTool(list_learnings) error = nil, want error")
|
||||
}
|
||||
|
||||
rpcErr, data := requireWireError(t, err)
|
||||
if rpcErr.Code != jsonrpc.CodeInvalidParams {
|
||||
t.Fatalf("list_learnings code = %d, want %d", rpcErr.Code, jsonrpc.CodeInvalidParams)
|
||||
}
|
||||
if data.Type != mcperrors.TypeInvalidInput {
|
||||
t.Fatalf("list_learnings data.type = %q, want %q", data.Type, mcperrors.TypeInvalidInput)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func streamableTestToolSet() ToolSet {
|
||||
|
||||
@@ -75,6 +75,9 @@ func (t *LearningsTool) Add(ctx context.Context, req *mcp.CallToolRequest, in Ad
|
||||
if summary == "" {
|
||||
return nil, AddLearningOutput{}, errRequiredField("summary")
|
||||
}
|
||||
if err := t.ensureConfigured(); err != nil {
|
||||
return nil, AddLearningOutput{}, err
|
||||
}
|
||||
|
||||
project, err := resolveProject(ctx, t.store, t.sessions, req, in.Project, false)
|
||||
if err != nil {
|
||||
@@ -113,6 +116,10 @@ func (t *LearningsTool) Add(ctx context.Context, req *mcp.CallToolRequest, in Ad
|
||||
}
|
||||
|
||||
func (t *LearningsTool) Get(ctx context.Context, _ *mcp.CallToolRequest, in GetLearningInput) (*mcp.CallToolResult, GetLearningOutput, error) {
|
||||
if err := t.ensureConfigured(); err != nil {
|
||||
return nil, GetLearningOutput{}, err
|
||||
}
|
||||
|
||||
learning, err := t.store.GetLearning(ctx, in.ID)
|
||||
if err != nil {
|
||||
return nil, GetLearningOutput{}, err
|
||||
@@ -121,6 +128,10 @@ func (t *LearningsTool) Get(ctx context.Context, _ *mcp.CallToolRequest, in GetL
|
||||
}
|
||||
|
||||
func (t *LearningsTool) List(ctx context.Context, req *mcp.CallToolRequest, in ListLearningsInput) (*mcp.CallToolResult, ListLearningsOutput, error) {
|
||||
if err := t.ensureConfigured(); err != nil {
|
||||
return nil, ListLearningsOutput{}, err
|
||||
}
|
||||
|
||||
project, err := resolveProject(ctx, t.store, t.sessions, req, in.Project, false)
|
||||
if err != nil {
|
||||
return nil, ListLearningsOutput{}, err
|
||||
@@ -146,6 +157,13 @@ func (t *LearningsTool) List(ctx context.Context, req *mcp.CallToolRequest, in L
|
||||
return nil, ListLearningsOutput{Learnings: items}, nil
|
||||
}
|
||||
|
||||
func (t *LearningsTool) ensureConfigured() error {
|
||||
if t == nil || t.store == nil {
|
||||
return errInvalidInput("learnings tool is not configured")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func defaultString(value string, fallback string) string {
|
||||
if value == "" {
|
||||
return fallback
|
||||
|
||||
70
internal/tools/learnings_test.go
Normal file
70
internal/tools/learnings_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.warky.dev/wdevs/amcs/internal/mcperrors"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestLearningsAddRequiresSummary(t *testing.T) {
|
||||
tool := &LearningsTool{}
|
||||
|
||||
_, _, err := tool.Add(context.Background(), nil, AddLearningInput{})
|
||||
if err == nil {
|
||||
t.Fatal("Add() error = nil, want error")
|
||||
}
|
||||
|
||||
_, data := requireRPCError(t, err)
|
||||
if data.Field != "summary" {
|
||||
t.Fatalf("Add() error field = %q, want %q", data.Field, "summary")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLearningsMethodsRequireConfiguredStore(t *testing.T) {
|
||||
tool := &LearningsTool{}
|
||||
|
||||
t.Run("add", func(t *testing.T) {
|
||||
_, _, err := tool.Add(context.Background(), nil, AddLearningInput{Summary: "Keep this"})
|
||||
if err == nil {
|
||||
t.Fatal("Add() error = nil, want error")
|
||||
}
|
||||
_, data := requireRPCError(t, err)
|
||||
if data.Type != mcperrors.TypeInvalidInput {
|
||||
t.Fatalf("Add() data.type = %q, want %q", data.Type, mcperrors.TypeInvalidInput)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get", func(t *testing.T) {
|
||||
_, _, err := tool.Get(context.Background(), nil, GetLearningInput{ID: uuid.New()})
|
||||
if err == nil {
|
||||
t.Fatal("Get() error = nil, want error")
|
||||
}
|
||||
_, data := requireRPCError(t, err)
|
||||
if data.Type != mcperrors.TypeInvalidInput {
|
||||
t.Fatalf("Get() data.type = %q, want %q", data.Type, mcperrors.TypeInvalidInput)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list", func(t *testing.T) {
|
||||
_, _, err := tool.List(context.Background(), nil, ListLearningsInput{})
|
||||
if err == nil {
|
||||
t.Fatal("List() error = nil, want error")
|
||||
}
|
||||
_, data := requireRPCError(t, err)
|
||||
if data.Type != mcperrors.TypeInvalidInput {
|
||||
t.Fatalf("List() data.type = %q, want %q", data.Type, mcperrors.TypeInvalidInput)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestNormalizeStringSliceTrimsDedupesAndDropsEmpties(t *testing.T) {
|
||||
got := normalizeStringSlice([]string{" alpha ", "beta", "", "beta", "alpha"})
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("normalizeStringSlice() len = %d, want 2", len(got))
|
||||
}
|
||||
if got[0] != "alpha" || got[1] != "beta" {
|
||||
t.Fatalf("normalizeStringSlice() = %#v, want [alpha beta]", got)
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@ Do not abandon the project scope or retry without a project. The project simply
|
||||
- Use project memory for code decisions, architecture, TODOs, debugging findings, and context specific to the current repo or workstream.
|
||||
- Before substantial work, always retrieve context with `get_project_context` or `recall_context` so prior decisions inform your approach.
|
||||
- Save durable project facts with `capture_thought` after completing meaningful work.
|
||||
- Use structured learnings for curated, reusable lessons that should remain distinct from raw thought capture.
|
||||
- Use `save_file` or `upload_file` for project assets the memory should retain, such as screenshots, PDFs, audio notes, and other documents.
|
||||
- If the goal is to retain the artifact itself, store the file directly instead of first reading, transcribing, or summarizing its contents.
|
||||
- For binary files or files larger than 10 MB, call `upload_file` with `content_path` (absolute server-side path) first to get an `amcs://files/{id}` URI, then pass that URI to `save_file` as `content_uri` to link it to a thought. This avoids base64 encoding entirely.
|
||||
@@ -64,6 +65,14 @@ Do not abandon the project scope or retry without a project. The project simply
|
||||
- Stored files and attachment metadata must not be sent to the metadata extraction client.
|
||||
- Do not attach memory to the wrong project.
|
||||
|
||||
## Structured Learnings
|
||||
|
||||
- Learnings are curated memory records for durable insights, decisions, and evidence-backed findings.
|
||||
- Prefer `capture_thought` for fast/raw notes during work; prefer learnings when the information is stable enough to normalize and track.
|
||||
- Create learnings with `add_learning` (required: `summary`; optional: `details`, `category`, `area`, `status`, `priority`, `confidence`, `action_required`, `tags`, and related links).
|
||||
- Retrieve one learning with `get_learning` and browse/filter with `list_learnings` (project/category/area/status/priority/tag/query).
|
||||
- Keep learnings concise, specific, and non-duplicative; use `tags` and status fields so future retrieval is reliable.
|
||||
|
||||
## Global Notebook Rules
|
||||
|
||||
- Use global memory only for information that is genuinely cross-project or not project-bound.
|
||||
@@ -100,4 +109,4 @@ Notes are returned by `describe_tools` in future sessions. Annotate whenever you
|
||||
|
||||
## Short Operational Form
|
||||
|
||||
At the start of every session, call `describe_tools` to read the full tool list and any accumulated usage notes. Use AMCS memory in project scope when the current work matches a known project; if no clear project matches, global notebook memory is allowed for non-project-specific information. At the start of every project session call `list_project_skills` and `list_project_guardrails` and apply what is returned; only create new skills or guardrails if none exist. If your MCP client does not preserve sessions across calls, pass `project` explicitly instead of relying on `set_active_project`. Store durable notes with `capture_thought`. For binary files or files larger than 10 MB, call `upload_file` with `content_path` to stage the file and get an `amcs://files/{id}` URI, then pass that URI to `save_file` as `content_uri` to link it to a thought. For small files, use `save_file` or `upload_file` with `content_base64` directly. Browse stored files with `list_files`, and load them with `load_file` only when their contents are needed. Stored files can also be read as raw binary via MCP resources at `amcs://files/{id}`. Never store project-specific memory globally when a matching project exists, and never store memory in the wrong project. If project matching is ambiguous, ask the user. If a tool returns `project_not_found`, call `create_project` with that name and retry — never drop the project scope. Whenever you discover a non-obvious tool behaviour, gotcha, or workflow pattern, record it with `annotate_tool` so future sessions benefit.
|
||||
At the start of every session, call `describe_tools` to read the full tool list and any accumulated usage notes. Use AMCS memory in project scope when the current work matches a known project; if no clear project matches, global notebook memory is allowed for non-project-specific information. At the start of every project session call `list_project_skills` and `list_project_guardrails` and apply what is returned; only create new skills or guardrails if none exist. If your MCP client does not preserve sessions across calls, pass `project` explicitly instead of relying on `set_active_project`. Store raw/durable notes with `capture_thought`, and store curated durable lessons with `add_learning`. For binary files or files larger than 10 MB, call `upload_file` with `content_path` to stage the file and get an `amcs://files/{id}` URI, then pass that URI to `save_file` as `content_uri` to link it to a thought. For small files, use `save_file` or `upload_file` with `content_base64` directly. Browse stored files with `list_files`, and load them with `load_file` only when their contents are needed. Stored files can also be read as raw binary via MCP resources at `amcs://files/{id}`. Never store project-specific memory globally when a matching project exists, and never store memory in the wrong project. If project matching is ambiguous, ask the user. If a tool returns `project_not_found`, call `create_project` with that name and retry — never drop the project scope. Whenever you discover a non-obvious tool behaviour, gotcha, or workflow pattern, record it with `annotate_tool` so future sessions benefit.
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
create extension if not exists vector;
|
||||
create extension if not exists pgcrypto;
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
@@ -17,6 +17,13 @@ CREATE SEQUENCE IF NOT EXISTS public.identity_projects_id
|
||||
START 1
|
||||
CACHE 1;
|
||||
|
||||
CREATE SEQUENCE IF NOT EXISTS public.identity_thought_links_id
|
||||
INCREMENT 1
|
||||
MINVALUE 1
|
||||
MAXVALUE 9223372036854775807
|
||||
START 1
|
||||
CACHE 1;
|
||||
|
||||
CREATE SEQUENCE IF NOT EXISTS public.identity_embeddings_id
|
||||
INCREMENT 1
|
||||
MINVALUE 1
|
||||
@@ -38,6 +45,20 @@ CREATE SEQUENCE IF NOT EXISTS public.identity_tool_annotations_id
|
||||
START 1
|
||||
CACHE 1;
|
||||
|
||||
CREATE SEQUENCE IF NOT EXISTS public.identity_project_skills_id
|
||||
INCREMENT 1
|
||||
MINVALUE 1
|
||||
MAXVALUE 9223372036854775807
|
||||
START 1
|
||||
CACHE 1;
|
||||
|
||||
CREATE SEQUENCE IF NOT EXISTS public.identity_project_guardrails_id
|
||||
INCREMENT 1
|
||||
MINVALUE 1
|
||||
MAXVALUE 9223372036854775807
|
||||
START 1
|
||||
CACHE 1;
|
||||
|
||||
-- Tables for schema: public
|
||||
CREATE TABLE IF NOT EXISTS public.family_members (
|
||||
birth_date date,
|
||||
@@ -97,6 +118,7 @@ CREATE TABLE IF NOT EXISTS public.projects (
|
||||
CREATE TABLE IF NOT EXISTS public.thought_links (
|
||||
created_at timestamptz DEFAULT now(),
|
||||
from_id bigint NOT NULL,
|
||||
id serial NOT NULL,
|
||||
relation text NOT NULL,
|
||||
to_id bigint NOT NULL
|
||||
);
|
||||
@@ -322,6 +344,7 @@ CREATE TABLE IF NOT EXISTS public.agent_guardrails (
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.project_skills (
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
id serial NOT NULL,
|
||||
project_id uuid NOT NULL,
|
||||
skill_id uuid NOT NULL
|
||||
);
|
||||
@@ -329,6 +352,7 @@ CREATE TABLE IF NOT EXISTS public.project_skills (
|
||||
CREATE TABLE IF NOT EXISTS public.project_guardrails (
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
guardrail_id uuid NOT NULL,
|
||||
id serial NOT NULL,
|
||||
project_id uuid NOT NULL
|
||||
);
|
||||
|
||||
@@ -879,6 +903,19 @@ BEGIN
|
||||
END;
|
||||
$$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'thought_links'
|
||||
AND column_name = 'id'
|
||||
) THEN
|
||||
ALTER TABLE public.thought_links ADD COLUMN id serial NOT NULL;
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
@@ -3102,6 +3139,19 @@ BEGIN
|
||||
END;
|
||||
$$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'project_skills'
|
||||
AND column_name = 'id'
|
||||
) THEN
|
||||
ALTER TABLE public.project_skills ADD COLUMN id serial NOT NULL;
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
@@ -3154,6 +3204,19 @@ BEGIN
|
||||
END;
|
||||
$$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'project_guardrails'
|
||||
AND column_name = 'id'
|
||||
) THEN
|
||||
ALTER TABLE public.project_guardrails ADD COLUMN id serial NOT NULL;
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
@@ -3181,7 +3244,7 @@ BEGIN
|
||||
AND constraint_name IN ('family_members_pkey', 'public_family_members_pkey');
|
||||
|
||||
IF auto_pk_name IS NOT NULL THEN
|
||||
EXECUTE 'ALTER TABLE public.family_members DROP CONSTRAINT ' || quote_ident(auto_pk_name);
|
||||
EXECUTE 'ALTER TABLE public.family_members DROP CONSTRAINT ' || quote_ident(auto_pk_name) || ' cascade';
|
||||
END IF;
|
||||
|
||||
-- Add named primary key if it doesn't exist
|
||||
@@ -3209,7 +3272,7 @@ BEGIN
|
||||
AND constraint_name IN ('activities_pkey', 'public_activities_pkey');
|
||||
|
||||
IF auto_pk_name IS NOT NULL THEN
|
||||
EXECUTE 'ALTER TABLE public.activities DROP CONSTRAINT ' || quote_ident(auto_pk_name);
|
||||
EXECUTE 'ALTER TABLE public.activities DROP CONSTRAINT ' || quote_ident(auto_pk_name) || ' cascade';
|
||||
END IF;
|
||||
|
||||
-- Add named primary key if it doesn't exist
|
||||
@@ -3237,7 +3300,7 @@ BEGIN
|
||||
AND constraint_name IN ('important_dates_pkey', 'public_important_dates_pkey');
|
||||
|
||||
IF auto_pk_name IS NOT NULL THEN
|
||||
EXECUTE 'ALTER TABLE public.important_dates DROP CONSTRAINT ' || quote_ident(auto_pk_name);
|
||||
EXECUTE 'ALTER TABLE public.important_dates DROP CONSTRAINT ' || quote_ident(auto_pk_name) || ' cascade';
|
||||
END IF;
|
||||
|
||||
-- Add named primary key if it doesn't exist
|
||||
@@ -3265,7 +3328,7 @@ BEGIN
|
||||
AND constraint_name IN ('thoughts_pkey', 'public_thoughts_pkey');
|
||||
|
||||
IF auto_pk_name IS NOT NULL THEN
|
||||
EXECUTE 'ALTER TABLE public.thoughts DROP CONSTRAINT ' || quote_ident(auto_pk_name);
|
||||
EXECUTE 'ALTER TABLE public.thoughts DROP CONSTRAINT ' || quote_ident(auto_pk_name) || ' cascade';
|
||||
END IF;
|
||||
|
||||
-- Add named primary key if it doesn't exist
|
||||
@@ -3293,7 +3356,7 @@ BEGIN
|
||||
AND constraint_name IN ('projects_pkey', 'public_projects_pkey');
|
||||
|
||||
IF auto_pk_name IS NOT NULL THEN
|
||||
EXECUTE 'ALTER TABLE public.projects DROP CONSTRAINT ' || quote_ident(auto_pk_name);
|
||||
EXECUTE 'ALTER TABLE public.projects DROP CONSTRAINT ' || quote_ident(auto_pk_name) || ' cascade';
|
||||
END IF;
|
||||
|
||||
-- Add named primary key if it doesn't exist
|
||||
@@ -3308,6 +3371,34 @@ BEGIN
|
||||
END;
|
||||
$$;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
auto_pk_name text;
|
||||
BEGIN
|
||||
-- Drop auto-generated primary key if it exists
|
||||
SELECT constraint_name INTO auto_pk_name
|
||||
FROM information_schema.table_constraints
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'thought_links'
|
||||
AND constraint_type = 'PRIMARY KEY'
|
||||
AND constraint_name IN ('thought_links_pkey', 'public_thought_links_pkey');
|
||||
|
||||
IF auto_pk_name IS NOT NULL THEN
|
||||
EXECUTE 'ALTER TABLE public.thought_links DROP CONSTRAINT ' || quote_ident(auto_pk_name) || ' cascade';
|
||||
END IF;
|
||||
|
||||
-- Add named primary key if it doesn't exist
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'thought_links'
|
||||
AND constraint_name = 'pk_public_thought_links'
|
||||
) THEN
|
||||
ALTER TABLE public.thought_links ADD CONSTRAINT pk_public_thought_links PRIMARY KEY (id);
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
auto_pk_name text;
|
||||
@@ -3321,7 +3412,7 @@ BEGIN
|
||||
AND constraint_name IN ('embeddings_pkey', 'public_embeddings_pkey');
|
||||
|
||||
IF auto_pk_name IS NOT NULL THEN
|
||||
EXECUTE 'ALTER TABLE public.embeddings DROP CONSTRAINT ' || quote_ident(auto_pk_name);
|
||||
EXECUTE 'ALTER TABLE public.embeddings DROP CONSTRAINT ' || quote_ident(auto_pk_name) || ' cascade';
|
||||
END IF;
|
||||
|
||||
-- Add named primary key if it doesn't exist
|
||||
@@ -3349,7 +3440,7 @@ BEGIN
|
||||
AND constraint_name IN ('professional_contacts_pkey', 'public_professional_contacts_pkey');
|
||||
|
||||
IF auto_pk_name IS NOT NULL THEN
|
||||
EXECUTE 'ALTER TABLE public.professional_contacts DROP CONSTRAINT ' || quote_ident(auto_pk_name);
|
||||
EXECUTE 'ALTER TABLE public.professional_contacts DROP CONSTRAINT ' || quote_ident(auto_pk_name) || ' cascade';
|
||||
END IF;
|
||||
|
||||
-- Add named primary key if it doesn't exist
|
||||
@@ -3377,7 +3468,7 @@ BEGIN
|
||||
AND constraint_name IN ('contact_interactions_pkey', 'public_contact_interactions_pkey');
|
||||
|
||||
IF auto_pk_name IS NOT NULL THEN
|
||||
EXECUTE 'ALTER TABLE public.contact_interactions DROP CONSTRAINT ' || quote_ident(auto_pk_name);
|
||||
EXECUTE 'ALTER TABLE public.contact_interactions DROP CONSTRAINT ' || quote_ident(auto_pk_name) || ' cascade';
|
||||
END IF;
|
||||
|
||||
-- Add named primary key if it doesn't exist
|
||||
@@ -3405,7 +3496,7 @@ BEGIN
|
||||
AND constraint_name IN ('opportunities_pkey', 'public_opportunities_pkey');
|
||||
|
||||
IF auto_pk_name IS NOT NULL THEN
|
||||
EXECUTE 'ALTER TABLE public.opportunities DROP CONSTRAINT ' || quote_ident(auto_pk_name);
|
||||
EXECUTE 'ALTER TABLE public.opportunities DROP CONSTRAINT ' || quote_ident(auto_pk_name) || ' cascade';
|
||||
END IF;
|
||||
|
||||
-- Add named primary key if it doesn't exist
|
||||
@@ -3433,7 +3524,7 @@ BEGIN
|
||||
AND constraint_name IN ('stored_files_pkey', 'public_stored_files_pkey');
|
||||
|
||||
IF auto_pk_name IS NOT NULL THEN
|
||||
EXECUTE 'ALTER TABLE public.stored_files DROP CONSTRAINT ' || quote_ident(auto_pk_name);
|
||||
EXECUTE 'ALTER TABLE public.stored_files DROP CONSTRAINT ' || quote_ident(auto_pk_name) || ' cascade';
|
||||
END IF;
|
||||
|
||||
-- Add named primary key if it doesn't exist
|
||||
@@ -3461,7 +3552,7 @@ BEGIN
|
||||
AND constraint_name IN ('household_items_pkey', 'public_household_items_pkey');
|
||||
|
||||
IF auto_pk_name IS NOT NULL THEN
|
||||
EXECUTE 'ALTER TABLE public.household_items DROP CONSTRAINT ' || quote_ident(auto_pk_name);
|
||||
EXECUTE 'ALTER TABLE public.household_items DROP CONSTRAINT ' || quote_ident(auto_pk_name) || ' cascade';
|
||||
END IF;
|
||||
|
||||
-- Add named primary key if it doesn't exist
|
||||
@@ -3489,7 +3580,7 @@ BEGIN
|
||||
AND constraint_name IN ('household_vendors_pkey', 'public_household_vendors_pkey');
|
||||
|
||||
IF auto_pk_name IS NOT NULL THEN
|
||||
EXECUTE 'ALTER TABLE public.household_vendors DROP CONSTRAINT ' || quote_ident(auto_pk_name);
|
||||
EXECUTE 'ALTER TABLE public.household_vendors DROP CONSTRAINT ' || quote_ident(auto_pk_name) || ' cascade';
|
||||
END IF;
|
||||
|
||||
-- Add named primary key if it doesn't exist
|
||||
@@ -3517,7 +3608,7 @@ BEGIN
|
||||
AND constraint_name IN ('maintenance_tasks_pkey', 'public_maintenance_tasks_pkey');
|
||||
|
||||
IF auto_pk_name IS NOT NULL THEN
|
||||
EXECUTE 'ALTER TABLE public.maintenance_tasks DROP CONSTRAINT ' || quote_ident(auto_pk_name);
|
||||
EXECUTE 'ALTER TABLE public.maintenance_tasks DROP CONSTRAINT ' || quote_ident(auto_pk_name) || ' cascade';
|
||||
END IF;
|
||||
|
||||
-- Add named primary key if it doesn't exist
|
||||
@@ -3545,7 +3636,7 @@ BEGIN
|
||||
AND constraint_name IN ('maintenance_logs_pkey', 'public_maintenance_logs_pkey');
|
||||
|
||||
IF auto_pk_name IS NOT NULL THEN
|
||||
EXECUTE 'ALTER TABLE public.maintenance_logs DROP CONSTRAINT ' || quote_ident(auto_pk_name);
|
||||
EXECUTE 'ALTER TABLE public.maintenance_logs DROP CONSTRAINT ' || quote_ident(auto_pk_name) || ' cascade';
|
||||
END IF;
|
||||
|
||||
-- Add named primary key if it doesn't exist
|
||||
@@ -3573,7 +3664,7 @@ BEGIN
|
||||
AND constraint_name IN ('recipes_pkey', 'public_recipes_pkey');
|
||||
|
||||
IF auto_pk_name IS NOT NULL THEN
|
||||
EXECUTE 'ALTER TABLE public.recipes DROP CONSTRAINT ' || quote_ident(auto_pk_name);
|
||||
EXECUTE 'ALTER TABLE public.recipes DROP CONSTRAINT ' || quote_ident(auto_pk_name) || ' cascade';
|
||||
END IF;
|
||||
|
||||
-- Add named primary key if it doesn't exist
|
||||
@@ -3601,7 +3692,7 @@ BEGIN
|
||||
AND constraint_name IN ('meal_plans_pkey', 'public_meal_plans_pkey');
|
||||
|
||||
IF auto_pk_name IS NOT NULL THEN
|
||||
EXECUTE 'ALTER TABLE public.meal_plans DROP CONSTRAINT ' || quote_ident(auto_pk_name);
|
||||
EXECUTE 'ALTER TABLE public.meal_plans DROP CONSTRAINT ' || quote_ident(auto_pk_name) || ' cascade';
|
||||
END IF;
|
||||
|
||||
-- Add named primary key if it doesn't exist
|
||||
@@ -3629,7 +3720,7 @@ BEGIN
|
||||
AND constraint_name IN ('shopping_lists_pkey', 'public_shopping_lists_pkey');
|
||||
|
||||
IF auto_pk_name IS NOT NULL THEN
|
||||
EXECUTE 'ALTER TABLE public.shopping_lists DROP CONSTRAINT ' || quote_ident(auto_pk_name);
|
||||
EXECUTE 'ALTER TABLE public.shopping_lists DROP CONSTRAINT ' || quote_ident(auto_pk_name) || ' cascade';
|
||||
END IF;
|
||||
|
||||
-- Add named primary key if it doesn't exist
|
||||
@@ -3657,7 +3748,7 @@ BEGIN
|
||||
AND constraint_name IN ('chat_histories_pkey', 'public_chat_histories_pkey');
|
||||
|
||||
IF auto_pk_name IS NOT NULL THEN
|
||||
EXECUTE 'ALTER TABLE public.chat_histories DROP CONSTRAINT ' || quote_ident(auto_pk_name);
|
||||
EXECUTE 'ALTER TABLE public.chat_histories DROP CONSTRAINT ' || quote_ident(auto_pk_name) || ' cascade';
|
||||
END IF;
|
||||
|
||||
-- Add named primary key if it doesn't exist
|
||||
@@ -3685,7 +3776,7 @@ BEGIN
|
||||
AND constraint_name IN ('tool_annotations_pkey', 'public_tool_annotations_pkey');
|
||||
|
||||
IF auto_pk_name IS NOT NULL THEN
|
||||
EXECUTE 'ALTER TABLE public.tool_annotations DROP CONSTRAINT ' || quote_ident(auto_pk_name);
|
||||
EXECUTE 'ALTER TABLE public.tool_annotations DROP CONSTRAINT ' || quote_ident(auto_pk_name) || ' cascade';
|
||||
END IF;
|
||||
|
||||
-- Add named primary key if it doesn't exist
|
||||
@@ -3713,7 +3804,7 @@ BEGIN
|
||||
AND constraint_name IN ('learnings_pkey', 'public_learnings_pkey');
|
||||
|
||||
IF auto_pk_name IS NOT NULL THEN
|
||||
EXECUTE 'ALTER TABLE public.learnings DROP CONSTRAINT ' || quote_ident(auto_pk_name);
|
||||
EXECUTE 'ALTER TABLE public.learnings DROP CONSTRAINT ' || quote_ident(auto_pk_name) || ' cascade';
|
||||
END IF;
|
||||
|
||||
-- Add named primary key if it doesn't exist
|
||||
@@ -3741,7 +3832,7 @@ BEGIN
|
||||
AND constraint_name IN ('agent_skills_pkey', 'public_agent_skills_pkey');
|
||||
|
||||
IF auto_pk_name IS NOT NULL THEN
|
||||
EXECUTE 'ALTER TABLE public.agent_skills DROP CONSTRAINT ' || quote_ident(auto_pk_name);
|
||||
EXECUTE 'ALTER TABLE public.agent_skills DROP CONSTRAINT ' || quote_ident(auto_pk_name) || ' cascade';
|
||||
END IF;
|
||||
|
||||
-- Add named primary key if it doesn't exist
|
||||
@@ -3769,7 +3860,7 @@ BEGIN
|
||||
AND constraint_name IN ('agent_guardrails_pkey', 'public_agent_guardrails_pkey');
|
||||
|
||||
IF auto_pk_name IS NOT NULL THEN
|
||||
EXECUTE 'ALTER TABLE public.agent_guardrails DROP CONSTRAINT ' || quote_ident(auto_pk_name);
|
||||
EXECUTE 'ALTER TABLE public.agent_guardrails DROP CONSTRAINT ' || quote_ident(auto_pk_name) || ' cascade';
|
||||
END IF;
|
||||
|
||||
-- Add named primary key if it doesn't exist
|
||||
@@ -3784,6 +3875,62 @@ BEGIN
|
||||
END;
|
||||
$$;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
auto_pk_name text;
|
||||
BEGIN
|
||||
-- Drop auto-generated primary key if it exists
|
||||
SELECT constraint_name INTO auto_pk_name
|
||||
FROM information_schema.table_constraints
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'project_skills'
|
||||
AND constraint_type = 'PRIMARY KEY'
|
||||
AND constraint_name IN ('project_skills_pkey', 'public_project_skills_pkey');
|
||||
|
||||
IF auto_pk_name IS NOT NULL THEN
|
||||
EXECUTE 'ALTER TABLE public.project_skills DROP CONSTRAINT ' || quote_ident(auto_pk_name) || ' cascade';
|
||||
END IF;
|
||||
|
||||
-- Add named primary key if it doesn't exist
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'project_skills'
|
||||
AND constraint_name = 'pk_public_project_skills'
|
||||
) THEN
|
||||
ALTER TABLE public.project_skills ADD CONSTRAINT pk_public_project_skills PRIMARY KEY (id);
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
auto_pk_name text;
|
||||
BEGIN
|
||||
-- Drop auto-generated primary key if it exists
|
||||
SELECT constraint_name INTO auto_pk_name
|
||||
FROM information_schema.table_constraints
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'project_guardrails'
|
||||
AND constraint_type = 'PRIMARY KEY'
|
||||
AND constraint_name IN ('project_guardrails_pkey', 'public_project_guardrails_pkey');
|
||||
|
||||
IF auto_pk_name IS NOT NULL THEN
|
||||
EXECUTE 'ALTER TABLE public.project_guardrails DROP CONSTRAINT ' || quote_ident(auto_pk_name) || ' cascade';
|
||||
END IF;
|
||||
|
||||
-- Add named primary key if it doesn't exist
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'project_guardrails'
|
||||
AND constraint_name = 'pk_public_project_guardrails'
|
||||
) THEN
|
||||
ALTER TABLE public.project_guardrails ADD CONSTRAINT pk_public_project_guardrails PRIMARY KEY (id);
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Indexes for schema: public
|
||||
CREATE INDEX IF NOT EXISTS idx_activities_start_date_end_date
|
||||
ON public.activities USING btree (start_date, end_date);
|
||||
@@ -4327,6 +4474,25 @@ BEGIN
|
||||
END;
|
||||
$$;
|
||||
DO $$
|
||||
DECLARE
|
||||
m_cnt bigint;
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM pg_class c
|
||||
INNER JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE c.relname = 'identity_thought_links_id'
|
||||
AND n.nspname = 'public'
|
||||
AND c.relkind = 'S'
|
||||
) THEN
|
||||
SELECT COALESCE(MAX(id), 0) + 1
|
||||
FROM public.thought_links
|
||||
INTO m_cnt;
|
||||
|
||||
PERFORM setval('public.identity_thought_links_id'::regclass, m_cnt);
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
DO $$
|
||||
DECLARE
|
||||
m_cnt bigint;
|
||||
BEGIN
|
||||
@@ -4383,6 +4549,44 @@ BEGIN
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
DO $$
|
||||
DECLARE
|
||||
m_cnt bigint;
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM pg_class c
|
||||
INNER JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE c.relname = 'identity_project_skills_id'
|
||||
AND n.nspname = 'public'
|
||||
AND c.relkind = 'S'
|
||||
) THEN
|
||||
SELECT COALESCE(MAX(id), 0) + 1
|
||||
FROM public.project_skills
|
||||
INTO m_cnt;
|
||||
|
||||
PERFORM setval('public.identity_project_skills_id'::regclass, m_cnt);
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
DO $$
|
||||
DECLARE
|
||||
m_cnt bigint;
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM pg_class c
|
||||
INNER JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE c.relname = 'identity_project_guardrails_id'
|
||||
AND n.nspname = 'public'
|
||||
AND c.relkind = 'S'
|
||||
) THEN
|
||||
SELECT COALESCE(MAX(id), 0) + 1
|
||||
FROM public.project_guardrails
|
||||
INTO m_cnt;
|
||||
|
||||
PERFORM setval('public.identity_project_guardrails_id'::regclass, m_cnt);
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
-- Comments for schema: public
|
||||
|
||||
|
||||
|
||||
@@ -39,4 +39,17 @@ GRANT ALL ON TABLE public.project_guardrails TO amcs;
|
||||
-- Chat Histories (018)
|
||||
GRANT ALL ON TABLE public.chat_histories TO amcs;
|
||||
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO amcs;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO amcs;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
r RECORD;
|
||||
BEGIN
|
||||
FOR r IN
|
||||
SELECT tablename
|
||||
FROM pg_tables
|
||||
WHERE schemaname = 'public' -- Change 'public' to your schema name
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE public.%I OWNER TO amcs', r.tablename);
|
||||
END LOOP;
|
||||
END $$;
|
||||
@@ -11,22 +11,22 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"@tailwindcss/vite": "^4.1.4",
|
||||
"@types/node": "^24.5.2",
|
||||
"svelte": "^5.28.2",
|
||||
"svelte-check": "^4.1.6",
|
||||
"tailwindcss": "^4.1.4",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.3.2"
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@types/node": "^25.6.0",
|
||||
"svelte": "^5.55.5",
|
||||
"svelte-check": "^4.4.6",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sentry/svelte": "^10.49.0",
|
||||
"@sentry/svelte": "^10.50.0",
|
||||
"@skeletonlabs/skeleton": "^4.15.2",
|
||||
"@skeletonlabs/skeleton-svelte": "^4.15.2",
|
||||
"@tanstack/svelte-virtual": "^3.13.24",
|
||||
"@warkypublic/artemis-kit": "file:../../artemis-kit",
|
||||
"@warkypublic/resolvespec-js": "^1.0.1",
|
||||
"@warkypublic/svelix": "^0.1.31"
|
||||
"@warkypublic/svelix": "^0.1.39"
|
||||
}
|
||||
}
|
||||
}
|
||||
1034
ui/pnpm-lock.yaml
generated
1034
ui/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -111,7 +111,27 @@
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status request failed with ${response.status}`);
|
||||
}
|
||||
data = (await response.json()) as StatusResponse;
|
||||
const raw = (await response.json()) as Partial<StatusResponse> | null;
|
||||
data = {
|
||||
title: raw?.title ?? 'AMCS',
|
||||
description: raw?.description ?? '',
|
||||
version: raw?.version ?? 'unknown',
|
||||
build_date: raw?.build_date ?? 'unknown',
|
||||
commit: raw?.commit ?? 'unknown',
|
||||
connected_count: raw?.connected_count ?? 0,
|
||||
total_known: raw?.total_known ?? 0,
|
||||
connected_window: raw?.connected_window ?? 'last 10 minutes',
|
||||
oauth_enabled: !!raw?.oauth_enabled,
|
||||
entries: Array.isArray(raw?.entries) ? raw.entries : [],
|
||||
metrics: {
|
||||
total_requests: raw?.metrics?.total_requests ?? 0,
|
||||
unique_principals: raw?.metrics?.unique_principals ?? 0,
|
||||
unique_ips: raw?.metrics?.unique_ips ?? 0,
|
||||
unique_agents: raw?.metrics?.unique_agents ?? 0,
|
||||
top_ips: Array.isArray(raw?.metrics?.top_ips) ? raw.metrics.top_ips : [],
|
||||
top_agents: Array.isArray(raw?.metrics?.top_agents) ? raw.metrics.top_agents : []
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to load status';
|
||||
} finally {
|
||||
|
||||
124
ui/src/api.ts
124
ui/src/api.ts
@@ -94,20 +94,21 @@ async function rsCall<T>(
|
||||
|
||||
function rsReadMany<T>(
|
||||
entity: string,
|
||||
options?: { filters?: ResolveSpecFilter[]; limit?: number; sort?: { column: string; direction: 'asc' | 'desc' }[] }
|
||||
options?: { filters?: ResolveSpecFilter[]; limit?: number; offset?: number; sort?: { column: string; direction: 'asc' | 'desc' }[] }
|
||||
): Promise<T[]> {
|
||||
return rsCall<T[]>(`/api/rs/public/${entity}`, 'read', {
|
||||
return rsCall<T[] | null>(`/api/rs/public/${entity}`, 'read', {
|
||||
options: {
|
||||
...(options?.filters?.length ? { filters: options.filters } : {}),
|
||||
...(options?.sort?.length ? { sort: options.sort } : {}),
|
||||
...(options?.limit ? { limit: options.limit } : {})
|
||||
...(options?.limit ? { limit: options.limit } : {}),
|
||||
...(options?.offset ? { offset: options.offset } : {})
|
||||
}
|
||||
});
|
||||
}).then((rows) => (Array.isArray(rows) ? rows : []));
|
||||
}
|
||||
|
||||
export const api = {
|
||||
projects: {
|
||||
list: async () => {
|
||||
list: async (params?: { limit?: number; offset?: number; q?: string }) => {
|
||||
type ProjectRow = {
|
||||
guid: string;
|
||||
name: string;
|
||||
@@ -117,7 +118,12 @@ export const api = {
|
||||
thought_count?: number;
|
||||
};
|
||||
|
||||
const projects = await rsCall<ProjectRow[]>('/api/rs/public/projects', 'read', {
|
||||
const filters: ResolveSpecFilter[] = [];
|
||||
if (params?.q) {
|
||||
filters.push({ column: 'name', operator: 'ilike', value: `%${params.q}%` });
|
||||
}
|
||||
|
||||
const projectRows = await rsCall<ProjectRow[] | null>('/api/rs/public/projects', 'read', {
|
||||
options: {
|
||||
columns: ['guid', 'name', 'description', 'created_at', 'last_active_at'],
|
||||
computedColumns: [
|
||||
@@ -126,11 +132,14 @@ export const api = {
|
||||
expression: 'COALESCE((SELECT COUNT(*) FROM public.thoughts t WHERE t.project_id = projects.guid), 0)'
|
||||
}
|
||||
],
|
||||
...(filters.length ? { filters } : {}),
|
||||
sort: [{ column: 'created_at', direction: 'desc' }],
|
||||
limit: 500
|
||||
limit: params?.limit ?? 100,
|
||||
offset: params?.offset ?? 0
|
||||
}
|
||||
});
|
||||
|
||||
const projects = Array.isArray(projectRows) ? projectRows : [];
|
||||
return projects.map((project) => ({
|
||||
id: project.guid,
|
||||
name: project.name,
|
||||
@@ -146,7 +155,7 @@ export const api = {
|
||||
})
|
||||
},
|
||||
thoughts: {
|
||||
list: (params: { q?: string; project_id?: string; limit?: number; include_archived?: boolean }) => {
|
||||
list: (params: { q?: string; project_id?: string; limit?: number; offset?: number; include_archived?: boolean }) => {
|
||||
const filters: ResolveSpecFilter[] = [];
|
||||
if (params.q) {
|
||||
filters.push({ column: 'content', operator: 'ilike', value: `%${params.q}%` });
|
||||
@@ -160,10 +169,65 @@ export const api = {
|
||||
return rsReadMany<import('./types').Thought>('thoughts', {
|
||||
filters,
|
||||
limit: params.limit ?? 100,
|
||||
...(params.offset !== undefined ? { offset: params.offset } : {}),
|
||||
sort: [{ column: 'created_at', direction: 'desc' }]
|
||||
});
|
||||
}).then((rows) =>
|
||||
rows.map((row) => ({
|
||||
...row,
|
||||
content: typeof row.content === 'string' ? row.content : '',
|
||||
metadata: {
|
||||
...(row.metadata ?? {}),
|
||||
people: row.metadata?.people ?? [],
|
||||
action_items: row.metadata?.action_items ?? [],
|
||||
dates_mentioned: row.metadata?.dates_mentioned ?? [],
|
||||
topics: row.metadata?.topics ?? [],
|
||||
type: row.metadata?.type ?? '',
|
||||
source: row.metadata?.source ?? '',
|
||||
metadata_status: row.metadata?.metadata_status ?? ''
|
||||
}
|
||||
}))
|
||||
);
|
||||
},
|
||||
get: (id: string) => rsCall<import('./types').Thought>(`/api/rs/public/thoughts/${id}`, 'read'),
|
||||
links: async (thoughtID: string) => {
|
||||
const numericID = Number.parseInt(thoughtID, 10);
|
||||
if (Number.isNaN(numericID)) return [];
|
||||
|
||||
const [outbound, inbound] = await Promise.all([
|
||||
rsReadMany<import('./types').ThoughtLink>('thought_links', {
|
||||
filters: [{ column: 'from_id', operator: 'eq', value: numericID }],
|
||||
limit: 200,
|
||||
sort: [{ column: 'created_at', direction: 'desc' }]
|
||||
}),
|
||||
rsReadMany<import('./types').ThoughtLink>('thought_links', {
|
||||
filters: [{ column: 'to_id', operator: 'eq', value: numericID }],
|
||||
limit: 200,
|
||||
sort: [{ column: 'created_at', direction: 'desc' }]
|
||||
})
|
||||
]);
|
||||
|
||||
const byID = new Map<number, import('./types').ThoughtLink>();
|
||||
for (const link of outbound) byID.set(link.id, link);
|
||||
for (const link of inbound) byID.set(link.id, link);
|
||||
|
||||
return Array.from(byID.values()).sort(
|
||||
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
);
|
||||
},
|
||||
get: (id: string) =>
|
||||
rsCall<import('./types').Thought>(`/api/rs/public/thoughts/${id}`, 'read').then((row) => ({
|
||||
...row,
|
||||
content: typeof row.content === 'string' ? row.content : '',
|
||||
metadata: {
|
||||
...(row.metadata ?? {}),
|
||||
people: row.metadata?.people ?? [],
|
||||
action_items: row.metadata?.action_items ?? [],
|
||||
dates_mentioned: row.metadata?.dates_mentioned ?? [],
|
||||
topics: row.metadata?.topics ?? [],
|
||||
type: row.metadata?.type ?? '',
|
||||
source: row.metadata?.source ?? '',
|
||||
metadata_status: row.metadata?.metadata_status ?? ''
|
||||
}
|
||||
})),
|
||||
delete: (id: string) => rsCall<void>(`/api/rs/public/thoughts/${id}`, 'delete'),
|
||||
archive: (id: string) =>
|
||||
rsCall<void>(`/api/rs/public/thoughts/${id}`, 'update', {
|
||||
@@ -209,6 +273,46 @@ export const api = {
|
||||
});
|
||||
}
|
||||
},
|
||||
maintenance: {
|
||||
tasks: () =>
|
||||
rsReadMany<import('./types').MaintenanceTask>('maintenance_tasks', {
|
||||
limit: 200,
|
||||
sort: [{ column: 'next_due', direction: 'asc' }]
|
||||
}),
|
||||
logs: () =>
|
||||
rsReadMany<import('./types').MaintenanceLog>('maintenance_logs', {
|
||||
limit: 200,
|
||||
sort: [{ column: 'completed_at', direction: 'desc' }]
|
||||
}),
|
||||
runBackfill: (input?: {
|
||||
project?: string;
|
||||
limit?: number;
|
||||
include_archived?: boolean;
|
||||
older_than_days?: number;
|
||||
dry_run?: boolean;
|
||||
}) =>
|
||||
post<import('./types').BackfillResult>('/api/admin/actions/backfill', {
|
||||
limit: input?.limit ?? 100,
|
||||
project: input?.project,
|
||||
include_archived: input?.include_archived ?? false,
|
||||
older_than_days: input?.older_than_days ?? 0,
|
||||
dry_run: input?.dry_run ?? false
|
||||
}),
|
||||
runRetryMetadata: (input?: {
|
||||
project?: string;
|
||||
limit?: number;
|
||||
include_archived?: boolean;
|
||||
older_than_days?: number;
|
||||
dry_run?: boolean;
|
||||
}) =>
|
||||
post<import('./types').MetadataRetryResult>('/api/admin/actions/retry-metadata', {
|
||||
limit: input?.limit ?? 100,
|
||||
project: input?.project,
|
||||
include_archived: input?.include_archived ?? false,
|
||||
older_than_days: input?.older_than_days ?? 1,
|
||||
dry_run: input?.dry_run ?? false
|
||||
})
|
||||
},
|
||||
stats: async () => {
|
||||
type StatsThoughtRow = {
|
||||
metadata?: {
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<thead class="bg-white/5 text-xs uppercase tracking-[0.2em] text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-medium">Principal</th>
|
||||
<th class="px-4 py-3 font-medium">IP</th>
|
||||
<th class="px-4 py-3 font-medium">Last accessed</th>
|
||||
<th class="px-4 py-3 font-medium">Last path</th>
|
||||
<th class="px-4 py-3 font-medium">Agent</th>
|
||||
@@ -26,6 +27,7 @@
|
||||
{#each entries as entry}
|
||||
<tr class="hover:bg-white/[0.03]">
|
||||
<td class="px-4 py-3 align-top"><code class="rounded bg-white/5 px-2 py-1 font-mono text-xs text-cyan-100">{entry.key_id}</code></td>
|
||||
<td class="px-4 py-3 align-top"><code class="text-xs text-slate-200">{entry.remote_addr || '—'}</code></td>
|
||||
<td class="px-4 py-3 align-top text-slate-200">{formatDate(entry.last_accessed_at)}</td>
|
||||
<td class="px-4 py-3 align-top"><code class="text-slate-100">{entry.last_path}</code></td>
|
||||
<td class="max-w-[16rem] truncate px-4 py-3 align-top text-xs text-slate-400">{entry.user_agent ?? '—'}</td>
|
||||
|
||||
43
ui/src/components/dashboard/ConnectionBreakdown.svelte
Normal file
43
ui/src/components/dashboard/ConnectionBreakdown.svelte
Normal file
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import type { RequestAggregate } from '../../types';
|
||||
|
||||
const {
|
||||
title,
|
||||
entries,
|
||||
emptyLabel
|
||||
}: {
|
||||
title: string;
|
||||
entries: RequestAggregate[];
|
||||
emptyLabel: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<section class="rounded-3xl border border-white/10 bg-slate-900/80 p-6 shadow-xl shadow-slate-950/20 sm:p-8">
|
||||
<h3 class="text-xl font-semibold text-white">{title}</h3>
|
||||
{#if entries.length === 0}
|
||||
<div class="mt-6 rounded-2xl border border-dashed border-white/10 bg-slate-950/40 px-4 py-10 text-center text-slate-500">
|
||||
{emptyLabel}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mt-6 overflow-hidden rounded-2xl border border-white/10">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-white/10 text-left text-sm text-slate-300">
|
||||
<thead class="bg-white/5 text-xs uppercase tracking-[0.2em] text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-medium">Value</th>
|
||||
<th class="px-4 py-3 text-right font-medium">Requests</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-white/5 bg-slate-950/30">
|
||||
{#each entries as entry}
|
||||
<tr class="hover:bg-white/[0.03]">
|
||||
<td class="max-w-[40rem] truncate px-4 py-3 align-top"><code class="text-xs text-slate-200">{entry.key}</code></td>
|
||||
<td class="px-4 py-3 align-top text-right font-semibold text-white">{entry.request_count}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { StatusResponse } from '../../types';
|
||||
import AccessTable from './AccessTable.svelte';
|
||||
import ConnectionBreakdown from './ConnectionBreakdown.svelte';
|
||||
import StatusCards from './StatusCards.svelte';
|
||||
|
||||
const {
|
||||
@@ -47,3 +48,18 @@
|
||||
{#if data && data.entries.length > 0}
|
||||
<AccessTable entries={data.entries} />
|
||||
{/if}
|
||||
|
||||
{#if data}
|
||||
<div class="mt-6 grid gap-6 xl:grid-cols-2">
|
||||
<ConnectionBreakdown
|
||||
title="Requests By IP Address"
|
||||
entries={data.metrics.top_ips}
|
||||
emptyLabel="No connection requests recorded yet."
|
||||
/>
|
||||
<ConnectionBreakdown
|
||||
title="Requests By User Agent"
|
||||
entries={data.metrics.top_agents}
|
||||
emptyLabel="No user agents recorded yet."
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
const { data }: { data: StatusResponse } = $props();
|
||||
</script>
|
||||
|
||||
<div class="mt-6 grid gap-4 sm:grid-cols-3">
|
||||
<div class="mt-6 grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<div class="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<p class="text-sm uppercase tracking-[0.2em] text-slate-400">Connected users</p>
|
||||
<p class="mt-2 text-3xl font-semibold text-white">{data.connected_count}</p>
|
||||
@@ -13,8 +13,26 @@
|
||||
<p class="text-sm uppercase tracking-[0.2em] text-slate-400">Known principals</p>
|
||||
<p class="mt-2 text-3xl font-semibold text-white">{data.total_known}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<p class="text-sm uppercase tracking-[0.2em] text-slate-400">Total requests</p>
|
||||
<p class="mt-2 text-3xl font-semibold text-white">{data.metrics.total_requests}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<p class="text-sm uppercase tracking-[0.2em] text-slate-400">Unique IPs</p>
|
||||
<p class="mt-2 text-3xl font-semibold text-white">{data.metrics.unique_ips}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<p class="text-sm uppercase tracking-[0.2em] text-slate-400">Unique agents</p>
|
||||
<p class="mt-2 text-3xl font-semibold text-white">{data.metrics.unique_agents}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<p class="text-sm uppercase tracking-[0.2em] text-slate-400">Version</p>
|
||||
<p class="mt-2 break-all text-2xl font-semibold text-white">{data.version}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 rounded-2xl border border-white/10 bg-slate-950/40 p-4 text-xs text-slate-400">
|
||||
<p><span class="text-slate-200">Build date:</span> {data.build_date}</p>
|
||||
<p class="mt-1"><span class="text-slate-200">Commit:</span> {data.commit}</p>
|
||||
<p class="mt-1"><span class="text-slate-200">OAuth enabled:</span> {data.oauth_enabled ? 'yes' : 'no'}</p>
|
||||
</div>
|
||||
|
||||
219
ui/src/components/learnings/LearningsPage.svelte
Normal file
219
ui/src/components/learnings/LearningsPage.svelte
Normal file
@@ -0,0 +1,219 @@
|
||||
<script lang="ts">
|
||||
import { GridlerFull, type GridlerColumn } from "@warkypublic/svelix";
|
||||
import { GlobalStateStore } from "../../shellState";
|
||||
import { adminGridTheme } from "../../gridTheme";
|
||||
import type { Learning } from "../../types";
|
||||
|
||||
let selectedLearning = $state<Learning | null>(null);
|
||||
let gridTotal = $state<number | null>(null);
|
||||
|
||||
const learningsDataSourceOptions = {
|
||||
url: "/api/rs",
|
||||
authToken: GlobalStateStore.getState().session.authToken,
|
||||
schema: "public",
|
||||
entity: "learnings",
|
||||
uniqueID: "id",
|
||||
sort: [{ column: "created_at", direction: "desc" }],
|
||||
} as unknown as {
|
||||
url: string;
|
||||
authToken?: string;
|
||||
schema: string;
|
||||
entity: string;
|
||||
uniqueID: string;
|
||||
};
|
||||
|
||||
const columns: GridlerColumn[] = [
|
||||
{ id: "summary", title: "Summary", dataKey: "summary", width: 420 },
|
||||
{ id: "category", title: "Category", dataKey: "category", width: 140 },
|
||||
{ id: "area", title: "Area", dataKey: "area", width: 140 },
|
||||
{ id: "status", title: "Status", dataKey: "status", width: 130 },
|
||||
{ id: "priority", title: "Priority", dataKey: "priority", width: 120 },
|
||||
{
|
||||
id: "action_required",
|
||||
title: "Action",
|
||||
dataKey: "action_required",
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
id: "created_at",
|
||||
title: "Created",
|
||||
dataKey: "created_at",
|
||||
width: 210,
|
||||
format: "datetime",
|
||||
},
|
||||
];
|
||||
|
||||
function normalizeLearning(rowData: Record<string, unknown>): Learning {
|
||||
return {
|
||||
id: String(rowData.id ?? ""),
|
||||
summary: typeof rowData.summary === "string" ? rowData.summary : "",
|
||||
details: typeof rowData.details === "string" ? rowData.details : "",
|
||||
category: typeof rowData.category === "string" ? rowData.category : "",
|
||||
area: typeof rowData.area === "string" ? rowData.area : "",
|
||||
status: typeof rowData.status === "string" ? rowData.status : "",
|
||||
priority: typeof rowData.priority === "string" ? rowData.priority : "",
|
||||
confidence:
|
||||
typeof rowData.confidence === "string" ? rowData.confidence : "",
|
||||
action_required: Boolean(rowData.action_required),
|
||||
source_type:
|
||||
typeof rowData.source_type === "string" ? rowData.source_type : undefined,
|
||||
source_ref:
|
||||
typeof rowData.source_ref === "string" ? rowData.source_ref : undefined,
|
||||
tags: typeof rowData.tags === "string" ? rowData.tags : undefined,
|
||||
project_id:
|
||||
typeof rowData.project_id === "string" ? rowData.project_id : undefined,
|
||||
related_thought_id:
|
||||
typeof rowData.related_thought_id === "string"
|
||||
? rowData.related_thought_id
|
||||
: undefined,
|
||||
related_skill_id:
|
||||
typeof rowData.related_skill_id === "string"
|
||||
? rowData.related_skill_id
|
||||
: undefined,
|
||||
reviewed_at:
|
||||
typeof rowData.reviewed_at === "string" ? rowData.reviewed_at : undefined,
|
||||
reviewed_by:
|
||||
typeof rowData.reviewed_by === "string" ? rowData.reviewed_by : undefined,
|
||||
created_at: String(rowData.created_at ?? ""),
|
||||
updated_at: String(rowData.updated_at ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
function onRowClick(
|
||||
_row: number,
|
||||
rowData: Record<string, unknown> | undefined,
|
||||
) {
|
||||
if (!rowData) {
|
||||
selectedLearning = null;
|
||||
return;
|
||||
}
|
||||
selectedLearning = normalizeLearning(rowData);
|
||||
}
|
||||
|
||||
function onGridEvent(
|
||||
type: string,
|
||||
_item?: unknown,
|
||||
_column?: unknown,
|
||||
_coords?: unknown,
|
||||
detail?: Record<string, unknown>,
|
||||
) {
|
||||
if (type !== "page_loaded" && type !== "load") return;
|
||||
const total = detail?.total;
|
||||
if (typeof total === "number") gridTotal = total;
|
||||
}
|
||||
|
||||
function formatDate(value?: string): string {
|
||||
if (!value) return "—";
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-4 w-full">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-white">Learnings</h2>
|
||||
<p class="mt-1 text-sm text-slate-400">
|
||||
{#if gridTotal === null}
|
||||
Server-backed grid
|
||||
{:else}
|
||||
{gridTotal} result{gridTotal !== 1 ? "s" : ""}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 xl:grid-cols-[1.6fr_1fr]">
|
||||
<div class="rounded-2xl border border-white/10 bg-slate-950/30 p-3">
|
||||
<GridlerFull
|
||||
{columns}
|
||||
theme={adminGridTheme}
|
||||
rowMarkers="number"
|
||||
height={560}
|
||||
width="100%"
|
||||
pageSize={40}
|
||||
dataSource="resolvespec"
|
||||
dataSourceOptions={learningsDataSourceOptions}
|
||||
serverSideSearch={true}
|
||||
searchColumns={["summary", "details", "category", "area", "status"]}
|
||||
{onGridEvent}
|
||||
{onRowClick}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<aside class="rounded-2xl border border-white/10 bg-slate-900/70 p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<h3 class="text-sm font-semibold text-white">Learning Inspector</h3>
|
||||
</div>
|
||||
|
||||
{#if !selectedLearning}
|
||||
<p class="mt-3 text-sm text-slate-500">
|
||||
Select a learning row to inspect details and metadata.
|
||||
</p>
|
||||
{:else}
|
||||
<div class="mt-3 space-y-3 text-sm text-slate-300">
|
||||
<p class="text-slate-200">{selectedLearning.summary}</p>
|
||||
<div class="rounded-xl border border-white/10 bg-white/5 p-3 space-y-1">
|
||||
<p>
|
||||
<strong class="text-slate-100">Category:</strong>
|
||||
{selectedLearning.category || "—"}
|
||||
</p>
|
||||
<p>
|
||||
<strong class="text-slate-100">Area:</strong>
|
||||
{selectedLearning.area || "—"}
|
||||
</p>
|
||||
<p>
|
||||
<strong class="text-slate-100">Status:</strong>
|
||||
{selectedLearning.status || "—"}
|
||||
</p>
|
||||
<p>
|
||||
<strong class="text-slate-100">Priority:</strong>
|
||||
{selectedLearning.priority || "—"}
|
||||
</p>
|
||||
<p>
|
||||
<strong class="text-slate-100">Confidence:</strong>
|
||||
{selectedLearning.confidence || "—"}
|
||||
</p>
|
||||
<p>
|
||||
<strong class="text-slate-100">Action Required:</strong>
|
||||
{selectedLearning.action_required ? "yes" : "no"}
|
||||
</p>
|
||||
<p>
|
||||
<strong class="text-slate-100">Created:</strong>
|
||||
{formatDate(selectedLearning.created_at)}
|
||||
</p>
|
||||
<p>
|
||||
<strong class="text-slate-100">Updated:</strong>
|
||||
{formatDate(selectedLearning.updated_at)}
|
||||
</p>
|
||||
<p>
|
||||
<strong class="text-slate-100">Reviewed:</strong>
|
||||
{formatDate(selectedLearning.reviewed_at)}
|
||||
</p>
|
||||
<p>
|
||||
<strong class="text-slate-100">Reviewed By:</strong>
|
||||
{selectedLearning.reviewed_by || "—"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-white/10 bg-white/5 p-3">
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">Details</p>
|
||||
<p class="mt-2 whitespace-pre-wrap text-slate-300">
|
||||
{selectedLearning.details || "No details provided."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-white/10 bg-white/5 p-3">
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">Source</p>
|
||||
<p class="mt-2 text-slate-300">
|
||||
{selectedLearning.source_type || "—"}
|
||||
{#if selectedLearning.source_ref}
|
||||
· {selectedLearning.source_ref}
|
||||
{/if}
|
||||
</p>
|
||||
<p class="mt-1 text-slate-400">Tags: {selectedLearning.tags || "—"}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
184
ui/src/components/maintenance/MaintenancePage.svelte
Normal file
184
ui/src/components/maintenance/MaintenancePage.svelte
Normal file
@@ -0,0 +1,184 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '../../api';
|
||||
import type { BackfillResult, MaintenanceLog, MaintenanceTask, MetadataRetryResult } from '../../types';
|
||||
|
||||
let tasks = $state<MaintenanceTask[]>([]);
|
||||
let logs = $state<MaintenanceLog[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let busyAction = $state<'backfill' | 'retry' | null>(null);
|
||||
let actionError = $state('');
|
||||
let actionMessage = $state('');
|
||||
let dryRun = $state(true);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const [taskRows, logRows] = await Promise.all([api.maintenance.tasks(), api.maintenance.logs()]);
|
||||
tasks = taskRows;
|
||||
logs = logRows;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load maintenance data';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value?: string): string {
|
||||
if (!value) return '—';
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
function formatCost(value?: number): string {
|
||||
if (value === undefined || value === null) return '—';
|
||||
return `$${value.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function summarizeBackfill(result: BackfillResult): string {
|
||||
return `Backfill (${result.dry_run ? 'dry-run' : 'run'}) scanned ${result.scanned}, embedded ${result.embedded}, failed ${result.failed}.`;
|
||||
}
|
||||
|
||||
function summarizeRetry(result: MetadataRetryResult): string {
|
||||
return `Metadata retry (${result.dry_run ? 'dry-run' : 'run'}) scanned ${result.scanned}, retried ${result.retried}, updated ${result.updated}, failed ${result.failed}.`;
|
||||
}
|
||||
|
||||
async function runBackfill() {
|
||||
busyAction = 'backfill';
|
||||
actionError = '';
|
||||
actionMessage = '';
|
||||
try {
|
||||
const result = await api.maintenance.runBackfill({ dry_run: dryRun });
|
||||
actionMessage = summarizeBackfill(result);
|
||||
} catch (e) {
|
||||
actionError = e instanceof Error ? e.message : 'Backfill failed';
|
||||
} finally {
|
||||
busyAction = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function runRetryMetadata() {
|
||||
busyAction = 'retry';
|
||||
actionError = '';
|
||||
actionMessage = '';
|
||||
try {
|
||||
const result = await api.maintenance.runRetryMetadata({ dry_run: dryRun });
|
||||
actionMessage = summarizeRetry(result);
|
||||
} catch (e) {
|
||||
actionError = e instanceof Error ? e.message : 'Retry failed';
|
||||
} finally {
|
||||
busyAction = null;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-end justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-white">Maintenance</h2>
|
||||
<p class="mt-1 text-sm text-slate-400">Operational state and safe maintenance actions.</p>
|
||||
</div>
|
||||
<button
|
||||
class="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-200 transition hover:bg-white/10"
|
||||
onclick={load}
|
||||
>Refresh</button>
|
||||
</div>
|
||||
|
||||
<section class="rounded-2xl border border-white/10 bg-slate-900/70 p-4">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<label class="flex items-center gap-2 text-sm text-slate-300">
|
||||
<input type="checkbox" class="accent-cyan-400" bind:checked={dryRun} />
|
||||
Dry run
|
||||
</label>
|
||||
<button
|
||||
class="rounded-xl border border-cyan-300/30 bg-cyan-400/10 px-4 py-2 text-sm font-medium text-cyan-100 transition hover:bg-cyan-400/20 disabled:opacity-50"
|
||||
onclick={runBackfill}
|
||||
disabled={busyAction !== null}
|
||||
>{busyAction === 'backfill' ? 'Running backfill…' : 'Run Backfill'}</button>
|
||||
<button
|
||||
class="rounded-xl border border-amber-300/30 bg-amber-400/10 px-4 py-2 text-sm font-medium text-amber-100 transition hover:bg-amber-400/20 disabled:opacity-50"
|
||||
onclick={runRetryMetadata}
|
||||
disabled={busyAction !== null}
|
||||
>{busyAction === 'retry' ? 'Running retry…' : 'Run Metadata Retry'}</button>
|
||||
</div>
|
||||
{#if actionError}
|
||||
<p class="mt-3 text-sm text-rose-300">{actionError}</p>
|
||||
{/if}
|
||||
{#if actionMessage}
|
||||
<p class="mt-3 text-sm text-emerald-300">{actionMessage}</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if loading}
|
||||
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-12 text-center text-slate-400">Loading…</div>
|
||||
{:else if error}
|
||||
<div class="rounded-2xl border border-rose-400/30 bg-rose-400/10 px-4 py-4 text-sm text-rose-100">{error}</div>
|
||||
{:else}
|
||||
<section class="space-y-4">
|
||||
<h3 class="text-lg font-semibold text-white">Tasks ({tasks.length})</h3>
|
||||
{#if tasks.length === 0}
|
||||
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-10 text-center text-slate-500">No maintenance tasks.</div>
|
||||
{:else}
|
||||
<div class="overflow-hidden rounded-2xl border border-white/10">
|
||||
<table class="min-w-full divide-y divide-white/10 text-sm text-slate-300">
|
||||
<thead class="bg-white/5 text-xs uppercase tracking-[0.18em] text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left font-medium">Task</th>
|
||||
<th class="px-4 py-3 text-left font-medium">Category</th>
|
||||
<th class="px-4 py-3 text-left font-medium">Priority</th>
|
||||
<th class="px-4 py-3 text-right font-medium">Next Due</th>
|
||||
<th class="px-4 py-3 text-right font-medium">Last Completed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-white/5 bg-slate-950/30">
|
||||
{#each tasks as task}
|
||||
<tr class="hover:bg-white/[0.03]">
|
||||
<td class="px-4 py-3 font-medium text-white">{task.name}</td>
|
||||
<td class="px-4 py-3 text-slate-400">{task.category || '—'}</td>
|
||||
<td class="px-4 py-3 text-slate-300">{task.priority || 'medium'}</td>
|
||||
<td class="px-4 py-3 text-right text-slate-300">{formatDate(task.next_due)}</td>
|
||||
<td class="px-4 py-3 text-right text-slate-400">{formatDate(task.last_completed)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="space-y-4">
|
||||
<h3 class="text-lg font-semibold text-white">Recent Logs ({logs.length})</h3>
|
||||
{#if logs.length === 0}
|
||||
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-10 text-center text-slate-500">No maintenance logs.</div>
|
||||
{:else}
|
||||
<div class="overflow-hidden rounded-2xl border border-white/10">
|
||||
<table class="min-w-full divide-y divide-white/10 text-sm text-slate-300">
|
||||
<thead class="bg-white/5 text-xs uppercase tracking-[0.18em] text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left font-medium">Completed</th>
|
||||
<th class="px-4 py-3 text-left font-medium">Task ID</th>
|
||||
<th class="px-4 py-3 text-left font-medium">By</th>
|
||||
<th class="px-4 py-3 text-right font-medium">Cost</th>
|
||||
<th class="px-4 py-3 text-left font-medium">Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-white/5 bg-slate-950/30">
|
||||
{#each logs as log}
|
||||
<tr class="hover:bg-white/[0.03]">
|
||||
<td class="px-4 py-3 text-slate-300">{formatDate(log.completed_at)}</td>
|
||||
<td class="px-4 py-3"><code class="text-xs text-cyan-100">{log.task_id}</code></td>
|
||||
<td class="px-4 py-3 text-slate-400">{log.performed_by || '—'}</td>
|
||||
<td class="px-4 py-3 text-right tabular-nums text-slate-300">{formatCost(log.cost)}</td>
|
||||
<td class="max-w-sm truncate px-4 py-3 text-slate-400">{log.notes || '—'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,6 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { GridlerFull, TextInputCtrl, type GridlerColumn } from '@warkypublic/svelix';
|
||||
import { api } from '../../api';
|
||||
import { GlobalStateStore } from '../../shellState';
|
||||
import { adminGridTheme } from '../../gridTheme';
|
||||
import type { ProjectSummary } from '../../types';
|
||||
|
||||
let projects = $state<ProjectSummary[]>([]);
|
||||
@@ -11,12 +14,39 @@
|
||||
let newName = $state('');
|
||||
let newDesc = $state('');
|
||||
let createError = $state('');
|
||||
let selectedProject = $state<ProjectSummary | null>(null);
|
||||
const projectDataSourceOptions = {
|
||||
url: '/api/rs',
|
||||
authToken: GlobalStateStore.getState().session.authToken,
|
||||
schema: 'public',
|
||||
entity: 'projects',
|
||||
uniqueID: 'id',
|
||||
hotfields: ['id', 'guid'],
|
||||
sort: [{ column: 'created_at', direction: 'desc' }]
|
||||
} as unknown as {
|
||||
url: string;
|
||||
authToken?: string;
|
||||
schema: string;
|
||||
entity: string;
|
||||
uniqueID: string;
|
||||
hotfields: string[];
|
||||
};
|
||||
|
||||
const columns: GridlerColumn[] = [
|
||||
{ id: 'name', title: 'Name', dataKey: 'name', width: 260 },
|
||||
{ id: 'description', title: 'Description', dataKey: 'description', width: 360 },
|
||||
{ id: 'last_active_at', title: 'Last Active', dataKey: 'last_active_at', width: 200, format: 'datetime' },
|
||||
{ id: 'created_at', title: 'Created', dataKey: 'created_at', width: 200, format: 'datetime' }
|
||||
];
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
projects = await api.projects.list();
|
||||
if (selectedProject) {
|
||||
selectedProject = projects.find((project) => project.id === selectedProject?.id) ?? null;
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load projects';
|
||||
} finally {
|
||||
@@ -41,8 +71,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
return new Date(value).toLocaleDateString();
|
||||
function onProjectRowClick(_row: number, rowData: Record<string, unknown> | undefined) {
|
||||
if (!rowData) {
|
||||
selectedProject = null;
|
||||
return;
|
||||
}
|
||||
const id = String(rowData.guid ?? rowData.id ?? '');
|
||||
selectedProject = {
|
||||
id,
|
||||
name: String(rowData.name ?? ''),
|
||||
description: String(rowData.description ?? ''),
|
||||
created_at: String(rowData.created_at ?? ''),
|
||||
last_active_at: String(rowData.last_active_at ?? ''),
|
||||
thought_count: Number(rowData.thought_count ?? 0)
|
||||
};
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
@@ -70,16 +112,15 @@
|
||||
<div class="rounded-2xl border border-cyan-400/20 bg-slate-900 p-5">
|
||||
<h3 class="text-sm font-semibold text-white">Create project</h3>
|
||||
<div class="mt-3 space-y-3">
|
||||
<input
|
||||
type="text"
|
||||
<TextInputCtrl
|
||||
label="Project name"
|
||||
placeholder="Name"
|
||||
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white placeholder-slate-500 focus:border-cyan-400/40 focus:outline-none"
|
||||
required
|
||||
bind:value={newName}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
<TextInputCtrl
|
||||
label="Description"
|
||||
placeholder="Description (optional)"
|
||||
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white placeholder-slate-500 focus:border-cyan-400/40 focus:outline-none"
|
||||
bind:value={newDesc}
|
||||
/>
|
||||
{#if createError}<p class="text-xs text-rose-300">{createError}</p>{/if}
|
||||
@@ -109,29 +150,25 @@
|
||||
No projects yet.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-hidden rounded-2xl border border-white/10">
|
||||
<table class="min-w-full divide-y divide-white/10 text-sm text-slate-300">
|
||||
<thead class="bg-white/5 text-xs uppercase tracking-[0.18em] text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left font-medium">Name</th>
|
||||
<th class="px-4 py-3 text-left font-medium">Description</th>
|
||||
<th class="px-4 py-3 text-right font-medium">Thoughts</th>
|
||||
<th class="px-4 py-3 text-right font-medium">Last active</th>
|
||||
<th class="px-4 py-3 text-right font-medium">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-white/5 bg-slate-950/30">
|
||||
{#each projects as p}
|
||||
<tr class="hover:bg-white/[0.03]">
|
||||
<td class="px-4 py-3 font-medium text-white">{p.name}</td>
|
||||
<td class="max-w-xs truncate px-4 py-3 text-slate-400">{p.description || '—'}</td>
|
||||
<td class="px-4 py-3 text-right tabular-nums text-slate-200">{p.thought_count}</td>
|
||||
<td class="px-4 py-3 text-right text-slate-400">{formatDate(p.last_active_at)}</td>
|
||||
<td class="px-4 py-3 text-right text-slate-400">{formatDate(p.created_at)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="rounded-2xl border border-white/10 bg-slate-950/30 p-3">
|
||||
<GridlerFull
|
||||
{columns}
|
||||
theme={adminGridTheme}
|
||||
rowMarkers="number"
|
||||
height={420}
|
||||
dataSource="resolvespec"
|
||||
dataSourceOptions={projectDataSourceOptions}
|
||||
serverSideSearch={true}
|
||||
searchColumns={['name', 'description']}
|
||||
onRowClick={onProjectRowClick}
|
||||
/>
|
||||
</div>
|
||||
{#if selectedProject}
|
||||
<div class="rounded-2xl border border-white/10 bg-slate-900/70 p-4">
|
||||
<h3 class="text-sm font-semibold text-white">Selected project</h3>
|
||||
<p class="mt-2 text-sm text-slate-300"><strong class="text-slate-100">{selectedProject.name}</strong> · {selectedProject.thought_count} thoughts</p>
|
||||
<p class="mt-1 text-sm text-slate-400">{selectedProject.description || 'No description.'}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import type { ShellPage, StatusResponse } from '../../types';
|
||||
import FilesPage from '../files/FilesPage.svelte';
|
||||
import GuardrailsPage from '../guardrails/GuardrailsPage.svelte';
|
||||
import LearningsPage from '../learnings/LearningsPage.svelte';
|
||||
import MaintenancePage from '../maintenance/MaintenancePage.svelte';
|
||||
import DashboardPage from '../dashboard/DashboardPage.svelte';
|
||||
import ProjectsPage from '../projects/ProjectsPage.svelte';
|
||||
import SkillsPage from '../skills/SkillsPage.svelte';
|
||||
@@ -37,12 +39,16 @@
|
||||
<ProjectsPage />
|
||||
{:else if currentPage === 'thoughts'}
|
||||
<ThoughtsPage />
|
||||
{:else if currentPage === 'learnings'}
|
||||
<LearningsPage />
|
||||
{:else if currentPage === 'skills'}
|
||||
<SkillsPage />
|
||||
{:else if currentPage === 'guardrails'}
|
||||
<GuardrailsPage />
|
||||
{:else if currentPage === 'files'}
|
||||
<FilesPage />
|
||||
{:else if currentPage === 'maintenance'}
|
||||
<MaintenancePage />
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -15,9 +15,11 @@
|
||||
{ id: 'dashboard', label: 'Dashboard', description: 'System overview and status.' },
|
||||
{ id: 'projects', label: 'Projects', description: 'Browse and manage projects.' },
|
||||
{ id: 'thoughts', label: 'Thoughts', description: 'Search and inspect thoughts.' },
|
||||
{ id: 'learnings', label: 'Learnings', description: 'Curated insights and outcomes.' },
|
||||
{ id: 'skills', label: 'Skills', description: 'Agent skill registry.' },
|
||||
{ id: 'guardrails', label: 'Guardrails', description: 'Agent guardrail registry.' },
|
||||
{ id: 'files', label: 'Files', description: 'Stored file inventory.' }
|
||||
{ id: 'files', label: 'Files', description: 'Stored file inventory.' },
|
||||
{ id: 'maintenance', label: 'Maintenance', description: 'Task state and upkeep actions.' }
|
||||
];
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,165 +1,389 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '../../api';
|
||||
import type { Thought, SearchResult } from '../../types';
|
||||
import {
|
||||
GridlerFull,
|
||||
type GridColumnFilters,
|
||||
type GridlerColumn,
|
||||
} from "@warkypublic/svelix";
|
||||
import { api } from "../../api";
|
||||
import { GlobalStateStore } from "../../shellState";
|
||||
import { adminGridTheme } from "../../gridTheme";
|
||||
import type { StoredFile, Thought, ThoughtLink } from "../../types";
|
||||
|
||||
type Row = Thought | SearchResult;
|
||||
|
||||
let rows = $state<Row[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let query = $state('');
|
||||
let includeArchived = $state(false);
|
||||
let actionBusy = $state<string | null>(null);
|
||||
let actionError = $state('');
|
||||
let actionError = $state("");
|
||||
let inspectorBusy = $state(false);
|
||||
let selectedThought = $state<Thought | null>(null);
|
||||
let relatedLinks = $state<ThoughtLink[]>([]);
|
||||
let relatedFiles = $state<StoredFile[]>([]);
|
||||
let gridTotal = $state<number | null>(null);
|
||||
const thoughtsDataSourceOptions = {
|
||||
url: "/api/rs",
|
||||
authToken: GlobalStateStore.getState().session.authToken,
|
||||
schema: "public",
|
||||
entity: "thoughts",
|
||||
uniqueID: "id",
|
||||
hotfields: ["guid", "metadata", "project_id", "archived_at"],
|
||||
sort: [{ column: "created_at", direction: "desc" }],
|
||||
} as unknown as {
|
||||
url: string;
|
||||
authToken?: string;
|
||||
schema: string;
|
||||
entity: string;
|
||||
uniqueID: string;
|
||||
hotfields: string[];
|
||||
};
|
||||
|
||||
let searchTimer: ReturnType<typeof setTimeout>;
|
||||
const columns: GridlerColumn[] = [
|
||||
{ id: "id", title: "ID", dataKey: "id", width: 90, format: "number" },
|
||||
{ id: "content", title: "Content", dataKey: "content", width: 520 },
|
||||
{
|
||||
id: "created_at",
|
||||
title: "Created",
|
||||
dataKey: "created_at",
|
||||
width: 210,
|
||||
format: "datetime",
|
||||
},
|
||||
{
|
||||
id: "updated_at",
|
||||
title: "Updated",
|
||||
dataKey: "updated_at",
|
||||
width: 210,
|
||||
format: "datetime",
|
||||
},
|
||||
{
|
||||
id: "archived_at",
|
||||
title: "Archived",
|
||||
dataKey: "archived_at",
|
||||
width: 210,
|
||||
format: "datetime",
|
||||
},
|
||||
];
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
rows = await api.thoughts.list({ q: query || undefined, include_archived: includeArchived || undefined, limit: 100 }) as Row[];
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load thoughts';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onQueryInput() {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(load, 350);
|
||||
}
|
||||
const baseFilters = $derived<GridColumnFilters>(
|
||||
includeArchived
|
||||
? {}
|
||||
: {
|
||||
archived_at: {
|
||||
value: "",
|
||||
op: "isNull",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
async function archive(id: string) {
|
||||
actionBusy = id;
|
||||
actionError = '';
|
||||
actionError = "";
|
||||
try {
|
||||
await api.thoughts.archive(id);
|
||||
await load();
|
||||
if (selectedThought?.id === id)
|
||||
selectedThought.archived_at = new Date().toISOString();
|
||||
} catch (e) {
|
||||
actionError = e instanceof Error ? e.message : 'Archive failed';
|
||||
actionError = e instanceof Error ? e.message : "Archive failed";
|
||||
} finally {
|
||||
actionBusy = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: string) {
|
||||
if (!confirm('Permanently delete this thought?')) return;
|
||||
if (!confirm("Permanently delete this thought?")) return;
|
||||
actionBusy = id;
|
||||
actionError = '';
|
||||
actionError = "";
|
||||
try {
|
||||
await api.thoughts.delete(id);
|
||||
await load();
|
||||
if (selectedThought?.id === id) {
|
||||
selectedThought = null;
|
||||
relatedLinks = [];
|
||||
relatedFiles = [];
|
||||
}
|
||||
} catch (e) {
|
||||
actionError = e instanceof Error ? e.message : 'Delete failed';
|
||||
actionError = e instanceof Error ? e.message : "Delete failed";
|
||||
} finally {
|
||||
actionBusy = null;
|
||||
}
|
||||
}
|
||||
|
||||
function isArchived(row: Row): boolean {
|
||||
return 'archived_at' in row && !!row.archived_at;
|
||||
function normalizeThought(rowData: Record<string, unknown>): Thought {
|
||||
const metadataObj = (rowData.metadata ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
id: String(rowData.id ?? ""),
|
||||
guid: rowData.guid ? String(rowData.guid) : undefined,
|
||||
content: typeof rowData.content === "string" ? rowData.content : "",
|
||||
project_id: rowData.project_id ? String(rowData.project_id) : undefined,
|
||||
archived_at: rowData.archived_at
|
||||
? String(rowData.archived_at)
|
||||
: undefined,
|
||||
created_at: String(rowData.created_at ?? ""),
|
||||
updated_at: String(rowData.updated_at ?? ""),
|
||||
metadata: {
|
||||
people: Array.isArray(metadataObj.people)
|
||||
? (metadataObj.people as string[])
|
||||
: [],
|
||||
action_items: Array.isArray(metadataObj.action_items)
|
||||
? (metadataObj.action_items as string[])
|
||||
: [],
|
||||
dates_mentioned: Array.isArray(metadataObj.dates_mentioned)
|
||||
? (metadataObj.dates_mentioned as string[])
|
||||
: [],
|
||||
topics: Array.isArray(metadataObj.topics)
|
||||
? (metadataObj.topics as string[])
|
||||
: [],
|
||||
type: typeof metadataObj.type === "string" ? metadataObj.type : "",
|
||||
source:
|
||||
typeof metadataObj.source === "string" ? metadataObj.source : "",
|
||||
metadata_status:
|
||||
typeof metadataObj.metadata_status === "string"
|
||||
? metadataObj.metadata_status
|
||||
: "",
|
||||
metadata_error:
|
||||
typeof metadataObj.metadata_error === "string"
|
||||
? metadataObj.metadata_error
|
||||
: undefined,
|
||||
attachments: Array.isArray(metadataObj.attachments)
|
||||
? (metadataObj.attachments as Thought["metadata"]["attachments"])
|
||||
: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function content(row: Row): string {
|
||||
return row.content.length > 120 ? row.content.slice(0, 120) + '…' : row.content;
|
||||
async function inspectThought(row: Thought | null) {
|
||||
selectedThought = row;
|
||||
relatedLinks = [];
|
||||
relatedFiles = [];
|
||||
if (!row) return;
|
||||
|
||||
inspectorBusy = true;
|
||||
try {
|
||||
const thoughtRef = row.guid ?? row.id;
|
||||
const [links, files] = await Promise.all([
|
||||
api.thoughts.links(row.id),
|
||||
api.files.list({ thought_id: thoughtRef }),
|
||||
]);
|
||||
relatedLinks = links;
|
||||
relatedFiles = files;
|
||||
} finally {
|
||||
inspectorBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
function onRowClick(
|
||||
_row: number,
|
||||
rowData: Record<string, unknown> | undefined,
|
||||
) {
|
||||
if (!rowData) {
|
||||
void inspectThought(null);
|
||||
return;
|
||||
}
|
||||
void inspectThought(normalizeThought(rowData));
|
||||
}
|
||||
|
||||
function onGridEvent(
|
||||
type: string,
|
||||
_item?: unknown,
|
||||
_column?: unknown,
|
||||
_coords?: unknown,
|
||||
detail?: Record<string, unknown>,
|
||||
) {
|
||||
if (type !== "page_loaded" && type !== "load") return;
|
||||
const total = detail?.total;
|
||||
if (typeof total === "number") gridTotal = total;
|
||||
}
|
||||
|
||||
function formatDate(value?: string) {
|
||||
if (!value) return "—";
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
function isSelectedArchived(): boolean {
|
||||
return !!selectedThought?.archived_at;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-4 w-full">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-white">Thoughts</h2>
|
||||
<p class="mt-1 text-sm text-slate-400">{rows.length} result{rows.length !== 1 ? 's' : ''}</p>
|
||||
<p class="mt-1 text-sm text-slate-400">
|
||||
{#if gridTotal === null}
|
||||
Server-backed grid
|
||||
{:else}
|
||||
{gridTotal} result{gridTotal !== 1 ? "s" : ""}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="flex items-center gap-2 text-sm text-slate-400">
|
||||
<input type="checkbox" class="accent-cyan-400" bind:checked={includeArchived} onchange={load} />
|
||||
<input
|
||||
type="checkbox"
|
||||
class="accent-cyan-400"
|
||||
bind:checked={includeArchived}
|
||||
/>
|
||||
Archived
|
||||
</label>
|
||||
<button
|
||||
class="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-200 transition hover:bg-white/10"
|
||||
onclick={load}
|
||||
>Refresh</button>
|
||||
onclick={() => {
|
||||
selectedThought = null;
|
||||
relatedLinks = [];
|
||||
relatedFiles = [];
|
||||
}}>Refresh</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search thoughts…"
|
||||
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white placeholder-slate-500 focus:border-cyan-400/40 focus:outline-none"
|
||||
bind:value={query}
|
||||
oninput={onQueryInput}
|
||||
/>
|
||||
|
||||
{#if actionError}
|
||||
<p class="text-sm text-rose-300">{actionError}</p>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-12 text-center text-slate-400">Loading…</div>
|
||||
{:else if error}
|
||||
<div class="rounded-2xl border border-rose-400/30 bg-rose-400/10 px-4 py-4 text-sm text-rose-100">{error}</div>
|
||||
{:else if rows.length === 0}
|
||||
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-12 text-center text-slate-500">No thoughts found.</div>
|
||||
{:else}
|
||||
<div class="overflow-hidden rounded-2xl border border-white/10">
|
||||
<table class="min-w-full divide-y divide-white/10 text-sm text-slate-300">
|
||||
<thead class="bg-white/5 text-xs uppercase tracking-[0.18em] text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left font-medium">Content</th>
|
||||
<th class="px-4 py-3 text-left font-medium">Type</th>
|
||||
<th class="px-4 py-3 text-left font-medium">Status</th>
|
||||
<th class="px-4 py-3 text-right font-medium">Created</th>
|
||||
<th class="px-4 py-3 text-right font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-white/5 bg-slate-950/30">
|
||||
{#each rows as row}
|
||||
<tr class={`hover:bg-white/[0.03] ${isArchived(row) ? 'opacity-50' : ''}`}>
|
||||
<td class="max-w-sm px-4 py-3 align-top">
|
||||
<p class="text-white">{content(row)}</p>
|
||||
{#if row.metadata.topics?.length}
|
||||
<p class="mt-1 text-xs text-slate-500">{row.metadata.topics.slice(0,3).join(', ')}</p>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-3 align-top">
|
||||
<span class="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-slate-300">
|
||||
{row.metadata.type || '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 align-top text-xs text-slate-400">
|
||||
{isArchived(row) ? 'archived' : (row.metadata.metadata_status || 'active')}
|
||||
</td>
|
||||
<td class="px-4 py-3 align-top text-right text-slate-400">{formatDate(row.created_at)}</td>
|
||||
<td class="px-4 py-3 align-top text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
{#if !isArchived(row)}
|
||||
<button
|
||||
class="text-xs text-slate-400 underline-offset-2 hover:text-slate-200"
|
||||
onclick={() => archive(row.id)}
|
||||
disabled={actionBusy === row.id}
|
||||
>Archive</button>
|
||||
{/if}
|
||||
<button
|
||||
class="text-xs text-rose-400 underline-offset-2 hover:text-rose-300"
|
||||
onclick={() => remove(row.id)}
|
||||
disabled={actionBusy === row.id}
|
||||
>Delete</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="grid gap-4 xl:grid-cols-[1.6fr_1fr]">
|
||||
<div class="rounded-2xl border border-white/10 bg-slate-950/30 p-3">
|
||||
<GridlerFull
|
||||
{columns}
|
||||
theme={adminGridTheme}
|
||||
rowMarkers="number"
|
||||
height={560}
|
||||
width="100%"
|
||||
pageSize={40}
|
||||
dataSource="resolvespec"
|
||||
dataSourceOptions={thoughtsDataSourceOptions}
|
||||
serverSideSearch={true}
|
||||
searchColumns={["content"]}
|
||||
filters={baseFilters}
|
||||
{onGridEvent}
|
||||
{onRowClick}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<aside class="rounded-2xl border border-white/10 bg-slate-900/70 p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<h3 class="text-sm font-semibold text-white">Thought Inspector</h3>
|
||||
{#if selectedThought}
|
||||
<div class="flex gap-2">
|
||||
{#if !isSelectedArchived()}
|
||||
<button
|
||||
class="text-xs text-slate-300 hover:text-white"
|
||||
onclick={() => {
|
||||
if (selectedThought) void archive(selectedThought.id);
|
||||
}}
|
||||
disabled={actionBusy === selectedThought?.id}>Archive</button
|
||||
>
|
||||
{/if}
|
||||
<button
|
||||
class="text-xs text-rose-300 hover:text-rose-200"
|
||||
onclick={() => {
|
||||
if (selectedThought) void remove(selectedThought.id);
|
||||
}}
|
||||
disabled={actionBusy === selectedThought?.id}>Delete</button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !selectedThought}
|
||||
<p class="mt-3 text-sm text-slate-500">
|
||||
Select a thought row to inspect metadata, links, and file records.
|
||||
</p>
|
||||
{:else}
|
||||
<div class="mt-3 space-y-3 text-sm text-slate-300">
|
||||
<p class="text-slate-200">{selectedThought.content}</p>
|
||||
<div class="rounded-xl border border-white/10 bg-white/5 p-3">
|
||||
<p>
|
||||
<strong class="text-slate-100">Type:</strong>
|
||||
{selectedThought.metadata.type || "—"}
|
||||
</p>
|
||||
<p>
|
||||
<strong class="text-slate-100">Status:</strong>
|
||||
{selectedThought.metadata.metadata_status || "active"}
|
||||
</p>
|
||||
{#if selectedThought.metadata.metadata_error}
|
||||
<p class="mt-1 text-rose-300">
|
||||
<strong>Error:</strong>
|
||||
{selectedThought.metadata.metadata_error}
|
||||
</p>
|
||||
{/if}
|
||||
<p class="mt-1">
|
||||
<strong class="text-slate-100">Created:</strong>
|
||||
{formatDate(selectedThought.created_at)}
|
||||
</p>
|
||||
<p>
|
||||
<strong class="text-slate-100">Updated:</strong>
|
||||
{formatDate(selectedThought.updated_at)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">
|
||||
Related Links ({relatedLinks.length})
|
||||
</p>
|
||||
{#if inspectorBusy}
|
||||
<p class="mt-2 text-xs text-slate-500">Loading links…</p>
|
||||
{:else if relatedLinks.length === 0}
|
||||
<p class="mt-2 text-xs text-slate-500">No related links.</p>
|
||||
{:else}
|
||||
<ul class="mt-2 space-y-1 text-xs">
|
||||
{#each relatedLinks as link}
|
||||
<li
|
||||
class="rounded-lg border border-white/10 bg-white/5 px-2 py-1"
|
||||
>
|
||||
<code class="text-cyan-100">{link.from_id}</code> →
|
||||
<code class="text-cyan-100">{link.to_id}</code>
|
||||
· {link.relation}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">
|
||||
Attachments ({selectedThought.metadata.attachments?.length ?? 0})
|
||||
</p>
|
||||
{#if (selectedThought.metadata.attachments?.length ?? 0) === 0}
|
||||
<p class="mt-2 text-xs text-slate-500">No attachment metadata.</p>
|
||||
{:else}
|
||||
<ul class="mt-2 space-y-1 text-xs">
|
||||
{#each selectedThought.metadata.attachments ?? [] as attachment}
|
||||
<li
|
||||
class="rounded-lg border border-white/10 bg-white/5 px-2 py-1"
|
||||
>
|
||||
{attachment.name} · {attachment.media_type} · {attachment.size_bytes}
|
||||
bytes
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">
|
||||
Stored File Records ({relatedFiles.length})
|
||||
</p>
|
||||
{#if inspectorBusy}
|
||||
<p class="mt-2 text-xs text-slate-500">Loading files…</p>
|
||||
{:else if relatedFiles.length === 0}
|
||||
<p class="mt-2 text-xs text-slate-500">
|
||||
No stored files linked to this thought.
|
||||
</p>
|
||||
{:else}
|
||||
<ul class="mt-2 space-y-1 text-xs">
|
||||
{#each relatedFiles as file}
|
||||
<li
|
||||
class="rounded-lg border border-white/10 bg-white/5 px-2 py-1"
|
||||
>
|
||||
<a
|
||||
href={`/files/${file.id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="text-cyan-300 hover:text-cyan-200">{file.name}</a
|
||||
>
|
||||
· {file.media_type}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
18
ui/src/gridTheme.ts
Normal file
18
ui/src/gridTheme.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { GridlerTheme } from '@warkypublic/svelix';
|
||||
|
||||
export const adminGridTheme: Partial<GridlerTheme> = {
|
||||
accentColor: '#22d3ee',
|
||||
accentFg: '#06232b',
|
||||
bgCell: '#020617',
|
||||
bgHeader: '#0f172a',
|
||||
bgHeaderHasFocus: '#164e63',
|
||||
bgSearchResult: '#083344',
|
||||
borderColor: '#1e293b',
|
||||
foreground: '#cbd5e1',
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontSize: 13,
|
||||
headerFontSize: 12,
|
||||
lineHeight: 1.4,
|
||||
cellHorizontalPadding: 10,
|
||||
cellVerticalPadding: 8
|
||||
};
|
||||
100
ui/src/types.ts
100
ui/src/types.ts
@@ -2,10 +2,25 @@ export type AccessEntry = {
|
||||
key_id: string;
|
||||
last_accessed_at: string;
|
||||
last_path: string;
|
||||
remote_addr: string;
|
||||
user_agent: string;
|
||||
request_count: number;
|
||||
};
|
||||
|
||||
export type RequestAggregate = {
|
||||
key: string;
|
||||
request_count: number;
|
||||
};
|
||||
|
||||
export type AccessMetrics = {
|
||||
total_requests: number;
|
||||
unique_principals: number;
|
||||
unique_ips: number;
|
||||
unique_agents: number;
|
||||
top_ips: RequestAggregate[];
|
||||
top_agents: RequestAggregate[];
|
||||
};
|
||||
|
||||
export type StatusResponse = {
|
||||
title: string;
|
||||
description: string;
|
||||
@@ -17,6 +32,7 @@ export type StatusResponse = {
|
||||
connected_window: string;
|
||||
oauth_enabled: boolean;
|
||||
entries: AccessEntry[];
|
||||
metrics: AccessMetrics;
|
||||
};
|
||||
|
||||
export type NavItem = {
|
||||
@@ -26,7 +42,7 @@ export type NavItem = {
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export type ShellPage = 'dashboard' | 'projects' | 'thoughts' | 'skills' | 'guardrails' | 'files';
|
||||
export type ShellPage = 'dashboard' | 'projects' | 'thoughts' | 'learnings' | 'skills' | 'guardrails' | 'files' | 'maintenance';
|
||||
|
||||
export type Project = {
|
||||
id: string;
|
||||
@@ -49,10 +65,21 @@ export type ThoughtMetadata = {
|
||||
source: string;
|
||||
metadata_status: string;
|
||||
metadata_error?: string;
|
||||
attachments?: ThoughtAttachment[];
|
||||
};
|
||||
|
||||
export type ThoughtAttachment = {
|
||||
file_id: string;
|
||||
name: string;
|
||||
media_type: string;
|
||||
kind?: string;
|
||||
size_bytes: number;
|
||||
sha256?: string;
|
||||
};
|
||||
|
||||
export type Thought = {
|
||||
id: string;
|
||||
guid?: string;
|
||||
content: string;
|
||||
metadata: ThoughtMetadata;
|
||||
project_id?: string;
|
||||
@@ -61,6 +88,14 @@ export type Thought = {
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type ThoughtLink = {
|
||||
id: number;
|
||||
from_id: number;
|
||||
to_id: number;
|
||||
relation: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type SearchResult = {
|
||||
id: string;
|
||||
content: string;
|
||||
@@ -109,3 +144,66 @@ export type ThoughtStats = {
|
||||
top_topics: { key: string; count: number }[];
|
||||
top_people: { key: string; count: number }[];
|
||||
};
|
||||
|
||||
export type Learning = {
|
||||
id: string;
|
||||
summary: string;
|
||||
details: string;
|
||||
category: string;
|
||||
area: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
confidence: string;
|
||||
action_required: boolean;
|
||||
source_type?: string;
|
||||
source_ref?: string;
|
||||
tags?: string;
|
||||
project_id?: string;
|
||||
related_thought_id?: string;
|
||||
related_skill_id?: string;
|
||||
reviewed_at?: string;
|
||||
reviewed_by?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type MaintenanceTask = {
|
||||
id: string;
|
||||
name: string;
|
||||
category?: string;
|
||||
priority: string;
|
||||
frequency_days?: number;
|
||||
next_due?: string;
|
||||
last_completed?: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type MaintenanceLog = {
|
||||
id: string;
|
||||
task_id: string;
|
||||
completed_at: string;
|
||||
performed_by?: string;
|
||||
cost?: number;
|
||||
notes?: string;
|
||||
next_action?: string;
|
||||
};
|
||||
|
||||
export type BackfillResult = {
|
||||
model: string;
|
||||
scanned: number;
|
||||
embedded: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
dry_run: boolean;
|
||||
};
|
||||
|
||||
export type MetadataRetryResult = {
|
||||
scanned: number;
|
||||
retried: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
dry_run: boolean;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user