feat(ui): add maintenance page for task management
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:
warkanum committed 2026-04-26 23:13:41 +02:00
1 parent b39cd3ba72
commit 927a118338
48 files changed
+2228 -868

No files matched your search

+17 -1
View File
@@ -20,10 +20,26 @@ LDFLAGS := -s -w \
-X $(BUILDINFO_PKG).Commit=$(COMMIT_SHA) \ -X $(BUILDINFO_PKG).Commit=$(COMMIT_SHA) \
-X $(BUILDINFO_PKG).BuildDate=$(BUILD_DATE) -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 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 build: ui-build
@mkdir -p $(BIN_DIR) @mkdir -p $(BIN_DIR)
go build -ldflags "$(LDFLAGS)" -o $(SERVER_BIN) $(CMD_SERVER) go build -ldflags "$(LDFLAGS)" -o $(SERVER_BIN) $(CMD_SERVER)
+34
View File
@@ -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 | | `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 | | `set_active_project` | Set session project scope; requires a stateful MCP session |
| `get_active_project` | Get current session project | | `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 | | `summarize_thoughts` | LLM prose summary over a filtered set |
| `recall_context` | Semantic + recency context block for injection | | `recall_context` | Semantic + recency context block for injection |
| `link_thoughts` | Create a typed relationship between thoughts | | `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 | | `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 | | `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 ## Self-Documenting Tools
AMCS includes a built-in tool directory that models can read and annotate. 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`. 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.** **Use `pnpm` for all UI work in this repo.**
- `make build` — runs the real UI build first, then compiles the Go server - `make build` — runs the real UI build first, then compiles the Go server
+79
View 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)
})
}
+3
View File
@@ -191,6 +191,7 @@ func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *st
filesTool := tools.NewFilesTool(db, activeProjects) filesTool := tools.NewFilesTool(db, activeProjects)
enrichmentRetryer := tools.NewEnrichmentRetryer(context.Background(), db, bgMetadata, cfg.Capture, cfg.AI.Metadata.Timeout, activeProjects, logger) enrichmentRetryer := tools.NewEnrichmentRetryer(context.Background(), db, bgMetadata, cfg.Capture, cfg.AI.Metadata.Timeout, activeProjects, logger)
backfillTool := tools.NewBackfillTool(db, bgEmbeddings, activeProjects, logger) backfillTool := tools.NewBackfillTool(db, bgEmbeddings, activeProjects, logger)
adminActions := newAdminActions(backfillTool, enrichmentRetryer, logger)
toolSet := mcpserver.ToolSet{ toolSet := mcpserver.ToolSet{
Capture: tools.NewCaptureTool(db, embeddings, cfg.Capture, activeProjects, enrichmentRetryer, backfillTool), 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/register", oauthRegisterHandler(dynClients, logger))
mux.HandleFunc("/api/oauth/authorize", oauthAuthorizeHandler(dynClients, authCodes, logger)) mux.HandleFunc("/api/oauth/authorize", oauthAuthorizeHandler(dynClients, authCodes, logger))
mux.HandleFunc("/api/oauth/token", oauthTokenHandler(oauthRegistry, tokenStore, 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("/favicon.ico", serveFavicon)
mux.HandleFunc("/images/project.jpg", serveHomeImage) mux.HandleFunc("/images/project.jpg", serveHomeImage)
mux.HandleFunc("/images/icon.png", serveIcon) mux.HandleFunc("/images/icon.png", serveIcon)
+18
View File
@@ -61,6 +61,7 @@ func TestResolveSpecGuardAllowsSupportedMutations(t *testing.T) {
entity string entity string
operation string operation string
}{ }{
{name: "learnings read", entity: "learnings", operation: "read"},
{name: "projects create", entity: "projects", operation: "create"}, {name: "projects create", entity: "projects", operation: "create"},
{name: "thoughts update", entity: "thoughts", operation: "update"}, {name: "thoughts update", entity: "thoughts", operation: "update"},
{name: "thoughts delete", entity: "thoughts", operation: "delete"}, {name: "thoughts delete", entity: "thoughts", operation: "delete"},
@@ -119,6 +120,13 @@ func TestResolveSpecGuardBlocksUnsupportedMutations(t *testing.T) {
wantCode: http.StatusForbidden, wantCode: http.StatusForbidden,
wantMessageIn: `operation "delete" is not allowed for public.stored_files`, 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", name: "unknown operation is rejected",
entity: "projects", 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")
}
+2
View File
@@ -25,6 +25,7 @@ type statusAPIResponse struct {
TotalKnown int `json:"total_known"` TotalKnown int `json:"total_known"`
ConnectedWindow string `json:"connected_window"` ConnectedWindow string `json:"connected_window"`
Entries []auth.AccessSnapshot `json:"entries"` Entries []auth.AccessSnapshot `json:"entries"`
Metrics auth.AccessMetrics `json:"metrics"`
OAuthEnabled bool `json:"oauth_enabled"` OAuthEnabled bool `json:"oauth_enabled"`
} }
@@ -40,6 +41,7 @@ func statusSnapshot(info buildinfo.Info, tracker *auth.AccessTracker, oauthEnabl
TotalKnown: len(entries), TotalKnown: len(entries),
ConnectedWindow: "last 10 minutes", ConnectedWindow: "last 10 minutes",
Entries: entries, Entries: entries,
Metrics: tracker.Metrics(20),
OAuthEnabled: oauthEnabled, OAuthEnabled: oauthEnabled,
} }
} }
+9
View File
@@ -49,6 +49,15 @@ func TestStatusSnapshotShowsTrackedAccess(t *testing.T) {
if snapshot.Entries[0].KeyID != "client-a" || snapshot.Entries[0].LastPath != "/files" { 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]) 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) { func TestStatusAPIHandlerReturnsJSON(t *testing.T) {
+87 -4
View File
@@ -1,7 +1,9 @@
package auth package auth
import ( import (
"net"
"sort" "sort"
"strings"
"sync" "sync"
"time" "time"
) )
@@ -16,12 +18,19 @@ type AccessSnapshot struct {
} }
type AccessTracker struct { type AccessTracker struct {
mu sync.RWMutex mu sync.RWMutex
entries map[string]AccessSnapshot entries map[string]AccessSnapshot
ipCounts map[string]int
agentCounts map[string]int
totalRequests int
} }
func NewAccessTracker() *AccessTracker { 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) { 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() t.mu.Lock()
defer t.mu.Unlock() defer t.mu.Unlock()
normalizedRemoteAddr := normalizeRemoteAddr(remoteAddr)
entry := t.entries[keyID] entry := t.entries[keyID]
entry.KeyID = keyID entry.KeyID = keyID
entry.LastPath = path entry.LastPath = path
entry.RemoteAddr = remoteAddr entry.RemoteAddr = normalizedRemoteAddr
entry.UserAgent = userAgent entry.UserAgent = userAgent
entry.LastAccessedAt = now.UTC() entry.LastAccessedAt = now.UTC()
entry.RequestCount++ entry.RequestCount++
t.entries[keyID] = entry 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 { func (t *AccessTracker) Snapshot() []AccessSnapshot {
@@ -79,3 +110,55 @@ func (t *AccessTracker) ConnectedCount(now time.Time, window time.Duration) int
} }
return count 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
}
+42
View File
@@ -30,6 +30,9 @@ func TestAccessTrackerRecordAndSnapshot(t *testing.T) {
if snap[0].UserAgent != "agent-a2" { if snap[0].UserAgent != "agent-a2" {
t.Fatalf("snapshot[0].UserAgent = %q, want agent-a2", snap[0].UserAgent) 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) { func TestAccessTrackerConnectedCount(t *testing.T) {
@@ -43,3 +46,42 @@ func TestAccessTrackerConnectedCount(t *testing.T) {
t.Fatalf("ConnectedCount() = %d, want 1", got) 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.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 { func streamableTestToolSet() ToolSet {
+18
View File
@@ -75,6 +75,9 @@ func (t *LearningsTool) Add(ctx context.Context, req *mcp.CallToolRequest, in Ad
if summary == "" { if summary == "" {
return nil, AddLearningOutput{}, errRequiredField("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) project, err := resolveProject(ctx, t.store, t.sessions, req, in.Project, false)
if err != nil { 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) { 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) learning, err := t.store.GetLearning(ctx, in.ID)
if err != nil { if err != nil {
return nil, GetLearningOutput{}, err 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) { 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) project, err := resolveProject(ctx, t.store, t.sessions, req, in.Project, false)
if err != nil { if err != nil {
return nil, ListLearningsOutput{}, err 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 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 { func defaultString(value string, fallback string) string {
if value == "" { if value == "" {
return fallback return fallback
+70
View 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)
}
}
+10 -1
View File
@@ -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. - 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. - 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. - 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. - 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. - 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. - 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. - Stored files and attachment metadata must not be sent to the metadata extraction client.
- Do not attach memory to the wrong project. - 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 ## Global Notebook Rules
- Use global memory only for information that is genuinely cross-project or not project-bound. - 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 ## 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
View File
@@ -1,2 +1,3 @@
create extension if not exists vector; create extension if not exists vector;
create extension if not exists pgcrypto; create extension if not exists pgcrypto;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
+226 -22
View File
@@ -17,6 +17,13 @@ CREATE SEQUENCE IF NOT EXISTS public.identity_projects_id
START 1 START 1
CACHE 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 CREATE SEQUENCE IF NOT EXISTS public.identity_embeddings_id
INCREMENT 1 INCREMENT 1
MINVALUE 1 MINVALUE 1
@@ -38,6 +45,20 @@ CREATE SEQUENCE IF NOT EXISTS public.identity_tool_annotations_id
START 1 START 1
CACHE 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 -- Tables for schema: public
CREATE TABLE IF NOT EXISTS public.family_members ( CREATE TABLE IF NOT EXISTS public.family_members (
birth_date date, birth_date date,
@@ -97,6 +118,7 @@ CREATE TABLE IF NOT EXISTS public.projects (
CREATE TABLE IF NOT EXISTS public.thought_links ( CREATE TABLE IF NOT EXISTS public.thought_links (
created_at timestamptz DEFAULT now(), created_at timestamptz DEFAULT now(),
from_id bigint NOT NULL, from_id bigint NOT NULL,
id serial NOT NULL,
relation text NOT NULL, relation text NOT NULL,
to_id bigint 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 ( CREATE TABLE IF NOT EXISTS public.project_skills (
created_at timestamptz NOT NULL DEFAULT now(), created_at timestamptz NOT NULL DEFAULT now(),
id serial NOT NULL,
project_id uuid NOT NULL, project_id uuid NOT NULL,
skill_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 ( CREATE TABLE IF NOT EXISTS public.project_guardrails (
created_at timestamptz NOT NULL DEFAULT now(), created_at timestamptz NOT NULL DEFAULT now(),
guardrail_id uuid NOT NULL, guardrail_id uuid NOT NULL,
id serial NOT NULL,
project_id uuid NOT NULL project_id uuid NOT NULL
); );
@@ -879,6 +903,19 @@ BEGIN
END; 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 $$ DO $$
BEGIN BEGIN
IF NOT EXISTS ( IF NOT EXISTS (
@@ -3102,6 +3139,19 @@ BEGIN
END; 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 $$ DO $$
BEGIN BEGIN
IF NOT EXISTS ( IF NOT EXISTS (
@@ -3154,6 +3204,19 @@ BEGIN
END; 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 $$ DO $$
BEGIN BEGIN
IF NOT EXISTS ( IF NOT EXISTS (
@@ -3181,7 +3244,7 @@ BEGIN
AND constraint_name IN ('family_members_pkey', 'public_family_members_pkey'); AND constraint_name IN ('family_members_pkey', 'public_family_members_pkey');
IF auto_pk_name IS NOT NULL THEN 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; END IF;
-- Add named primary key if it doesn't exist -- Add named primary key if it doesn't exist
@@ -3209,7 +3272,7 @@ BEGIN
AND constraint_name IN ('activities_pkey', 'public_activities_pkey'); AND constraint_name IN ('activities_pkey', 'public_activities_pkey');
IF auto_pk_name IS NOT NULL THEN 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; END IF;
-- Add named primary key if it doesn't exist -- 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'); AND constraint_name IN ('important_dates_pkey', 'public_important_dates_pkey');
IF auto_pk_name IS NOT NULL THEN 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; END IF;
-- Add named primary key if it doesn't exist -- Add named primary key if it doesn't exist
@@ -3265,7 +3328,7 @@ BEGIN
AND constraint_name IN ('thoughts_pkey', 'public_thoughts_pkey'); AND constraint_name IN ('thoughts_pkey', 'public_thoughts_pkey');
IF auto_pk_name IS NOT NULL THEN 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; END IF;
-- Add named primary key if it doesn't exist -- Add named primary key if it doesn't exist
@@ -3293,7 +3356,7 @@ BEGIN
AND constraint_name IN ('projects_pkey', 'public_projects_pkey'); AND constraint_name IN ('projects_pkey', 'public_projects_pkey');
IF auto_pk_name IS NOT NULL THEN 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; END IF;
-- Add named primary key if it doesn't exist -- Add named primary key if it doesn't exist
@@ -3308,6 +3371,34 @@ BEGIN
END; 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 $$ DO $$
DECLARE DECLARE
auto_pk_name text; auto_pk_name text;
@@ -3321,7 +3412,7 @@ BEGIN
AND constraint_name IN ('embeddings_pkey', 'public_embeddings_pkey'); AND constraint_name IN ('embeddings_pkey', 'public_embeddings_pkey');
IF auto_pk_name IS NOT NULL THEN 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; END IF;
-- Add named primary key if it doesn't exist -- 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'); AND constraint_name IN ('professional_contacts_pkey', 'public_professional_contacts_pkey');
IF auto_pk_name IS NOT NULL THEN 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; END IF;
-- Add named primary key if it doesn't exist -- 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'); AND constraint_name IN ('contact_interactions_pkey', 'public_contact_interactions_pkey');
IF auto_pk_name IS NOT NULL THEN 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; END IF;
-- Add named primary key if it doesn't exist -- Add named primary key if it doesn't exist
@@ -3405,7 +3496,7 @@ BEGIN
AND constraint_name IN ('opportunities_pkey', 'public_opportunities_pkey'); AND constraint_name IN ('opportunities_pkey', 'public_opportunities_pkey');
IF auto_pk_name IS NOT NULL THEN 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; END IF;
-- Add named primary key if it doesn't exist -- 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'); AND constraint_name IN ('stored_files_pkey', 'public_stored_files_pkey');
IF auto_pk_name IS NOT NULL THEN 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; END IF;
-- Add named primary key if it doesn't exist -- 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'); AND constraint_name IN ('household_items_pkey', 'public_household_items_pkey');
IF auto_pk_name IS NOT NULL THEN 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; END IF;
-- Add named primary key if it doesn't exist -- 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'); AND constraint_name IN ('household_vendors_pkey', 'public_household_vendors_pkey');
IF auto_pk_name IS NOT NULL THEN 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; END IF;
-- Add named primary key if it doesn't exist -- 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'); AND constraint_name IN ('maintenance_tasks_pkey', 'public_maintenance_tasks_pkey');
IF auto_pk_name IS NOT NULL THEN 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; END IF;
-- Add named primary key if it doesn't exist -- 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'); AND constraint_name IN ('maintenance_logs_pkey', 'public_maintenance_logs_pkey');
IF auto_pk_name IS NOT NULL THEN 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; END IF;
-- Add named primary key if it doesn't exist -- Add named primary key if it doesn't exist
@@ -3573,7 +3664,7 @@ BEGIN
AND constraint_name IN ('recipes_pkey', 'public_recipes_pkey'); AND constraint_name IN ('recipes_pkey', 'public_recipes_pkey');
IF auto_pk_name IS NOT NULL THEN 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; END IF;
-- Add named primary key if it doesn't exist -- 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'); AND constraint_name IN ('meal_plans_pkey', 'public_meal_plans_pkey');
IF auto_pk_name IS NOT NULL THEN 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; END IF;
-- Add named primary key if it doesn't exist -- 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'); AND constraint_name IN ('shopping_lists_pkey', 'public_shopping_lists_pkey');
IF auto_pk_name IS NOT NULL THEN 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; END IF;
-- Add named primary key if it doesn't exist -- 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'); AND constraint_name IN ('chat_histories_pkey', 'public_chat_histories_pkey');
IF auto_pk_name IS NOT NULL THEN 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; END IF;
-- Add named primary key if it doesn't exist -- 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'); AND constraint_name IN ('tool_annotations_pkey', 'public_tool_annotations_pkey');
IF auto_pk_name IS NOT NULL THEN 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; END IF;
-- Add named primary key if it doesn't exist -- Add named primary key if it doesn't exist
@@ -3713,7 +3804,7 @@ BEGIN
AND constraint_name IN ('learnings_pkey', 'public_learnings_pkey'); AND constraint_name IN ('learnings_pkey', 'public_learnings_pkey');
IF auto_pk_name IS NOT NULL THEN 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; END IF;
-- Add named primary key if it doesn't exist -- 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'); AND constraint_name IN ('agent_skills_pkey', 'public_agent_skills_pkey');
IF auto_pk_name IS NOT NULL THEN 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; END IF;
-- Add named primary key if it doesn't exist -- 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'); AND constraint_name IN ('agent_guardrails_pkey', 'public_agent_guardrails_pkey');
IF auto_pk_name IS NOT NULL THEN 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; END IF;
-- Add named primary key if it doesn't exist -- Add named primary key if it doesn't exist
@@ -3784,6 +3875,62 @@ BEGIN
END; 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 -- Indexes for schema: public
CREATE INDEX IF NOT EXISTS idx_activities_start_date_end_date CREATE INDEX IF NOT EXISTS idx_activities_start_date_end_date
ON public.activities USING btree (start_date, end_date); ON public.activities USING btree (start_date, end_date);
@@ -4327,6 +4474,25 @@ BEGIN
END; END;
$$; $$;
DO $$ 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 DECLARE
m_cnt bigint; m_cnt bigint;
BEGIN BEGIN
@@ -4383,6 +4549,44 @@ BEGIN
END IF; END IF;
END; 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 -- Comments for schema: public
+14 -1
View File
@@ -39,4 +39,17 @@ GRANT ALL ON TABLE public.project_guardrails TO amcs;
-- Chat Histories (018) -- Chat Histories (018)
GRANT ALL ON TABLE public.chat_histories TO amcs; 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 $$;
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+11 -11
View File
@@ -11,22 +11,22 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.3", "@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tailwindcss/vite": "^4.1.4", "@tailwindcss/vite": "^4.2.4",
"@types/node": "^24.5.2", "@types/node": "^25.6.0",
"svelte": "^5.28.2", "svelte": "^5.55.5",
"svelte-check": "^4.1.6", "svelte-check": "^4.4.6",
"tailwindcss": "^4.1.4", "tailwindcss": "^4.2.4",
"typescript": "^5.8.3", "typescript": "^6.0.3",
"vite": "^6.3.2" "vite": "^8.0.10"
}, },
"dependencies": { "dependencies": {
"@sentry/svelte": "^10.49.0", "@sentry/svelte": "^10.50.0",
"@skeletonlabs/skeleton": "^4.15.2", "@skeletonlabs/skeleton": "^4.15.2",
"@skeletonlabs/skeleton-svelte": "^4.15.2", "@skeletonlabs/skeleton-svelte": "^4.15.2",
"@tanstack/svelte-virtual": "^3.13.24", "@tanstack/svelte-virtual": "^3.13.24",
"@warkypublic/artemis-kit": "file:../../artemis-kit", "@warkypublic/artemis-kit": "file:../../artemis-kit",
"@warkypublic/resolvespec-js": "^1.0.1", "@warkypublic/resolvespec-js": "^1.0.1",
"@warkypublic/svelix": "^0.1.31" "@warkypublic/svelix": "^0.1.39"
} }
} }
+361 -673
View File
File diff suppressed because it is too large. Load diff
+21 -1
View File
@@ -111,7 +111,27 @@
if (!response.ok) { if (!response.ok) {
throw new Error(`Status request failed with ${response.status}`); 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) { } catch (err) {
error = err instanceof Error ? err.message : 'Failed to load status'; error = err instanceof Error ? err.message : 'Failed to load status';
} finally { } finally {
+114 -10
View File
@@ -94,20 +94,21 @@ async function rsCall<T>(
function rsReadMany<T>( function rsReadMany<T>(
entity: string, 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[]> { ): Promise<T[]> {
return rsCall<T[]>(`/api/rs/public/${entity}`, 'read', { return rsCall<T[] | null>(`/api/rs/public/${entity}`, 'read', {
options: { options: {
...(options?.filters?.length ? { filters: options.filters } : {}), ...(options?.filters?.length ? { filters: options.filters } : {}),
...(options?.sort?.length ? { sort: options.sort } : {}), ...(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 = { export const api = {
projects: { projects: {
list: async () => { list: async (params?: { limit?: number; offset?: number; q?: string }) => {
type ProjectRow = { type ProjectRow = {
guid: string; guid: string;
name: string; name: string;
@@ -117,7 +118,12 @@ export const api = {
thought_count?: number; 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: { options: {
columns: ['guid', 'name', 'description', 'created_at', 'last_active_at'], columns: ['guid', 'name', 'description', 'created_at', 'last_active_at'],
computedColumns: [ computedColumns: [
@@ -126,11 +132,14 @@ export const api = {
expression: 'COALESCE((SELECT COUNT(*) FROM public.thoughts t WHERE t.project_id = projects.guid), 0)' expression: 'COALESCE((SELECT COUNT(*) FROM public.thoughts t WHERE t.project_id = projects.guid), 0)'
} }
], ],
...(filters.length ? { filters } : {}),
sort: [{ column: 'created_at', direction: 'desc' }], 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) => ({ return projects.map((project) => ({
id: project.guid, id: project.guid,
name: project.name, name: project.name,
@@ -146,7 +155,7 @@ export const api = {
}) })
}, },
thoughts: { 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[] = []; const filters: ResolveSpecFilter[] = [];
if (params.q) { if (params.q) {
filters.push({ column: 'content', operator: 'ilike', value: `%${params.q}%` }); filters.push({ column: 'content', operator: 'ilike', value: `%${params.q}%` });
@@ -160,10 +169,65 @@ export const api = {
return rsReadMany<import('./types').Thought>('thoughts', { return rsReadMany<import('./types').Thought>('thoughts', {
filters, filters,
limit: params.limit ?? 100, limit: params.limit ?? 100,
...(params.offset !== undefined ? { offset: params.offset } : {}),
sort: [{ column: 'created_at', direction: 'desc' }] 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'), delete: (id: string) => rsCall<void>(`/api/rs/public/thoughts/${id}`, 'delete'),
archive: (id: string) => archive: (id: string) =>
rsCall<void>(`/api/rs/public/thoughts/${id}`, 'update', { 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 () => { stats: async () => {
type StatsThoughtRow = { type StatsThoughtRow = {
metadata?: { metadata?: {
@@ -16,6 +16,7 @@
<thead class="bg-white/5 text-xs uppercase tracking-[0.2em] text-slate-500"> <thead class="bg-white/5 text-xs uppercase tracking-[0.2em] text-slate-500">
<tr> <tr>
<th class="px-4 py-3 font-medium">Principal</th> <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 accessed</th>
<th class="px-4 py-3 font-medium">Last path</th> <th class="px-4 py-3 font-medium">Last path</th>
<th class="px-4 py-3 font-medium">Agent</th> <th class="px-4 py-3 font-medium">Agent</th>
@@ -26,6 +27,7 @@
{#each entries as entry} {#each entries as entry}
<tr class="hover:bg-white/[0.03]"> <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="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 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="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> <td class="max-w-[16rem] truncate px-4 py-3 align-top text-xs text-slate-400">{entry.user_agent ?? '—'}</td>
@@ -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"> <script lang="ts">
import type { StatusResponse } from '../../types'; import type { StatusResponse } from '../../types';
import AccessTable from './AccessTable.svelte'; import AccessTable from './AccessTable.svelte';
import ConnectionBreakdown from './ConnectionBreakdown.svelte';
import StatusCards from './StatusCards.svelte'; import StatusCards from './StatusCards.svelte';
const { const {
@@ -47,3 +48,18 @@
{#if data && data.entries.length > 0} {#if data && data.entries.length > 0}
<AccessTable entries={data.entries} /> <AccessTable entries={data.entries} />
{/if} {/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}
+19 -1
View File
@@ -4,7 +4,7 @@
const { data }: { data: StatusResponse } = $props(); const { data }: { data: StatusResponse } = $props();
</script> </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"> <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="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> <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="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> <p class="mt-2 text-3xl font-semibold text-white">{data.total_known}</p>
</div> </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"> <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="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> <p class="mt-2 break-all text-2xl font-semibold text-white">{data.version}</p>
</div> </div>
</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>
@@ -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>
@@ -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>
+68 -31
View File
@@ -1,6 +1,9 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { GridlerFull, TextInputCtrl, type GridlerColumn } from '@warkypublic/svelix';
import { api } from '../../api'; import { api } from '../../api';
import { GlobalStateStore } from '../../shellState';
import { adminGridTheme } from '../../gridTheme';
import type { ProjectSummary } from '../../types'; import type { ProjectSummary } from '../../types';
let projects = $state<ProjectSummary[]>([]); let projects = $state<ProjectSummary[]>([]);
@@ -11,12 +14,39 @@
let newName = $state(''); let newName = $state('');
let newDesc = $state(''); let newDesc = $state('');
let createError = $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() { async function load() {
loading = true; loading = true;
error = ''; error = '';
try { try {
projects = await api.projects.list(); projects = await api.projects.list();
if (selectedProject) {
selectedProject = projects.find((project) => project.id === selectedProject?.id) ?? null;
}
} catch (e) { } catch (e) {
error = e instanceof Error ? e.message : 'Failed to load projects'; error = e instanceof Error ? e.message : 'Failed to load projects';
} finally { } finally {
@@ -41,8 +71,20 @@
} }
} }
function formatDate(value: string) { function onProjectRowClick(_row: number, rowData: Record<string, unknown> | undefined) {
return new Date(value).toLocaleDateString(); 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); onMount(load);
@@ -70,16 +112,15 @@
<div class="rounded-2xl border border-cyan-400/20 bg-slate-900 p-5"> <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> <h3 class="text-sm font-semibold text-white">Create project</h3>
<div class="mt-3 space-y-3"> <div class="mt-3 space-y-3">
<input <TextInputCtrl
type="text" label="Project name"
placeholder="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} bind:value={newName}
/> />
<input <TextInputCtrl
type="text" label="Description"
placeholder="Description (optional)" 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} bind:value={newDesc}
/> />
{#if createError}<p class="text-xs text-rose-300">{createError}</p>{/if} {#if createError}<p class="text-xs text-rose-300">{createError}</p>{/if}
@@ -109,29 +150,25 @@
No projects yet. No projects yet.
</div> </div>
{:else} {:else}
<div class="overflow-hidden rounded-2xl border border-white/10"> <div class="rounded-2xl border border-white/10 bg-slate-950/30 p-3">
<table class="min-w-full divide-y divide-white/10 text-sm text-slate-300"> <GridlerFull
<thead class="bg-white/5 text-xs uppercase tracking-[0.18em] text-slate-500"> {columns}
<tr> theme={adminGridTheme}
<th class="px-4 py-3 text-left font-medium">Name</th> rowMarkers="number"
<th class="px-4 py-3 text-left font-medium">Description</th> height={420}
<th class="px-4 py-3 text-right font-medium">Thoughts</th> dataSource="resolvespec"
<th class="px-4 py-3 text-right font-medium">Last active</th> dataSourceOptions={projectDataSourceOptions}
<th class="px-4 py-3 text-right font-medium">Created</th> serverSideSearch={true}
</tr> searchColumns={['name', 'description']}
</thead> onRowClick={onProjectRowClick}
<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> </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} {/if}
</div> </div>
@@ -2,6 +2,8 @@
import type { ShellPage, StatusResponse } from '../../types'; import type { ShellPage, StatusResponse } from '../../types';
import FilesPage from '../files/FilesPage.svelte'; import FilesPage from '../files/FilesPage.svelte';
import GuardrailsPage from '../guardrails/GuardrailsPage.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 DashboardPage from '../dashboard/DashboardPage.svelte';
import ProjectsPage from '../projects/ProjectsPage.svelte'; import ProjectsPage from '../projects/ProjectsPage.svelte';
import SkillsPage from '../skills/SkillsPage.svelte'; import SkillsPage from '../skills/SkillsPage.svelte';
@@ -37,12 +39,16 @@
<ProjectsPage /> <ProjectsPage />
{:else if currentPage === 'thoughts'} {:else if currentPage === 'thoughts'}
<ThoughtsPage /> <ThoughtsPage />
{:else if currentPage === 'learnings'}
<LearningsPage />
{:else if currentPage === 'skills'} {:else if currentPage === 'skills'}
<SkillsPage /> <SkillsPage />
{:else if currentPage === 'guardrails'} {:else if currentPage === 'guardrails'}
<GuardrailsPage /> <GuardrailsPage />
{:else if currentPage === 'files'} {:else if currentPage === 'files'}
<FilesPage /> <FilesPage />
{:else if currentPage === 'maintenance'}
<MaintenancePage />
{/if} {/if}
</main> </main>
</div> </div>
+3 -1
View File
@@ -15,9 +15,11 @@
{ id: 'dashboard', label: 'Dashboard', description: 'System overview and status.' }, { id: 'dashboard', label: 'Dashboard', description: 'System overview and status.' },
{ id: 'projects', label: 'Projects', description: 'Browse and manage projects.' }, { id: 'projects', label: 'Projects', description: 'Browse and manage projects.' },
{ id: 'thoughts', label: 'Thoughts', description: 'Search and inspect thoughts.' }, { 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: 'skills', label: 'Skills', description: 'Agent skill registry.' },
{ id: 'guardrails', label: 'Guardrails', description: 'Agent guardrail 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> </script>
+334 -110
View File
@@ -1,165 +1,389 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import {
import { api } from '../../api'; GridlerFull,
import type { Thought, SearchResult } from '../../types'; 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 includeArchived = $state(false);
let actionBusy = $state<string | null>(null); 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() { const baseFilters = $derived<GridColumnFilters>(
loading = true; includeArchived
error = ''; ? {}
try { : {
rows = await api.thoughts.list({ q: query || undefined, include_archived: includeArchived || undefined, limit: 100 }) as Row[]; archived_at: {
} catch (e) { value: "",
error = e instanceof Error ? e.message : 'Failed to load thoughts'; op: "isNull",
} finally { },
loading = false; },
} );
}
function onQueryInput() {
clearTimeout(searchTimer);
searchTimer = setTimeout(load, 350);
}
async function archive(id: string) { async function archive(id: string) {
actionBusy = id; actionBusy = id;
actionError = ''; actionError = "";
try { try {
await api.thoughts.archive(id); await api.thoughts.archive(id);
await load(); if (selectedThought?.id === id)
selectedThought.archived_at = new Date().toISOString();
} catch (e) { } catch (e) {
actionError = e instanceof Error ? e.message : 'Archive failed'; actionError = e instanceof Error ? e.message : "Archive failed";
} finally { } finally {
actionBusy = null; actionBusy = null;
} }
} }
async function remove(id: string) { async function remove(id: string) {
if (!confirm('Permanently delete this thought?')) return; if (!confirm("Permanently delete this thought?")) return;
actionBusy = id; actionBusy = id;
actionError = ''; actionError = "";
try { try {
await api.thoughts.delete(id); await api.thoughts.delete(id);
await load(); if (selectedThought?.id === id) {
selectedThought = null;
relatedLinks = [];
relatedFiles = [];
}
} catch (e) { } catch (e) {
actionError = e instanceof Error ? e.message : 'Delete failed'; actionError = e instanceof Error ? e.message : "Delete failed";
} finally { } finally {
actionBusy = null; actionBusy = null;
} }
} }
function isArchived(row: Row): boolean { function normalizeThought(rowData: Record<string, unknown>): Thought {
return 'archived_at' in row && !!row.archived_at; 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 { async function inspectThought(row: Thought | null) {
return row.content.length > 120 ? row.content.slice(0, 120) + '…' : row.content; 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(); return new Date(value).toLocaleString();
} }
onMount(load); function isSelectedArchived(): boolean {
return !!selectedThought?.archived_at;
}
</script> </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 class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div> <div>
<h2 class="text-2xl font-semibold text-white">Thoughts</h2> <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>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<label class="flex items-center gap-2 text-sm text-slate-400"> <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 Archived
</label> </label>
<button <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" 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} onclick={() => {
>Refresh</button> selectedThought = null;
relatedLinks = [];
relatedFiles = [];
}}>Refresh</button
>
</div> </div>
</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} {#if actionError}
<p class="text-sm text-rose-300">{actionError}</p> <p class="text-sm text-rose-300">{actionError}</p>
{/if} {/if}
{#if loading} <div class="grid gap-4 xl:grid-cols-[1.6fr_1fr]">
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-12 text-center text-slate-400">Loading…</div> <div class="rounded-2xl border border-white/10 bg-slate-950/30 p-3">
{:else if error} <GridlerFull
<div class="rounded-2xl border border-rose-400/30 bg-rose-400/10 px-4 py-4 text-sm text-rose-100">{error}</div> {columns}
{:else if rows.length === 0} theme={adminGridTheme}
<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> rowMarkers="number"
{:else} height={560}
<div class="overflow-hidden rounded-2xl border border-white/10"> width="100%"
<table class="min-w-full divide-y divide-white/10 text-sm text-slate-300"> pageSize={40}
<thead class="bg-white/5 text-xs uppercase tracking-[0.18em] text-slate-500"> dataSource="resolvespec"
<tr> dataSourceOptions={thoughtsDataSourceOptions}
<th class="px-4 py-3 text-left font-medium">Content</th> serverSideSearch={true}
<th class="px-4 py-3 text-left font-medium">Type</th> searchColumns={["content"]}
<th class="px-4 py-3 text-left font-medium">Status</th> filters={baseFilters}
<th class="px-4 py-3 text-right font-medium">Created</th> {onGridEvent}
<th class="px-4 py-3 text-right font-medium">Actions</th> {onRowClick}
</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> </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> </div>
+18
View 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
};
+99 -1
View File
@@ -2,10 +2,25 @@ export type AccessEntry = {
key_id: string; key_id: string;
last_accessed_at: string; last_accessed_at: string;
last_path: string; last_path: string;
remote_addr: string;
user_agent: string; user_agent: string;
request_count: number; 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 = { export type StatusResponse = {
title: string; title: string;
description: string; description: string;
@@ -17,6 +32,7 @@ export type StatusResponse = {
connected_window: string; connected_window: string;
oauth_enabled: boolean; oauth_enabled: boolean;
entries: AccessEntry[]; entries: AccessEntry[];
metrics: AccessMetrics;
}; };
export type NavItem = { export type NavItem = {
@@ -26,7 +42,7 @@ export type NavItem = {
disabled?: boolean; 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 = { export type Project = {
id: string; id: string;
@@ -49,10 +65,21 @@ export type ThoughtMetadata = {
source: string; source: string;
metadata_status: string; metadata_status: string;
metadata_error?: 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 = { export type Thought = {
id: string; id: string;
guid?: string;
content: string; content: string;
metadata: ThoughtMetadata; metadata: ThoughtMetadata;
project_id?: string; project_id?: string;
@@ -61,6 +88,14 @@ export type Thought = {
updated_at: string; updated_at: string;
}; };
export type ThoughtLink = {
id: number;
from_id: number;
to_id: number;
relation: string;
created_at: string;
};
export type SearchResult = { export type SearchResult = {
id: string; id: string;
content: string; content: string;
@@ -109,3 +144,66 @@ export type ThoughtStats = {
top_topics: { key: string; count: number }[]; top_topics: { key: string; count: number }[];
top_people: { 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;
};