feat: SQLite desktop edition — full desktop app with team tasks (#505)

* feat(store): add SQLite backend foundation with build-tag injection

Add sqlitestore package (//go:build sqlite) as alternative to PostgreSQL:
- pool.go: WAL mode, busy_timeout, 4 concurrent read connections
- helpers.go: ? param helpers, JSON array storage, nullable/update utils
- scope.go: tenant scope with ? placeholders (vs PG's $N)
- schema.sql: 1296-line flattened DDL from 29 PG migrations (51 tables)
- schema.go: embedded schema with transactional apply + version tracking
- factory.go: NewSQLiteStores() stub (stores wired in Phase 2)

Build-tag split for store initialization:
- cmd/gateway_stores_pg.go (//go:build !sqlite) — default PG-only
- cmd/gateway_stores_sqlite.go (//go:build sqlite) — runtime PG/SQLite switch
- cmd/gateway_setup.go: extracted wireTracingAndCron() shared helper

Config: GOCLAW_STORAGE_BACKEND + GOCLAW_SQLITE_PATH env vars.
Security: goclaw.db added to DenyPaths (exec, read_file, filesystem tools).

* feat(edition): add centralized edition package for feature tier limits

New internal/edition/ package — single source of truth for all edition limits:
- Edition struct with MaxAgents, MaxTeams, MaxChannels, KGEnabled, TeamFullMode, etc.
- Standard (default, all features) and Lite (desktop, 5 agents, 1 team) presets
- atomic.Pointer for thread-safe Current()/SetCurrent()

Wiring:
- cmd/gateway.go: GOCLAW_EDITION env override (lite/standard) at startup
- cmd/gateway_stores_sqlite.go: auto-set Lite when backend=sqlite
- /v1/edition HTTP endpoint for UI comparison modal (public, no auth)

* feat(sqlitestore): implement Phase 2A core stores + sqliteonly build tag

Implement 12 SQLite store backends (4200+ LOC) mirroring existing PG stores:
- SessionStore, AgentStore, ProviderStore, TracingStore, SnapshotStore
- ConfigSecretsStore, SystemConfigStore, TenantStore, HeartbeatStore
- BuiltinToolStore, BuiltinToolTenantConfigStore, SkillTenantConfigStore

All stores wired in factory.go. Remaining stores (Memory, Cron, Skills,
Teams, etc.) left nil — gateway handles gracefully.

Add sqliteonly build tag for PG-free desktop builds:
- go build .                  → PG only
- go build -tags sqlite .     → PG + SQLite (runtime switch)
- go build -tags sqliteonly .  → SQLite only (no pgx dependency)

Key SQLite adaptations:
- ? placeholders (not $N), json_extract/json_each/json_array_length
- DISTINCT ON → GROUP BY + Go dedup, ANY($1) → IN (?,?...)
- rows.Err() checks on all scan loops, execMapUpdateWhereTenant in helpers
- GetOrCreateUserProfile uses RowsAffected() instead of PG's xmax trick

* feat(sqlitestore): implement Phase 2B feature stores

Port 8 additional store backends to SQLite (19 new files, ~6000 LOC):

Cron: cron.go, cron_crud.go, cron_exec.go, cron_scheduler.go
  - Job scheduling with cache, ListDue, MarkRunning, MarkComplete

Skills: skills.go, skills_crud.go, skills_content.go, skills_grants.go
  - CRUD, grants, content management. LIKE search (no FTS/vector in Lite)

MCP: mcp_servers.go, mcp_servers_access.go, mcp_user_credentials.go
  - Server CRUD, agent/user grants, encrypted credentials

Channels: channel_instances.go, pairing.go, pending_messages.go, contacts.go
  - Channel management, device pairing, message queue, contact store

Teams: teams.go, teams_tasks.go, teams_tasks_lifecycle.go, teams_tasks_activity.go
  - Team/task CRUD, lifecycle transitions, activity log, progress tracking
  - JSON array for members/blocked_by (replaces PG text[])

Remaining nil stores: Memory, AgentLinks, KnowledgeGraph, Activity,
SecureCLI, APIKeys, ConfigPermissions — gateway handles gracefully.

* fix(sqlitestore): fix critical arg ordering + data races in Phase 2B stores

C1: ListTasks arg mismatch — limit+1 in userID slot, wrong results
C2: SearchTasks missing duplicate userID arg for (? = '' OR t.user_id = ?)
H3: cron_crud.go cacheLoaded written without mutex → use InvalidateCache()
H4: cron_exec.go discarded ExecContext error → log warning
H1: Add rows.Err() checks in cron_crud, cron_scheduler, cron_exec, pairing

* feat(sqlitestore): implement Phase 2C — Memory, Activity, APIKeys, ConfigPermissions

Complete remaining essential stores (6 new files, ~900 LOC):

Memory: memory.go, memory_docs.go, memory_search.go
  - Document/chunk CRUD, LIKE-based search (no vector in Lite edition)
  - Embedding methods return empty results gracefully

Activity: activity.go — simple activity logging
APIKeys: api_keys.go — API key CRUD with SHA-256 hash lookup
ConfigPermissions: config_permissions.go — permission rules with TTL cache

24/27 stores now wired. Only AgentLinks, KnowledgeGraph, SecureCLI
remain nil (disabled in Lite edition by design).

Total: 49 files, 11,435 LOC in internal/store/sqlitestore/

* fix(sqlitestore): fix variable shadow in GetDocument + handle chunk delete error

C1: GetDocument scopeClause used := inside if/else blocks, shadowing
outer err variable — query errors silently swallowed. Fixed by renaming
to tcErr matching PG pattern.

H1: IndexDocument chunk deletion ExecContext error was discarded, could
cause duplicate chunks. Now returns error on failure.

* feat(desktop): Phase 3 — Wails v2 desktop app shell with chat UI

Desktop app (ui/desktop/) using Wails v2 + React 19 + Tailwind CSS 4:

Go backend:
- main.go: Wails entry point with embedded frontend assets
- app.go: gateway embedding via goroutine, health check, Wails bindings
- keyring.go: OS keyring secrets with file fallback
- cmd/gateway_export.go: exports RunGateway() for desktop embedding

React frontend:
- WS v3 client: protocol handshake, exponential backoff, call queue
- Chat system: Zustand stores, RAF-batched streaming, 10 event handlers
- Components: MessageBubble, MarkdownRenderer (rehype-sanitize),
  CodeBlock, ToolCallBlock, ThinkingBlock, ActivityIndicator, InputBar
- Layout: AppShell (2-column), Sidebar with agent/session list, TopBar
- Onboarding wizard (5 steps): welcome, gateway, provider, agent, ready
- Magic Blue theme (dark/light), Inter + JetBrains Mono typography

Build: all 3 variants pass (PG, sqlite, sqliteonly)

* chore: ignore Wails build artifacts (wailsjs, build, package.json.md5)

* fix(desktop): correct onboarding provider list and agent creation API contract

- ProviderStep: expand from 3 to 16 providers in 4 groups (Popular, Cloud, Local, Regional)
  with correct provider_type values and api_base defaults
- AgentStep: fix API payload — use agent_key (slug), provider (name string),
  agent_type=predefined with description in other_config
- use-agents: fix field mapping — agent_key, display_name from backend response
- Clean up failed provider on verify error

* fix(desktop): add missing providers — Bailian Coding, Z.ai Coding, Ollama Cloud

* fix(desktop): match web dashboard brand colors + persist onboarding in store

- Replace Magic Blue theme with web dashboard's warm blue OKLCH palette
- Move onboarding state from localStorage to Zustand persist store
- Add "Run Setup Wizard" option in TopBar settings menu to re-trigger onboarding
- Fix light mode theme activation (explicit :root:not(.dark) overrides)

* fix(desktop): apply dark theme before first paint + better error messages

- Add class="dark" default on <html> + inline script to read persisted theme
  before React hydrate (prevents light flash on dark mode)
- Improve provider test error message for network failures

* feat(desktop): add GoClaw logo from web UI to onboarding + topbar

* fix(desktop): add Vite proxy for dev mode to avoid CORS gateway errors

- Proxy /v1, /ws, /health to localhost:18790 in Vite dev server
- Use relative URLs in dev mode (import.meta.env.DEV) so proxy handles CORS
- Production build uses direct gateway URL from Wails binding

* fix(desktop): GoClaw dock icon + fix duplicate provider slug on re-test

- Convert goclaw-icon.svg to 1024x1024 PNG for macOS dock icon
- Fix provider creation: handle existing slug by finding and updating
- Track build/appicon.png in git (exclude only build/bin/)

* fix(sqlitestore): UUID text/blob mismatch in scopeClause breaks all queries

scopeClause passed uuid.UUID (16-byte array) to SQLite ? placeholder,
but tenant_id column stores TEXT strings. SQLite compared BLOB vs TEXT
→ no match → all scoped queries returned empty results.

Fix: pass scope.TenantID.String() to ensure TEXT comparison.

Also includes:
- macOS dock icon (.icns from GoClaw logo)
- Onboarding auto-detect existing providers/agents
- Debug logging for token + API URL

* fix(desktop): CORS for dev mode + direct gateway URL

- Wails dev server (port 34115) doesn't proxy API calls, causing 405
- Frontend now connects directly to gateway URL from Wails binding
- Added GOCLAW_DESKTOP=1 env → enables CORS middleware on gateway
- desktopCORS wraps mux with Access-Control-Allow-* headers + OPTIONS
- Simplified provider test flow: list-then-create/update

* fix(desktop): split onboarding into 6 steps matching web UI flow

Web UI flow: create provider → select model + verify → create agent.
Desktop was incorrectly trying to verify before creating provider.

Changes:
- ProviderStep: now only creates/saves provider (no verify)
- NEW ModelVerifyStep: loads models from provider, test connection
- AgentStep: receives pre-selected model, shows read-only model field
- OnboardingWizard: 6 steps with auto-detect skip logic
- Auto-detect: has agents→Ready, has providers→ModelVerify, nothing→Provider

* feat(desktop): add Combobox component, use searchable model selector

- New Combobox: searchable dropdown with custom value support
- ModelVerifyStep: replace plain input/select with Combobox
- Models loaded from GET /v1/providers/{id}/models API
- Allows typing custom model name if API returns empty list

* fix(desktop): rename icon to iconfile.icns matching Wails convention

* refactor(desktop): overhaul UI/UX, fix onboarding, fix event handling

Desktop app major refactor:

UI/UX:
- Chat-focused layout with floating panels on dotted canvas
- Sidebar: agent list + sessions grouped by date (no resource counts)
- Modern input bar: rounded pill with attach/send buttons
- User bubble: card style matching web UI (not solid color)
- Thinking block: collapsible, max-height, proper label
- ErrorBoundary wrapping app
- Dock icon: regenerated with transparent bg + dark rounded frame

Onboarding:
- 3-step flow matching web UI (Provider → Model → Agent)
- Proper create vs update (check DB before POST)
- SetupStepper with step circles + connectors
- Agent presets from web UI (Fox Spirit, Artisan, Astrologer)
- Auto-detect existing setup via use-bootstrap-status hook

Event handling (verified from Go source):
- chunk: payload.content (not payload.chunk)
- thinking: payload.content (not payload.thinking)
- tool.call: payload.id/name (not toolId/toolName)
- tool.result: payload.is_error/content (not error field)
- run.completed: usage.prompt_tokens/completion_tokens
- New: block.reply, run.retrying handlers

Backend fixes:
- SQLite scanTime helper for modernc.org/sqlite text timestamps
- X-GoClaw-User-Id header in desktop API client (not X-User-ID)
- user_id: system (owner role in desktop single-user mode)
- CORS: allow X-GoClaw-User-Id header
- SQLite busy timeout: 5s → 10s
- Snapshot SQL: cross-DB compatible (FILTER→CASE, ::BIGINT→CAST)
- Promise.allSettled for bootstrap status (one fail doesn't block other)

* feat(desktop): chat polish, SQLite fixes, summoning modal, bootstrap guard

Chat Polish (Phase 1):
- ToolCallBlock: Wrench/Zap icons, phase badges, arg summary, grouped rendering
- ThinkingBlock: auto-expand on stream, Brain icon, cursor pulse
- MessageBubble: isStreaming prop, streaming cursor, grouped tool calls
- ImageLightbox: fullscreen overlay, gallery nav, keyboard shortcuts, download
- MediaBlock: grid layout, click-to-open lightbox, hover overlay
- ActivityIndicator: phase-specific icons (Brain/Wrench/RefreshCw)
- ChatCanvas: track lastAssistantId for streaming, EmptyState with prompts
- Filter [System] nudge messages and tool-role messages from chat history

SQLite Fixes:
- sessions_list: fix time.Time scan failure (3 sites) using sqliteTime scanner
- snapshots: fix ON CONFLICT expression mismatch with unique index
- snapshots: fix GetLatestBucket using nullSqliteTime
- pool: explicit PRAGMAs (busy_timeout=15s, WAL, synchronous=NORMAL)
- schema: seed master tenant (was missing, causing FK violations)
- schema: incremental migration framework (version-gated patches)

Onboarding:
- SummoningModal: port from web UI with framer-motion animations
- AgentStep: show summoning modal after create, continue button
- App: auto-detect empty DB and reset onboarded flag
- ChatCanvas: loading spinner while agent loads after onboarding

Bootstrap Guard:
- After auto-cleanup of BOOTSTRAP.md, check if USER.md is still empty
- Inject reminder if agent cleared BOOTSTRAP but didn't fill USER.md

Session Management:
- Load chat history on session click (was missing useEffect)
- Fix race condition: don't clear messages on session switch (atomic replace)
- SidebarFooter: center New Chat button text

* fix(desktop): session management, delete confirm, event listener race fix

- New Chat: only clears state, no empty session creation (sendMessage auto-creates)
- Delete session: hover X button with ConfirmDialog confirmation
- Event listener: use sessionKeyRef instead of closure to prevent stale events
- Remove "skip to dashboard" link from onboarding
- Add reusable ConfirmDialog + ConfirmDeleteDialog common components

* feat(desktop): settings view with tabbed layout (Phase 4)

- SettingsView: tab container with header, close button, canvas-dots bg
- SettingsTabBar: 9 tabs (Appearance, Providers, Agents, MCP, Skills, Tools, Cron, Traces, About)
- AppearanceTab: dark/light theme toggle, language + timezone placeholders
- AboutTab: version, edition limits, runtime info
- ui-store: activeView, settingsTab, openSettings(), closeSettings()
- AppShell: switch between chat and settings views
- SidebarFooter: gear icon opens settings (was "Run Setup Wizard")
- Keyboard: Cmd+, opens settings, Escape closes
- Branding: "GoClaw Lite" in sidebar header
- Agent status: online (green) instead of idle for desktop
- Tab content wrapped in solid bg card with border

* feat(desktop): provider management CRUD in settings (Phase 5)

- use-providers hook: list, create, update, delete, verify via HTTP API
- ProviderList: list view with Add button, empty state
- ProviderRow: status dot, type badge, edit/delete actions
- ProviderFormDialog: create/edit with type selector, masked API key, test connection
- Wire ProviderList into Settings Providers tab

* fix(desktop): remove Test Connection from provider form (requires model + provider ID)

* feat(desktop): agent management CRUD in settings (Phase 6)

- AgentData/AgentInput types matching web UI contracts
- use-agent-crud hook: list, create, update, delete, resummon (5 agent limit)
- AgentCard: emoji, status dot, provider/model/type badges, edit/delete/resummon
- AgentFormDialog: provider Combobox + model Combobox (from /v1/providers/{id}/models)
  - Create: agent_type selector, "Check & Create" verifies model before create
  - Edit: no type change, no re-verify needed
  - Personality textarea for predefined agents
- AgentList: grid, edition limit warning, create triggers SummoningModal
- Delete uses ConfirmDeleteDialog (type name to confirm)
- Sidebar agent list refreshes after CRUD

* feat(desktop): agent detail panel with full config (Phase 6 polish)

Agent detail panel (fullscreen overlay covering sidebar):
- PersonalitySection: emoji editor, display name, description, status select, default toggle, agent key display
- ModelBudgetSection: provider/model Combobox with verify-before-save, context window, max tool iterations
- EvolutionSection: self_evolve toggle with info callout (predefined agents only)
- Sticky save bar: backdrop blur, cancel/save buttons, spinner on save
- Save blocked if provider/model changed but not verified

AgentList: card click opens detail panel, create dialog separate
AgentFormDialog: create-only, Check & Create with verify

* fix(desktop): resummon confirm, summoning z-index, save bar UX

- Resummon requires confirm dialog before triggering
- SummoningModal z-index z-50 → z-[70] (above detail panel z-[60])
- Save bar: show error inline, "Verify model first" when blocked

* feat(desktop): agent detail quality polish — files tab, memory config, rich cards

Types (synced with web UI):
- AgentData: added owner_id, workspace, restrict_to_workspace, frontmatter, context_window, max_tool_iterations as required fields
- MemoryConfig, CompactionConfig interfaces
- BootstrapFile type for WS file operations

AgentCard (matching web UI agent-card.tsx):
- Star icon for default agent
- Animated pulse badge for summoning status
- Self-evolve sparkle indicator (orange when active)
- Frontmatter/expertise with line-clamp-3
- Context window display (e.g. "200K ctx")
- Safe emoji extraction, UUID name detection

AgentDetailPanel:
- Tab navigation: Overview + Files tabs
- Overview: Personality + ModelBudget + Memory + Evolution sections
- Files tab: WS-based file editor (agents.files.list/get/set)
  - File sidebar with selection
  - Textarea editor with dirty tracking
  - Save button with spinner
- Sticky save bar with backdrop blur (overview only)
- Resummon with confirm dialog

MemorySection (matching web UI memory-section.tsx):
- Enable/disable toggle
- 6 config fields: max_results, min_score, max_chunk_len, chunk_overlap, vector_weight, text_weight
- "Using global defaults" when disabled

* feat(desktop): agent files — hide USER/HEARTBEAT, add Edit with AI

- Hide USER.md, USER_PREDEFINED.md, HEARTBEAT.md from files tab (managed by bootstrap/cron)
- Add "Edit with AI" button → RegenerateDialog → POST /v1/agents/{id}/regenerate
- Auto-select first file on load
- Show file size in sidebar
- Pass agentId to files tab for regenerate API call

* fix(desktop): remove memory config section (no embedding in SQLite)

* fix(desktop): remove misleading bytes display from file sidebar

* fix(desktop): replace native checkboxes/selects with custom Switch + Combobox

- New Switch component matching Radix UI toggle style
- Replace all native <input type="checkbox"> with Switch in agent/provider forms
- Replace native <select> for status with Combobox
- All interactive elements have cursor-pointer

* fix(desktop): evolution callout colors — use opacity-based for both themes

* fix(desktop): global cursor-pointer for all interactive elements

* fix(desktop): improve dark mode contrast for text and status colors

- text-secondary: 0.62 → 0.68 lightness (better readability on dark bg)
- text-muted: 0.52 → 0.58 lightness (was below WCAG AA 4.5:1 minimum)
- success: 0.45 → 0.55 (green was too dim on dark bg)
- warning: 0.65 → 0.70 (slightly brighter)
- idle: 0.52 → 0.58 (match text-muted)

* fix(desktop): revert color values to exact web UI match (0.62/0.52/0.45)

* fix(desktop): agent card badge colors — match web UI badge variants exactly

* feat(desktop): add MCP servers + Builtin Tools settings tabs

Phase 7 implementation:

MCP Tab:
- Full CRUD with 5-server edition limit
- Form dialog with transport-conditional fields (stdio/SSE/streamable-http)
- Test Connection with inline success/error feedback
- Agent grants dialog (grant/revoke per agent)
- Tools discovery dialog (view server tools)
- KeyValueEditor with sensitive field masking (auth/token/secret)

Tools Tab:
- Category-grouped list of 41 seeded builtin tools
- Toggle enable/disable with optimistic update
- Specialized settings forms: web_fetch extractor chain, media provider chain
- Generic JSON editor fallback for other tools
- Provider/model Combobox selection for media tools

Common:
- RefreshButton component with 500ms min spin animation
- KeyValueEditor with password masking for sensitive keys

Fixes:
- SQLite builtin_tools scan: use scanTimePair() for timestamps
- Session click while in settings: now closes settings view
- Dark mode text contrast: bumped text-secondary/text-muted lightness
- Light mode text contrast: darkened text-secondary/text-muted
- Focus ring thickness: ring-2 → ring-1 globally
- Misleading "memory layering (Postgres)" log label

* feat(desktop): add Skills tab, agent skill grants, emoji avatar, agent form redesign

Skills:
- Skills settings tab with upload ZIP, toggle, delete, runtime check
- Agent skill grants section in agent detail panel (toggle per agent)
- SQLite SkillManageStore interface compliance fixes

Agent form:
- 2-column layout, wider modal (max-w-3xl)
- 6 personality presets (Fox Spirit, Artisan, Astrologer, Researcher, Writer, Coder)
- Separate Verify Model + Summon buttons
- Always predefined type (removed open option)

Fixes:
- SQLite time.Time scan: mcp_servers, mcp_grants, activity_logs
- Combobox portal with scroll/resize tracking
- AgentAvatar shows emoji from other_config
- uploadFile sends X-GoClaw-User-Id header + generic type
- isApiClientReady guard prevents ErrorBoundary crash
- Verify model field: valid (not success)

* fix(sqlitestore): comprehensive timestamp scan sweep + UI fixes

SQLite timestamp sweep (13 files, 25+ sites):
All time.Time direct scans replaced with sqliteTime/scanTimePair/nullSqliteTime.
Files: activity, teams, teams_tasks, teams_tasks_activity, config_permissions,
agents_access, tracing_spans, tracing_scan, channel_instances, tenants,
pending_messages, api_keys, heartbeat.

UI fixes:
- Agent switching: clear active session + chat when agent changes
- MCP table: vertical align middle on row cells

* fix(sqlitestore): fix json.RawMessage scan + sqliteVal for dynamic updates

- mcp_servers_access: scan json columns via string intermediates (SQLite
  TEXT → json.RawMessage incompatible, use string then convert)
- helpers: add sqliteVal() to auto-marshal map/slice/struct to JSON
  string in execMapUpdate/execMapUpdateWhereTenant — fixes agent save
  500 error when updating other_config, tools_config, etc.
- Add AgentMcpSection: toggle MCP server grants per agent in detail panel
- Clean up debug logging from McpGrantsDialog

* feat(desktop): add i18n (react-i18next) + toast system + language/timezone pickers

i18n:
- Install react-i18next + i18next
- Copy 12 web locale namespaces (en/vi/zh) + desktop.json namespace
- Create i18n/index.ts with browser language detection + localStorage persist
- Replace ~300 hardcoded strings across 40+ components with t() calls
- Language picker in ChatTopBar (top-right) + Settings > Appearance
- Timezone picker in ChatTopBar with search + Intl.supportedValuesOf fallback
- All 6 agent presets fully translated (vi/zh) with prompts from locale files
- Agent Key never translated (uses stable English slugs)

Toast:
- Zustand toast store (success/error/warning/default, 4s auto-dismiss)
- Toaster component (bottom-right, z-100, slide-in animation)
- Toast calls in all CRUD hooks (agents, providers, MCP, skills, tools)

* feat(desktop): add Cron Jobs + Traces settings tabs with syntax highlighting

Cron Jobs (Phase 8):
- WS RPC hook (cron.list/create/delete/toggle/run/runs)
- CronList table with schedule formatting, status badges, run/toggle/delete
- CronFormDialog with slug name, agent selector, 3-way schedule (every/cron/once)
- CronRunsDialog for execution history

Traces (Phase 9):
- HTTP REST hook (GET /v1/traces with pagination + agent filter)
- TraceList table with duration, tokens, spans, relative time
- TraceDetailDialog with metadata, collapsible input/output, flat span list
- Syntax-highlighted JSON/code previews via react-syntax-highlighter

* fix(sqlitestore): fix tracing scan — endTime *time.Time → nullSqliteTime

Both trace and span scan functions used *time.Time for nullable end_time
column, which fails on SQLite TEXT timestamps. Changed to nullSqliteTime
with Valid check before assigning pointer.

* fix(desktop): fix cron schedule type label — map 'at' kind to 'once' i18n key

* fix(desktop): prevent flash on cron/traces refresh — keep data while refetching

* feat(desktop): add file attachment rendering — FileButton + FilePreviewDialog

- FileButton: compact attachment button with emoji icon, filename, size, download
- FilePreviewDialog: modal with type-detected preview (image/video/audio/markdown/code/text)
- MarkdownRenderer: override a/img for /v1/files/ links → FileButton + resolved URLs
- MediaBlock: non-image files render as FileButton instead of plain links
- api.ts: add getBaseUrl() for file URL resolution

* fix(desktop): resolve file URLs in chat — toFileUrl for media_refs + relative paths

- use-chat: add toFileUrl() to convert raw paths to /v1/files/{basename} URLs
  for both run.completed media and history media_refs
- MarkdownRenderer: detect relative file paths (./path/file.ext) via isFileLink
  in addition to /v1/files/ links, resolve all to gateway URL

* fix(desktop): authenticated file serving — media cache + blob URLs

Security fix: all /v1/files/ requests now use Bearer auth via fetchFile().
No more raw <a href> or <img src> with unauthenticated file URLs.

- Add media-cache.ts (blob cache with 5-min TTL, dedup inflight fetches)
- Add use-media-url.ts hook (returns cached blob URL for authenticated media)
- Add AuthImage component (loads images via auth blob) + downloadFile helper
- Update MarkdownRenderer: file images use AuthImage, downloads use downloadFile
- Update FileButton: authenticated download via blob
- Update FilePreviewDialog: authenticated fetch for text/preview content
- Clean filename display (strip timestamps + query params)

* fix(desktop): fix file attachments — use media_refs.id for URL, auth all media

Root cause: media_refs from backend has {id, mime_type, kind} but no path/url.
The id IS the filename basename. Fixed toFileUrl to use ref.id as fallback.

Also: AuthImage for lightbox, authenticated audio/video in MediaBlock,
clean filename display (strip timestamps).

* fix(desktop): file serving, traces, and media rendering improvements

Backend:
- Store MediaRef.Path in loop_finalize for direct file serving
- Use full path (not basename) in gateway_managed event signing
- Add fuzzyMatchInDir for LLM-hallucinated filenames
- Add findInWorkspace support for agent dirs and ws/ directory
- Add POST /v1/files/sign endpoint for client-side URL signing
- Always save LLM span input_preview (not just verbose mode)
- Truncate previews from tail (keep recent context), limit 2000 chars
- Add exact filename hint to create_image/video/audio tool results

Desktop frontend:
- File attachments: FileButton, FilePreviewDialog, AuthImage with blob cache
- Media cache: sign URLs via API for non-ft URLs, Bearer auth fallback
- Download via Wails SaveFile binding (native Save As dialog)
- OpenFile + DownloadURL Wails Go bindings
- Traces: rewrite with span tree hierarchy, formatTokens (90.8K),
  formatDuration with start/end fallback, expandable spans
- Traces: export via DownloadURL, copy with checkmark state
- Code preview: JSON = oneDark syntax highlight, text = light pre block
- MarkdownRenderer: baseUrl prop for relative image resolution
- ImageLightbox: preventDefault on keyboard nav (no macOS beep)
- ErrorBoundary: reload instead of re-render on retry
- Combobox: compact sizing (py-1.5, text-sm)
- FileButton: fix nested button HTML violation

* feat(desktop): team tasks kanban board + edition policy + file preview fixes

Phase 11 implementation:

Backend:
- TeamActionPolicy interface — lite/full edition gating for team_tasks tool
- Filter blocked actions from schema enum + early guard in Execute()
- System prompt: edition-specific team member guidance
- Skip skill_manage/publish_skill registration + seeding in lite
- teams.create: require at least 1 member
- files.go: 2-layer path isolation (workspace boundary + tenant scope)

Desktop UI:
- Kanban board with 6 status columns + framer-motion layout animation
- Task detail modal with collapsible description/result sections
- Team create dialog with styled member checkboxes
- Sidebar: teams section with create button + Lite edition badge
- Chat TaskPanel: compact active tasks with real-time WS updates
- Real-time: debounced get-light fetch (300ms) + progress patch (1s)
- Edition comparison modal (Lite vs Standard feature table)
- Custom dropdown filter replacing native select
- i18n: teams namespace (en/vi/zh)

Fixes:
- WS params: camelCase (teamId, taskId, sessionKey) matching backend
- FileButton: span wrapper (HTML nesting) + createPortal for dialog
- FilePreviewDialog: defensive filename normalization for extension check
- use-chat: prevSessionRef init null — fix blank chat on view switch
- Scrollbar: 3px auto-hide
- Agent click in sidebar returns to chat view

* feat: Update frontend build assets, change the default agent thinking level to 'low', and update Go module dependencies.
This commit is contained in:
Viet Tran authored and GitHub committed 2026-03-27 09:28:42 +07:00
1 parent 0dab087fa3
commit e183b459c9
260 files changed
+36927 -142

No files matched your search

+13
View File
@@ -31,6 +31,19 @@ ui/web/dist/
ui/web/.vite/
*.tsbuildinfo
# UI Desktop (Wails + React)
ui/desktop/frontend/node_modules/
ui/desktop/frontend/dist/*.js
ui/desktop/frontend/dist/*.css
ui/desktop/frontend/dist/assets/
ui/desktop/frontend/.vite/
ui/desktop/frontend/wailsjs/
ui/desktop/frontend/package.json.md5
ui/desktop/build/bin/
ui/desktop/desktop
goclaw-lite
/desktop
.mcp.json
tmp
.claude/
+19
View File
@@ -25,6 +25,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/channels/zalo"
zalopersonal "github.com/nextlevelbuilder/goclaw/internal/channels/zalo/personal"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/edition"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/heartbeat"
"github.com/nextlevelbuilder/goclaw/internal/gateway/methods"
@@ -77,6 +78,21 @@ func runGateway() {
os.Exit(1)
}
// Edition override: explicit GOCLAW_EDITION takes precedence over auto-detection.
// Auto-detection happens later in setupStoresAndTracing (sqlite → lite).
if edName := os.Getenv("GOCLAW_EDITION"); edName != "" {
switch edName {
case "lite":
edition.SetCurrent(edition.Lite)
slog.Info("edition: lite (explicit)")
case "standard":
edition.SetCurrent(edition.Standard)
slog.Info("edition: standard (explicit)")
default:
slog.Warn("unknown GOCLAW_EDITION, using standard", "value", edName)
}
}
// Create core components
msgBus := bus.New()
@@ -433,6 +449,9 @@ func runGateway() {
// API documentation (OpenAPI spec + Swagger UI at /docs)
server.SetDocsHandler(httpapi.NewDocsHandler())
// Edition info (public, no auth — used by desktop UI comparison modal)
server.SetEditionHandler(httpapi.NewEditionHandler())
if pgStores != nil && pgStores.APIKeys != nil {
server.SetAPIKeysHandler(httpapi.NewAPIKeysHandler(pgStores.APIKeys, msgBus))
server.SetAPIKeyStore(pgStores.APIKeys)
+23 -6
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"log/slog"
"github.com/nextlevelbuilder/goclaw/internal/edition"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
@@ -12,7 +13,7 @@ import (
// builtinToolSeedData returns the canonical list of built-in tools to seed into the database.
// Seed preserves user-customized enabled/settings values across upgrades.
func builtinToolSeedData() []store.BuiltinToolDef {
return []store.BuiltinToolDef{
defs := []store.BuiltinToolDef{
// filesystem
{Name: "read_file", DisplayName: "Read File", Description: "Read the contents of a file from the agent's workspace by path", Category: "filesystem", Enabled: true},
{Name: "write_file", DisplayName: "Write File", Description: "Write content to a file in the workspace, creating directories as needed", Category: "filesystem", Enabled: true},
@@ -107,6 +108,19 @@ func builtinToolSeedData() []store.BuiltinToolDef {
Requires: []string{"managed_mode", "teams"},
},
}
// Lite edition: remove skill management tools — not available on desktop.
if !edition.Current().TeamFullMode {
liteHidden := map[string]bool{"skill_manage": true, "publish_skill": true}
filtered := defs[:0]
for _, d := range defs {
if !liteHidden[d.Name] {
filtered = append(filtered, d)
}
}
return filtered
}
return defs
}
// seedBuiltinTools seeds built-in tool definitions into the database.
@@ -220,14 +234,17 @@ func applyBuiltinToolDisables(ctx context.Context, bts store.BuiltinToolStore, t
return
}
var disabled int
var disabledCount, enabledCount int
for _, t := range all {
if !t.Enabled {
toolsReg.Unregister(t.Name)
disabled++
toolsReg.Disable(t.Name)
disabledCount++
} else {
toolsReg.Enable(t.Name)
enabledCount++
}
}
if disabled > 0 {
slog.Info("builtin tools disabled", "count", disabled)
if disabledCount > 0 {
slog.Info("builtin tools updated", "disabled", disabledCount, "enabled", enabledCount)
}
}
+7
View File
@@ -0,0 +1,7 @@
package cmd
// RunGateway starts the gateway server.
// Exported for embedding in desktop app.
func RunGateway() {
runGateway()
}
+12 -5
View File
@@ -21,6 +21,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/sandbox"
"github.com/nextlevelbuilder/goclaw/internal/skills"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/edition"
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
"github.com/nextlevelbuilder/goclaw/internal/tools"
"github.com/nextlevelbuilder/goclaw/internal/tracing"
@@ -177,7 +178,7 @@ func wireExtras(
if c, ok := m["content"].(string); ok && strings.Contains(c, "/v1/") {
m["content"] = httpapi.SignFileURLs(c, secret)
}
// Convert media local paths → signed /v1/files/basename?ft=hash
// Convert media local paths → signed /v1/files/{full_path}?ft=hash
if rawMedia, ok := m["media"].([]agent.MediaResult); ok {
// Clone slice — the original is shared with RunResult.Media;
// mutating in-place corrupts paths for downstream consumers
@@ -185,8 +186,10 @@ func wireExtras(
signed := make([]agent.MediaResult, len(rawMedia))
for i, mr := range rawMedia {
signed[i] = mr
basename := filepath.Base(mr.Path)
url := "/v1/files/" + basename
// Use full path so backend resolves directly via os.Stat,
// no findInWorkspace fallback needed.
urlPath := strings.TrimPrefix(filepath.Clean(mr.Path), "/")
url := "/v1/files/" + urlPath
ft := httpapi.SignFileToken(url, secret, httpapi.FileTokenTTL)
signed[i].Path = url + "?ft=" + ft
}
@@ -278,7 +281,7 @@ func wireExtras(
ms.SetMemoryStore(stores.Memory)
}
}
slog.Info("memory layering enabled (Postgres)")
slog.Info("memory layering enabled")
}
// Wire knowledge graph store on KG tool + hint in memory_search results
@@ -436,7 +439,11 @@ func wireExtras(
if stores.Teams != nil && stores.Agents != nil {
teamMgr := tools.NewTeamToolManager(stores.Teams, stores.Agents, msgBus, workspace)
postTurn = teamMgr
toolsReg.Register(tools.NewTeamTasksTool(teamMgr))
var teamPolicy tools.TeamActionPolicy = tools.FullTeamPolicy{}
if !edition.Current().TeamFullMode {
teamPolicy = tools.LiteTeamPolicy{}
}
toolsReg.Register(tools.NewTeamTasksTool(teamMgr, teamPolicy))
// Wire workspace interceptor into write_file so team workspace validation
// and event broadcasting happen transparently via existing file tools.
wsInterceptor := tools.NewWorkspaceInterceptor(teamMgr)
+28 -42
View File
@@ -17,6 +17,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/sandbox"
"github.com/nextlevelbuilder/goclaw/internal/edition"
"github.com/nextlevelbuilder/goclaw/internal/skills"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
@@ -222,6 +223,9 @@ func setupToolRegistry(
filepath.Join(workspace, "memory.db-shm"),
filepath.Join(workspace, "config.json"),
filepath.Join(workspace, "delegate"),
filepath.Join(dataDir, "goclaw.db"),
filepath.Join(dataDir, "goclaw.db-wal"),
filepath.Join(dataDir, "goclaw.db-shm"),
)
if cfgPath := os.Getenv("GOCLAW_CONFIG"); cfgPath != "" {
et.DenyPaths(cfgPath)
@@ -236,12 +240,14 @@ func setupToolRegistry(
// deny paths add defense-in-depth.
internalDenyPaths := []string{
"config.json", "memory.db", "memory.db-wal", "memory.db-shm",
"goclaw.db", "goclaw.db-wal", "goclaw.db-shm",
"memory/", ".media/", ".uploads/", "delegate/",
}
// read_file: allow .media/ access (uploaded documents accessed via AllowPaths
// for backward compat; new uploads go to per-user .uploads/ within workspace).
readFileDenyPaths := []string{
"config.json", "memory.db", "memory.db-wal", "memory.db-shm",
"goclaw.db", "goclaw.db-wal", "goclaw.db-shm",
"memory/", "delegate/",
}
if rf, ok := toolsReg.Get("read_file"); ok {
@@ -268,39 +274,17 @@ func setupToolRegistry(
return
}
// setupStoresAndTracing creates PG stores, tracing collector, snapshot worker, and wires cron config.
// Exits the process on unrecoverable errors (missing DSN, schema mismatch, store creation failure).
func setupStoresAndTracing(
// wireTracingAndCron sets up tracing collector, snapshot worker, and cron config
// on an already-created store set. Shared between PG and SQLite build variants.
func wireTracingAndCron(
cfg *config.Config,
dataDir string,
stores *store.Stores,
msgBus *bus.MessageBus,
) (*store.Stores, *tracing.Collector, *tracing.SnapshotWorker) {
// --- Store creation (Postgres) ---
if cfg.Database.PostgresDSN == "" {
slog.Error("GOCLAW_POSTGRES_DSN is required. Set it in your environment or .env.local file.")
os.Exit(1)
}
dataDir string,
) (*tracing.Collector, *tracing.SnapshotWorker) {
var traceCollector *tracing.Collector
// Schema compatibility check: ensure DB schema matches this binary.
if err := checkSchemaOrAutoUpgrade(cfg.Database.PostgresDSN); err != nil {
slog.Error("schema compatibility check failed", "error", err)
os.Exit(1)
}
storeCfg := store.StoreConfig{
PostgresDSN: cfg.Database.PostgresDSN,
EncryptionKey: os.Getenv("GOCLAW_ENCRYPTION_KEY"),
SkillsStorageDir: filepath.Join(dataDir, "skills-store"),
}
pgStores, pgErr := pg.NewPGStores(storeCfg)
if pgErr != nil {
slog.Error("failed to create PG stores", "error", pgErr)
os.Exit(1)
}
if pgStores.Tracing != nil {
traceCollector = tracing.NewCollector(pgStores.Tracing)
if stores.Tracing != nil {
traceCollector = tracing.NewCollector(stores.Tracing)
traceCollector.OnFlush = func(traceIDs []uuid.UUID) {
ids := make([]string, len(traceIDs))
for i, id := range traceIDs {
@@ -317,8 +301,8 @@ func setupStoresAndTracing(
// Start snapshot worker for hourly usage aggregation
var snapshotWorker *tracing.SnapshotWorker
if pgStores.Snapshots != nil {
snapshotWorker = tracing.NewSnapshotWorker(pgStores.DB, pgStores.Snapshots)
if stores.Snapshots != nil {
snapshotWorker = tracing.NewSnapshotWorker(stores.DB, stores.Snapshots)
snapshotWorker.Start()
// Backfill historical data in background
@@ -334,24 +318,25 @@ func setupStoresAndTracing(
// Wire cron config from config.json
cronRetryCfg := cfg.Cron.ToRetryConfig()
// Apply retry config via type assertion on the concrete cron store.
pgStores.Cron.SetOnJob(nil) // ensure initialized; actual handler set below
_ = cronRetryCfg // config available; pg cron store reads it internally
if cfg.Cron.DefaultTimezone != "" {
pgStores.Cron.SetDefaultTimezone(cfg.Cron.DefaultTimezone)
if stores.Cron != nil {
stores.Cron.SetOnJob(nil) // ensure initialized; actual handler set below
_ = cronRetryCfg // config available; cron store reads it internally
if cfg.Cron.DefaultTimezone != "" {
stores.Cron.SetDefaultTimezone(cfg.Cron.DefaultTimezone)
}
}
// Load secrets from config_secrets table before env overrides.
// Precedence: config.json → DB secrets → env vars (highest).
if pgStores.ConfigSecrets != nil {
if secrets, err := pgStores.ConfigSecrets.GetAll(context.Background()); err == nil && len(secrets) > 0 {
if stores.ConfigSecrets != nil {
if secrets, err := stores.ConfigSecrets.GetAll(context.Background()); err == nil && len(secrets) > 0 {
cfg.ApplyDBSecrets(secrets)
cfg.ApplyEnvOverrides()
slog.Info("config secrets loaded from DB", "count", len(secrets))
}
}
return pgStores, traceCollector, snapshotWorker
return traceCollector, snapshotWorker
}
// setupMemoryEmbeddings wires embedding provider to PGMemoryStore and triggers backfill.
@@ -547,8 +532,9 @@ func setupSkillsSystem(
}
}
// Publish skill tool — lets agents register created skills in the database
if pgStores.Skills != nil {
// Publish skill tool — lets agents register created skills in the database.
// Disabled in lite edition: agents should not self-manage skills on desktop.
if pgStores.Skills != nil && edition.Current().TeamFullMode {
if manageStore, ok := pgStores.Skills.(store.SkillManageStore); ok {
storeDirs := pgStores.Skills.Dirs()
if len(storeDirs) > 0 {
+47
View File
@@ -0,0 +1,47 @@
//go:build !sqlite && !sqliteonly
package cmd
import (
"log/slog"
"os"
"path/filepath"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
"github.com/nextlevelbuilder/goclaw/internal/tracing"
)
// setupStoresAndTracing creates PG stores, tracing collector, snapshot worker, and wires cron config.
// This is the default (PG-only) build — no SQLite support compiled in.
func setupStoresAndTracing(
cfg *config.Config,
dataDir string,
msgBus *bus.MessageBus,
) (*store.Stores, *tracing.Collector, *tracing.SnapshotWorker) {
if cfg.Database.PostgresDSN == "" {
slog.Error("GOCLAW_POSTGRES_DSN is required. Set it in your environment or .env.local file.")
os.Exit(1)
}
if err := checkSchemaOrAutoUpgrade(cfg.Database.PostgresDSN); err != nil {
slog.Error("schema compatibility check failed", "error", err)
os.Exit(1)
}
storeCfg := store.StoreConfig{
PostgresDSN: cfg.Database.PostgresDSN,
EncryptionKey: os.Getenv("GOCLAW_ENCRYPTION_KEY"),
SkillsStorageDir: filepath.Join(dataDir, "skills-store"),
}
pgStores, pgErr := pg.NewPGStores(storeCfg)
if pgErr != nil {
slog.Error("failed to create PG stores", "error", pgErr)
os.Exit(1)
}
traceCollector, snapshotWorker := wireTracingAndCron(cfg, pgStores, msgBus, dataDir)
return pgStores, traceCollector, snapshotWorker
}
+88
View File
@@ -0,0 +1,88 @@
//go:build sqlite && !sqliteonly
package cmd
import (
"log/slog"
"os"
"path/filepath"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/edition"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
"github.com/nextlevelbuilder/goclaw/internal/store/sqlitestore"
"github.com/nextlevelbuilder/goclaw/internal/tracing"
)
// setupStoresAndTracing creates stores (PG or SQLite based on config), tracing collector,
// snapshot worker, and wires cron config.
// Built with -tags sqlite: supports both backends, selected via GOCLAW_STORAGE_BACKEND env.
func setupStoresAndTracing(
cfg *config.Config,
dataDir string,
msgBus *bus.MessageBus,
) (*store.Stores, *tracing.Collector, *tracing.SnapshotWorker) {
backend := cfg.Database.StorageBackend
if backend == "" {
backend = "postgres"
}
var stores *store.Stores
switch backend {
case "sqlite":
sqlitePath := cfg.Database.SQLitePath
if sqlitePath == "" {
sqlitePath = filepath.Join(dataDir, "goclaw.db")
}
storeCfg := store.StoreConfig{
SQLitePath: sqlitePath,
StorageBackend: "sqlite",
EncryptionKey: os.Getenv("GOCLAW_ENCRYPTION_KEY"),
SkillsStorageDir: filepath.Join(dataDir, "skills-store"),
}
s, err := sqlitestore.NewSQLiteStores(storeCfg)
if err != nil {
slog.Error("failed to create SQLite stores", "error", err, "path", sqlitePath)
os.Exit(1)
}
stores = s
// SQLite backend auto-defaults to Lite edition unless explicitly overridden.
if os.Getenv("GOCLAW_EDITION") == "" {
edition.SetCurrent(edition.Lite)
slog.Info("edition: lite (auto, sqlite backend)")
}
slog.Info("storage backend: sqlite", "path", sqlitePath)
case "postgres":
if cfg.Database.PostgresDSN == "" {
slog.Error("GOCLAW_POSTGRES_DSN is required. Set it in your environment or .env.local file.")
os.Exit(1)
}
if err := checkSchemaOrAutoUpgrade(cfg.Database.PostgresDSN); err != nil {
slog.Error("schema compatibility check failed", "error", err)
os.Exit(1)
}
storeCfg := store.StoreConfig{
PostgresDSN: cfg.Database.PostgresDSN,
EncryptionKey: os.Getenv("GOCLAW_ENCRYPTION_KEY"),
SkillsStorageDir: filepath.Join(dataDir, "skills-store"),
}
s, err := pg.NewPGStores(storeCfg)
if err != nil {
slog.Error("failed to create PG stores", "error", err)
os.Exit(1)
}
stores = s
slog.Info("storage backend: postgres")
default:
slog.Error("unknown GOCLAW_STORAGE_BACKEND; expected 'postgres' or 'sqlite'", "value", backend)
os.Exit(1)
}
traceCollector, snapshotWorker := wireTracingAndCron(cfg, stores, msgBus, dataDir)
return stores, traceCollector, snapshotWorker
}
+50
View File
@@ -0,0 +1,50 @@
//go:build sqliteonly
package cmd
import (
"log/slog"
"os"
"path/filepath"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/edition"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/store/sqlitestore"
"github.com/nextlevelbuilder/goclaw/internal/tracing"
)
// setupStoresAndTracing creates SQLite-only stores, tracing collector, and snapshot worker.
// Built with -tags sqliteonly: no PostgreSQL dependency compiled in.
func setupStoresAndTracing(
cfg *config.Config,
dataDir string,
msgBus *bus.MessageBus,
) (*store.Stores, *tracing.Collector, *tracing.SnapshotWorker) {
sqlitePath := cfg.Database.SQLitePath
if sqlitePath == "" {
sqlitePath = filepath.Join(dataDir, "goclaw.db")
}
storeCfg := store.StoreConfig{
SQLitePath: sqlitePath,
StorageBackend: "sqlite",
EncryptionKey: os.Getenv("GOCLAW_ENCRYPTION_KEY"),
SkillsStorageDir: filepath.Join(dataDir, "skills-store"),
}
stores, err := sqlitestore.NewSQLiteStores(storeCfg)
if err != nil {
slog.Error("failed to create SQLite stores", "error", err, "path", sqlitePath)
os.Exit(1)
}
// SQLite-only always defaults to Lite edition unless explicitly overridden.
if os.Getenv("GOCLAW_EDITION") == "" {
edition.SetCurrent(edition.Lite)
slog.Info("edition: lite (auto, sqliteonly build)")
}
slog.Info("storage backend: sqlite (sqliteonly build)", "path", sqlitePath)
traceCollector, snapshotWorker := wireTracingAndCron(cfg, stores, msgBus, dataDir)
return stores, traceCollector, snapshotWorker
}
+28 -2
View File
@@ -48,6 +48,7 @@ require (
github.com/aws/smithy-go v1.24.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/catppuccin/go v0.3.0 // indirect
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
@@ -60,22 +61,32 @@ require (
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/coder/websocket v1.8.12 // indirect
github.com/creachadair/msync v0.7.1 // indirect
github.com/danieljoos/wincred v1.2.3 // indirect
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/gaissmai/bart v0.18.0 // indirect
github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced // indirect
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/hdevalence/ed25519consensus v0.2.0 // indirect
github.com/huin/goupnp v1.3.0 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/jsimonetti/rtnetlink v1.4.0 // indirect
github.com/labstack/echo/v4 v4.13.3 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/leaanthony/gosod v1.0.4 // indirect
github.com/leaanthony/slicer v1.6.0 // indirect
github.com/leaanthony/u v1.1.1 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-shellwords v1.0.12 // indirect
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect
@@ -85,9 +96,14 @@ require (
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pires/go-proxyproto v0.8.1 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/prometheus-community/pro-bing v0.4.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/safchain/ethtool v0.3.0 // indirect
github.com/samber/lo v1.49.1 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect
@@ -95,10 +111,16 @@ require (
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da // indirect
github.com/tkrajina/go-reflector v0.5.8 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.22 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
github.com/wailsapp/wails/v2 v2.11.0 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
github.com/zalando/go-keyring v0.2.8 // indirect
go.uber.org/atomic v1.11.0 // indirect
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
@@ -109,6 +131,10 @@ require (
golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.47.0 // indirect
)
require (
@@ -153,7 +179,7 @@ require (
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/net v0.49.0
golang.org/x/sync v0.19.0
golang.org/x/sys v0.40.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.33.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect
+61
View File
@@ -60,6 +60,8 @@ github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3v
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
@@ -127,6 +129,8 @@ github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoi
github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9poewSb2XI1yTwD0g=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
@@ -186,6 +190,8 @@ github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737 h1:cf60tHxREO
github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737/go.mod h1:MIS0jDzbU/vuM9MC4YnBITCv+RYuTRq8dJzmCrFsK9g=
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg=
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466/go.mod h1:ZiQxhyQ+bbbfxUKVvjfO498oPYvtYhZzycal3G/NHmU=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
@@ -231,6 +237,8 @@ github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/jellydator/ttlcache/v3 v3.1.0 h1:0gPFG0IHHP6xyUyXq+JaD8fwkDCqgqwohXNJBcYE71g=
github.com/jellydator/ttlcache/v3 v3.1.0/go.mod h1:hi7MGFdMAwZna5n2tuvh63DvFLzVKySzCVW6+0gA2n4=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
@@ -252,6 +260,18 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
@@ -260,6 +280,10 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mark3labs/mcp-go v0.44.0 h1:OlYfcVviAnwNN40QZUrrzU0QZjq3En7rCU5X09a/B7I=
github.com/mark3labs/mcp-go v0.44.0/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
@@ -298,6 +322,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/mymmrac/telego v1.6.0 h1:Zc8rgyHozvd/7ZgyrigyHdAF9koHYMfilYfyB6wlFC0=
github.com/mymmrac/telego v1.6.0/go.mod h1:xt6ZWA8zi8KmuzryE1ImEdl9JSwjHNpM4yhC7D8hU4Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
@@ -308,6 +334,8 @@ github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0=
github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo=
@@ -323,6 +351,8 @@ github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
@@ -333,6 +363,8 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0=
github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs=
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
github.com/slack-go/slack v0.19.0 h1:J8lL/nGTsIUX53HU8YxZeI3PDkA+sxZsFrI2Dew7h44=
github.com/slack-go/slack v0.19.0/go.mod h1:K81UmCivcYd/5Jmz8vLBfuyoZ3B4rQC2GHVXHteXiAE=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
@@ -375,8 +407,11 @@ github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg=
github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA=
github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk=
github.com/tc-hib/winres v0.3.1 h1:CwRjEGrKdbi5CvZ4ID+iyVhgyfatxFoizjPhzez9Io4=
github.com/titanous/json5 v1.0.0 h1:hJf8Su1d9NuI/ffpxgxQfxh/UiBFZX7bMPid0rIL/7s=
github.com/titanous/json5 v1.0.0/go.mod h1:7JH1M8/LHKc6cyP5o5g3CSaRj+mBrIimTxzpvmckH8c=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/u-root/u-root v0.14.0 h1:Ka4T10EEML7dQ5XDvO9c3MBN8z4nuSnGjcd1jmU2ivg=
@@ -389,8 +424,16 @@ github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZy
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM=
github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
@@ -415,6 +458,8 @@ github.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE=
github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg=
github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU=
github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ=
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
@@ -465,6 +510,7 @@ golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
@@ -472,17 +518,24 @@ golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwE
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
@@ -518,6 +571,14 @@ honnef.co/go/tools v0.7.0-0.dev.0.20251022135355-8273271481d0 h1:5SXjd4ET5dYijLa
honnef.co/go/tools v0.7.0-0.dev.0.20251022135355-8273271481d0/go.mod h1:EPDDhEZqVHhWuPI5zPAsjU0U7v9xNIWjoOVyZ5ZcniQ=
howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM=
howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k=
software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
tailscale.com v1.94.2 h1:H+0NYSG81K1RBXnh6FfWee9G1KEeX9pvYspPrVdIfII=
+33
View File
@@ -8,11 +8,29 @@ import (
"log/slog"
"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// isUserFilePopulated checks if USER.md has been filled with actual user data
// beyond the blank template. The template has "- **Name:**\n" with no value.
func isUserFilePopulated(content string) bool {
trimmed := strings.TrimSpace(content)
if trimmed == "" {
return false
}
// Template markers: "**Name:**" followed by newline (no value) or just whitespace
for _, line := range strings.Split(content, "\n") {
line = strings.TrimSpace(line)
if line == "- **Name:**" || line == "**Name:**" {
return false // name field still empty
}
}
return true
}
// finalizeRun performs post-loop processing: sanitization, media dedup, session flush,
// bootstrap cleanup, and builds the final RunResult.
func (l *Loop) finalizeRun(
@@ -90,6 +108,7 @@ func (l *Loop) finalizeRun(
ID: filepath.Base(mr.Path),
MimeType: mr.ContentType,
Kind: kind,
Path: mr.Path,
})
}
rs.pendingMsgs = append(rs.pendingMsgs, assistantMsg)
@@ -134,6 +153,7 @@ func (l *Loop) finalizeRun(
l.sessions.Save(ctx, req.SessionKey)
// Bootstrap auto-cleanup: after enough conversation turns, remove BOOTSTRAP.md.
// If USER.md is still the blank template, inject a reminder so the agent fills it.
if hadBootstrap && l.bootstrapCleanup != nil {
userTurns := 1 // current user message
for _, m := range history {
@@ -146,6 +166,19 @@ func (l *Loop) finalizeRun(
slog.Warn("bootstrap auto-cleanup failed", "error", cleanErr, "agent", l.id, "user", req.UserID)
} else {
slog.Info("bootstrap auto-cleanup completed", "agent", l.id, "user", req.UserID, "turns", userTurns)
// Check if USER.md is still the blank template — nudge agent to fill it
if l.contextFileLoader != nil {
files := l.contextFileLoader(ctx, l.agentUUID, req.UserID, l.agentType)
for _, f := range files {
if f.Path == bootstrap.UserFile && !isUserFilePopulated(f.Content) {
rs.pendingMsgs = append(rs.pendingMsgs, providers.Message{
Role: "user",
Content: "[System] You completed onboarding but USER.md is still empty. Please update USER.md with the user's name and details from this conversation using write_file.",
})
break
}
}
}
}
}
}
+10
View File
@@ -12,12 +12,21 @@ import (
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/edition"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/safego"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
// teamGuidance returns edition-specific system prompt guidance for team members.
func teamGuidance(fullMode bool) string {
if fullMode {
return tools.FullTeamPolicy{}.MemberGuidance()
}
return tools.LiteTeamPolicy{}.MemberGuidance()
}
// filteredToolNames returns tool names after applying policy filters.
// Used for system prompt so denied tools don't appear in ## Tooling section.
func (l *Loop) filteredToolNames() []string {
@@ -187,6 +196,7 @@ func (l *Loop) buildMessages(ctx context.Context, history []providers.Message, s
HasTeam: hasTeamTools,
TeamWorkspace: tools.ToolTeamWorkspaceFromCtx(ctx),
TeamMembers: teamMembers,
TeamGuidance: teamGuidance(edition.Current().TeamFullMode),
HasSkillSearch: hasSkillSearch,
HasSkillManage: l.skillEvolve && hasSkillManage,
HasMCPToolSearch: hasMCPToolSearch,
+30 -35
View File
@@ -70,8 +70,9 @@ func (l *Loop) emitLLMSpanStart(ctx context.Context, start time.Time, iteration
span.TenantID = store.MasterTenantID
}
// Verbose mode: include input messages (same stripping as emitLLMSpan).
if collector.Verbose() && len(messages) > 0 {
// Include input messages preview as truncated JSON.
if len(messages) > 0 {
previewLimit := previewLimitForVerbose(collector.Verbose())
stripped := make([]providers.Message, len(messages))
copy(stripped, messages)
for i := range stripped {
@@ -84,7 +85,7 @@ func (l *Loop) emitLLMSpanStart(ctx context.Context, start time.Time, iteration
}
}
if b, err := json.Marshal(stripped); err == nil {
span.InputPreview = truncateStr(string(b), 100000)
span.InputPreview = tracing.TruncateJSON(string(b), previewLimit)
}
}
@@ -144,16 +145,12 @@ func (l *Loop) emitLLMSpanEnd(ctx context.Context, spanID uuid.UUID, start time.
}
}
updates["finish_reason"] = resp.FinishReason
verbose := collector.Verbose()
if verbose {
preview := resp.Content
if resp.Thinking != "" {
preview = "<thinking>\n" + resp.Thinking + "\n</thinking>\n" + resp.Content
}
updates["output_preview"] = truncateStr(preview, 100000)
} else {
updates["output_preview"] = truncateStr(resp.Content, 500)
limit := previewLimitForVerbose(collector.Verbose())
preview := resp.Content
if resp.Thinking != "" {
preview = "<thinking>\n" + resp.Thinking + "\n</thinking>\n" + resp.Content
}
updates["output_preview"] = tracing.TruncateMid(preview, limit)
}
collector.EmitSpanUpdate(spanID, traceID, updates)
@@ -173,10 +170,7 @@ func (l *Loop) emitToolSpanStart(ctx context.Context, start time.Time, toolName,
return uuid.Nil
}
previewLimit := 500
if collector.Verbose() {
previewLimit = 100000
}
previewLimit := previewLimitForVerbose(collector.Verbose())
spanID := store.GenNewID()
span := store.SpanData{
@@ -187,7 +181,7 @@ func (l *Loop) emitToolSpanStart(ctx context.Context, start time.Time, toolName,
StartTime: start,
ToolName: toolName,
ToolCallID: toolCallID,
InputPreview: truncateStr(input, previewLimit),
InputPreview: tracing.TruncateJSON(input, previewLimit),
Status: store.SpanStatusRunning,
Level: store.SpanLevelDefault,
CreatedAt: start,
@@ -222,16 +216,13 @@ func (l *Loop) emitToolSpanEnd(ctx context.Context, spanID uuid.UUID, start time
}
now := time.Now().UTC()
previewLimit := 500
if collector.Verbose() {
previewLimit = 100000
}
previewLimit := previewLimitForVerbose(collector.Verbose())
updates := map[string]any{
"end_time": now,
"duration_ms": int(now.Sub(start).Milliseconds()),
"status": store.SpanStatusCompleted,
"output_preview": truncateStr(result.ForLLM, previewLimit),
"output_preview": tracing.TruncateMid(result.ForLLM, previewLimit),
}
if result.IsError {
@@ -282,10 +273,7 @@ func (l *Loop) emitAgentSpanStart(ctx context.Context, agentSpanID uuid.UUID, st
return
}
previewLimit := 500
if collector.Verbose() {
previewLimit = 100000
}
previewLimit := previewLimitForVerbose(collector.Verbose())
spanName := l.id
span := store.SpanData{
@@ -298,7 +286,7 @@ func (l *Loop) emitAgentSpanStart(ctx context.Context, agentSpanID uuid.UUID, st
Level: store.SpanLevelDefault,
Model: l.model,
Provider: l.provider.Name(),
InputPreview: truncateStr(inputPreview, previewLimit),
InputPreview: tracing.TruncateMid(inputPreview, previewLimit),
CreatedAt: start,
}
// Nest under parent root span if this is an announce run.
@@ -341,11 +329,8 @@ func (l *Loop) emitAgentSpanEnd(ctx context.Context, agentSpanID uuid.UUID, star
updates["status"] = store.SpanStatusError
updates["error"] = runErr.Error()
} else if result != nil {
limit := 500
if collector.Verbose() {
limit = 100000
}
updates["output_preview"] = truncateStr(result.Content, limit)
limit := previewLimitForVerbose(collector.Verbose())
updates["output_preview"] = tracing.TruncateMid(result.Content, limit)
// Note: token counts are NOT set on agent spans to avoid double-counting
// with child llm_call spans. Trace aggregation sums only llm_call spans.
}
@@ -353,16 +338,26 @@ func (l *Loop) emitAgentSpanEnd(ctx context.Context, agentSpanID uuid.UUID, star
collector.EmitSpanUpdate(agentSpanID, traceID, updates)
}
// previewLimitForVerbose returns the preview character limit based on verbose mode.
func previewLimitForVerbose(verbose bool) int {
if verbose {
return 200_000
}
return 40_000
}
func truncateStr(s string, maxLen int) string {
s = strings.ToValidUTF8(s, "")
if len(s) <= maxLen {
return s
}
// Keep the tail — recent context is more useful for debugging.
start := len(s) - maxLen
// Don't cut in the middle of a multi-byte rune
for maxLen > 0 && !utf8.RuneStart(s[maxLen]) {
maxLen--
for start < len(s) && !utf8.RuneStart(s[start]) {
start++
}
return s[:maxLen] + "..."
return "..." + s[start:]
}
// EstimateTokens returns a rough token estimate for a slice of messages.
+2 -1
View File
@@ -37,6 +37,7 @@ type SystemPromptConfig struct {
HasTeam bool // agent belongs to a team? (skips generic spawn section)
TeamWorkspace string // absolute path to team shared workspace (empty if not in team)
TeamMembers []store.TeamMemberData // team member roster for task assignment
TeamGuidance string // edition-specific guidance from TeamActionPolicy.MemberGuidance()
ContextFiles []bootstrap.ContextFile // bootstrap files for # Project Context
ExtraPrompt string // extra system prompt (subagent context, etc.)
AgentType string // "open" or "predefined" — affects context file framing
@@ -228,7 +229,7 @@ func BuildSystemPrompt(cfg SystemPromptConfig) string {
// 6.4. ## Team Members — inject roster so agent knows who to assign tasks to
if !cfg.IsBootstrap && len(cfg.TeamMembers) > 0 {
lines = append(lines, buildTeamMembersSection(cfg.TeamMembers)...)
lines = append(lines, buildTeamMembersSection(cfg.TeamMembers, cfg.TeamGuidance)...)
}
// 6.5 ## Sandbox (matching TS sandboxInfo section) — skip during bootstrap
+6 -2
View File
@@ -392,7 +392,8 @@ func buildTeamWorkspaceSection(teamWsPath string) []string {
}
// buildTeamMembersSection lists team members so the agent knows who to assign tasks to.
func buildTeamMembersSection(members []store.TeamMemberData) []string {
// teamGuidance is injected from TeamActionPolicy.MemberGuidance() — varies by edition.
func buildTeamMembersSection(members []store.TeamMemberData, teamGuidance string) []string {
lines := []string{
"## Team Members",
"",
@@ -413,7 +414,10 @@ func buildTeamMembersSection(members []store.TeamMemberData) []string {
"",
"When creating tasks with team_tasks, set assignee to the agent_key of the best-suited member.",
"Do NOT invent agent keys — only use the keys listed above.",
"",
)
if teamGuidance != "" {
lines = append(lines, teamGuidance)
}
lines = append(lines, "")
return lines
}
+5 -3
View File
@@ -66,11 +66,13 @@ type TailscaleConfig struct {
EnableTLS bool `json:"enable_tls,omitempty"` // use ListenTLS for auto HTTPS certs
}
// DatabaseConfig configures the PostgreSQL connection and optional Redis cache.
// DatabaseConfig configures the database connection and optional Redis cache.
// DSN fields are NEVER read from config.json (secrets) — only from env vars.
type DatabaseConfig struct {
PostgresDSN string `json:"-"` // from env GOCLAW_POSTGRES_DSN only
RedisDSN string `json:"-"` // from env GOCLAW_REDIS_DSN only (optional, requires -tags redis)
PostgresDSN string `json:"-"` // from env GOCLAW_POSTGRES_DSN only
RedisDSN string `json:"-"` // from env GOCLAW_REDIS_DSN only (optional, requires -tags redis)
StorageBackend string `json:"-"` // from env GOCLAW_STORAGE_BACKEND only ("postgres" or "sqlite", default "postgres")
SQLitePath string `json:"-"` // from env GOCLAW_SQLITE_PATH only (default: {dataDir}/goclaw.db)
}
// SkillsConfig configures the skills storage system.
+2
View File
@@ -186,6 +186,8 @@ func (c *Config) applyEnvOverrides() {
// Database
envStr("GOCLAW_POSTGRES_DSN", &c.Database.PostgresDSN)
envStr("GOCLAW_REDIS_DSN", &c.Database.RedisDSN)
envStr("GOCLAW_STORAGE_BACKEND", &c.Database.StorageBackend)
envStr("GOCLAW_SQLITE_PATH", &c.Database.SQLitePath)
// Deprecation warning for GOCLAW_MODE (removed — PostgreSQL is always active)
if v := os.Getenv("GOCLAW_MODE"); v != "" {
+79
View File
@@ -0,0 +1,79 @@
// Package edition defines feature tiers for GoClaw.
// Set once at startup via SetCurrent(), read everywhere via Current().
// Adding a new edition = add one preset struct. No code changes elsewhere.
package edition
import "sync/atomic"
// Edition defines the feature limits for a GoClaw instance.
type Edition struct {
Name string `json:"name"` // "standard" or "lite"
MaxAgents int `json:"max_agents"` // 0 = unlimited
MaxTeams int `json:"max_teams"` // 0 = unlimited
MaxTeamMembers int `json:"max_team_members"` // 0 = unlimited
MaxChannels map[string]int `json:"max_channels"` // per channel type, nil = unlimited
KGEnabled bool `json:"kg_enabled"`
RBACEnabled bool `json:"rbac_enabled"`
TeamFullMode bool `json:"team_full_mode"` // false = lite task actions only
VectorSearch bool `json:"vector_search"` // false = FTS5 only
}
// --- Presets ---
// Standard is the default edition: all features enabled, no limits.
var Standard = Edition{
Name: "standard",
KGEnabled: true,
RBACEnabled: true,
TeamFullMode: true,
VectorSearch: true,
}
// Lite is the desktop/self-hosted edition with sensible limits.
var Lite = Edition{
Name: "lite",
MaxAgents: 5,
MaxTeams: 1,
MaxTeamMembers: 5,
MaxChannels: map[string]int{"telegram": 1, "discord": 1},
KGEnabled: false,
RBACEnabled: false,
TeamFullMode: false,
VectorSearch: false,
}
// --- Global state ---
// current holds the active edition. Atomic pointer for safe concurrent reads.
var current atomic.Pointer[Edition]
func init() {
std := Standard
current.Store(&std)
}
// Current returns the active edition. Safe for concurrent use.
func Current() Edition {
return *current.Load()
}
// SetCurrent sets the active edition. Call once at startup.
func SetCurrent(e Edition) {
current.Store(&e)
}
// --- Helpers ---
// IsLimited returns true if the edition enforces resource limits.
func (e Edition) IsLimited() bool {
return e.MaxAgents > 0 || e.MaxTeams > 0
}
// ChannelLimit returns the max instances for a channel type.
// Returns 0 (unlimited) if the channel type is not in MaxChannels.
func (e Edition) ChannelLimit(channelType string) int {
if e.MaxChannels == nil {
return 0
}
return e.MaxChannels[channelType]
}
+4
View File
@@ -125,6 +125,10 @@ func (m *TeamsMethods) handleCreate(ctx context.Context, client *gateway.Client,
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "lead")))
return
}
if len(params.Members) == 0 {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "at least 1 member is required"))
return
}
// Resolve lead agent
leadAgent, err := resolveAgentInfo(ctx, m.agentStore, params.Lead)
+26 -1
View File
@@ -8,6 +8,7 @@ import (
"log/slog"
"net"
"net/http"
"os"
"strings"
"sync"
"time"
@@ -279,10 +280,16 @@ func tokenAuthMiddleware(token string, next http.Handler) http.Handler {
func (s *Server) Start(ctx context.Context) error {
mux := s.BuildMux()
// Wrap with CORS for desktop dev mode (Wails serves frontend on different port).
var handler http.Handler = mux
if os.Getenv("GOCLAW_DESKTOP") == "1" {
handler = desktopCORS(mux)
}
addr := fmt.Sprintf("%s:%d", s.cfg.Gateway.Host, s.cfg.Gateway.Port)
s.httpServer = &http.Server{
Addr: addr,
Handler: mux,
Handler: handler,
}
slog.Info("gateway starting", "addr", addr)
@@ -469,6 +476,9 @@ func (s *Server) SetUsageHandler(h *httpapi.UsageHandler) { s.handlers = append(
// SetDocsHandler sets the OpenAPI spec + Swagger UI handler.
func (s *Server) SetDocsHandler(h *httpapi.DocsHandler) { s.handlers = append(s.handlers, h) }
// SetEditionHandler sets the edition info handler.
func (s *Server) SetEditionHandler(h *httpapi.EditionHandler) { s.handlers = append(s.handlers, h) }
// SetAgentStore sets the agent store for context injection in tools_invoke.
func (s *Server) SetAgentStore(as store.AgentStore) { s.agentStore = as }
@@ -612,3 +622,18 @@ func StartTestServer(s *Server, ctx context.Context) (addr string, start func())
return addr, start
}
// desktopCORS wraps a handler with permissive CORS headers for desktop dev mode.
// Only active when GOCLAW_DESKTOP=1 (set by desktop app.go).
func desktopCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-GoClaw-Tenant-Id, X-GoClaw-User-Id")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
+23
View File
@@ -0,0 +1,23 @@
package http
import (
"encoding/json"
"net/http"
"github.com/nextlevelbuilder/goclaw/internal/edition"
)
// EditionHandler serves the current edition info for UI feature comparison.
type EditionHandler struct{}
func NewEditionHandler() *EditionHandler { return &EditionHandler{} }
// RegisterRoutes adds the /v1/edition route. No auth required — edition info is public.
func (h *EditionHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /v1/edition", h.handleGet)
}
func (h *EditionHandler) handleGet(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(edition.Current())
}
+101 -10
View File
@@ -1,6 +1,7 @@
package http
import (
"encoding/json"
"fmt"
"log/slog"
"mime"
@@ -10,6 +11,7 @@ import (
"strings"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/edition"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
@@ -33,6 +35,29 @@ func NewFilesHandler(workspace, dataDir string) *FilesHandler {
// RegisterRoutes registers the file serving route.
func (h *FilesHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /v1/files/{path...}", h.auth(h.handleServe))
mux.HandleFunc("POST /v1/files/sign", h.handleSign)
}
// handleSign accepts a JSON body with a "path" field (absolute file path),
// returns a signed /v1/files/ URL with ?ft= token. Requires Bearer auth.
func (h *FilesHandler) handleSign(w http.ResponseWriter, r *http.Request) {
provided := extractBearerToken(r)
authedReq, ok := requireAuthBearer("", provided, w, r)
if !ok {
return
}
var body struct {
Path string `json:"path"`
}
if err := json.NewDecoder(authedReq.Body).Decode(&body); err != nil || body.Path == "" {
http.Error(w, `{"error":"path required"}`, http.StatusBadRequest)
return
}
urlPath := "/v1/files/" + strings.TrimPrefix(filepath.Clean(body.Path), "/")
ft := SignFileToken(urlPath, FileSigningKey(), FileTokenTTL)
writeJSON(w, http.StatusOK, map[string]string{
"url": urlPath + "?ft=" + ft,
})
}
func (h *FilesHandler) auth(next http.HandlerFunc) http.HandlerFunc {
@@ -93,21 +118,52 @@ func (h *FilesHandler) handleServe(w http.ResponseWriter, r *http.Request) {
}
}
// Tenant isolation: validate absolute path is within tenant's allowed directories.
// Skip for HMAC file tokens — the path is cryptographically bound in the signature,
// so it cannot be tampered with. File tokens are generated server-side with the correct path.
// Path isolation: validate file path is within allowed directories.
// Skip for HMAC file tokens — path is cryptographically bound in the signature.
if r.URL.Query().Get("ft") == "" {
tenantData := config.TenantDataDir(h.dataDir, store.TenantIDFromContext(r.Context()), store.TenantSlugFromContext(r.Context()))
tenantWs := h.tenantWorkspace(r)
if !strings.HasPrefix(absPath, tenantData+string(filepath.Separator)) &&
!strings.HasPrefix(absPath, tenantWs+string(filepath.Separator)) &&
absPath != tenantData && absPath != tenantWs {
allowed := false
// Always allow files within workspace root and data dir root.
// These are the two top-level directories that contain all user files.
sep := string(filepath.Separator)
if h.workspace != "" && (strings.HasPrefix(absPath, h.workspace+sep) || absPath == h.workspace) {
allowed = true
}
if !allowed && h.dataDir != "" && (strings.HasPrefix(absPath, h.dataDir+sep) || absPath == h.dataDir) {
allowed = true
}
// Multi-tenant (standard edition): additionally restrict to tenant-scoped subdirectories.
if allowed && edition.Current().RBACEnabled {
tenantData := config.TenantDataDir(h.dataDir, store.TenantIDFromContext(r.Context()), store.TenantSlugFromContext(r.Context()))
tenantWs := h.tenantWorkspace(r)
if !strings.HasPrefix(absPath, tenantData+sep) &&
!strings.HasPrefix(absPath, tenantWs+sep) &&
absPath != tenantData && absPath != tenantWs {
allowed = false
}
}
if !allowed {
slog.Warn("security.files_path_denied", "path", absPath, "workspace", h.workspace, "data_dir", h.dataDir)
http.NotFound(w, r)
return
}
}
info, err := os.Stat(absPath)
if err != nil && !os.IsNotExist(err) {
http.NotFound(w, r)
return
}
// Fuzzy match: generated files have timestamp suffixes (e.g. "file_20260326-232559_269000.png")
// but LLM may reference them without timestamp (e.g. "file.png"). Try prefix match in same dir.
if err != nil {
if resolved := fuzzyMatchInDir(absPath); resolved != "" {
absPath = resolved
info, err = os.Stat(absPath)
}
}
if err != nil || info.IsDir() {
// Fallback: search workspace for file by basename (handles LLM-hallucinated paths).
// Generated media filenames include timestamps and are globally unique.
@@ -162,8 +218,16 @@ func (h *FilesHandler) findInWorkspace(workspace, basename string) string {
}
if d.IsDir() {
name := d.Name()
// Allow workspace root + known directory structures
if name == "teams" || name == "generated" || name == "system" || name == ".uploads" || path == workspace {
// Allow workspace root
if path == workspace {
return nil
}
// Allow direct children of workspace root (agent workspace dirs like "quill", "goclaw")
if filepath.Dir(path) == workspace {
return nil
}
// Allow known directory structures
if name == "teams" || name == "generated" || name == "system" || name == "ws" || name == ".uploads" || name == "tenants" {
return nil
}
// Allow date directories (e.g. 2026-03-20)
@@ -185,6 +249,32 @@ func (h *FilesHandler) findInWorkspace(workspace, basename string) string {
return found
}
// fuzzyMatchInDir handles LLM-hallucinated filenames missing timestamp suffixes.
// E.g. requested "file.png" matches "file_20260326-232559_269000.png" in the same directory.
func fuzzyMatchInDir(absPath string) string {
dir := filepath.Dir(absPath)
base := filepath.Base(absPath)
ext := filepath.Ext(base)
stem := strings.TrimSuffix(base, ext) // "smart-home-cover"
entries, err := os.ReadDir(dir)
if err != nil {
return ""
}
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
// Match: starts with stem, has same extension, has timestamp between
// e.g. "smart-home-cover_20260326-232444_269000.png"
if strings.HasPrefix(name, stem) && strings.HasSuffix(name, ext) && name != base {
return filepath.Join(dir, name)
}
}
return ""
}
func isNumeric(s string) bool {
for _, c := range s {
if c < '0' || c > '9' {
@@ -193,3 +283,4 @@ func isNumeric(s string) bool {
}
return len(s) > 0
}
+6 -3
View File
@@ -156,16 +156,19 @@ func (a *AgentData) ParseMemoryConfig() *config.MemoryConfig {
}
// ParseThinkingLevel extracts thinking_level from other_config JSONB.
// Returns "" if not configured (meaning "off").
// Returns "low" if not configured (default thinking mode).
func (a *AgentData) ParseThinkingLevel() string {
if len(a.OtherConfig) == 0 {
return ""
return "low"
}
var cfg struct {
ThinkingLevel string `json:"thinking_level"`
}
if json.Unmarshal(a.OtherConfig, &cfg) != nil {
return ""
return "low"
}
if cfg.ThinkingLevel == "" {
return "low"
}
return cfg.ThinkingLevel
}
+17
View File
@@ -547,3 +547,20 @@ func (s *PGTracingStore) GetCostSummary(ctx context.Context, opts store.CostSumm
}
return result, nil
}
// DeleteTracesOlderThan deletes traces and their spans older than cutoff.
// Spans are deleted first (FK), then traces. Returns total traces deleted.
func (s *PGTracingStore) DeleteTracesOlderThan(ctx context.Context, cutoff time.Time) (int64, error) {
// Delete spans belonging to old traces.
_, err := s.db.ExecContext(ctx,
`DELETE FROM spans WHERE trace_id IN (SELECT id FROM traces WHERE created_at < $1)`, cutoff)
if err != nil {
return 0, fmt.Errorf("delete old spans: %w", err)
}
res, err := s.db.ExecContext(ctx, `DELETE FROM traces WHERE created_at < $1`, cutoff)
if err != nil {
return 0, fmt.Errorf("delete old traces: %w", err)
}
return res.RowsAffected()
}
+120
View File
@@ -0,0 +1,120 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"database/sql"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// SQLiteActivityStore implements store.ActivityStore backed by SQLite.
type SQLiteActivityStore struct {
db *sql.DB
}
// NewSQLiteActivityStore creates a new SQLiteActivityStore.
func NewSQLiteActivityStore(db *sql.DB) *SQLiteActivityStore {
return &SQLiteActivityStore{db: db}
}
func (s *SQLiteActivityStore) Log(ctx context.Context, entry *store.ActivityLog) error {
tenantID := store.TenantIDFromContext(ctx)
if tenantID == uuid.Nil {
tenantID = store.MasterTenantID
}
id := uuid.New()
_, err := s.db.ExecContext(ctx,
`INSERT INTO activity_logs (id, actor_type, actor_id, action, entity_type, entity_id, details, ip_address, tenant_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
id, entry.ActorType, entry.ActorID, entry.Action,
entry.EntityType, entry.EntityID, entry.Details, entry.IPAddress, tenantID,
)
return err
}
func (s *SQLiteActivityStore) List(ctx context.Context, opts store.ActivityListOpts) ([]store.ActivityLog, error) {
where, args := buildActivityWhere(ctx, opts)
limit := opts.Limit
if limit <= 0 {
limit = 50
}
args = append(args, limit, opts.Offset)
query := fmt.Sprintf(
`SELECT id, actor_type, actor_id, action, COALESCE(entity_type,''), COALESCE(entity_id,''), COALESCE(details,'null'), COALESCE(ip_address,''), created_at
FROM activity_logs %s ORDER BY created_at DESC LIMIT ? OFFSET ?`,
where,
)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var result []store.ActivityLog
for rows.Next() {
var a store.ActivityLog
var createdAt sqliteTime
if err := rows.Scan(&a.ID, &a.ActorType, &a.ActorID, &a.Action, &a.EntityType, &a.EntityID, &a.Details, &a.IPAddress, &createdAt); err != nil {
return nil, err
}
a.CreatedAt = createdAt.Time
result = append(result, a)
}
return result, rows.Err()
}
func (s *SQLiteActivityStore) Count(ctx context.Context, opts store.ActivityListOpts) (int, error) {
where, args := buildActivityWhere(ctx, opts)
query := fmt.Sprintf("SELECT COUNT(*) FROM activity_logs %s", where)
var count int
err := s.db.QueryRowContext(ctx, query, args...).Scan(&count)
return count, err
}
func buildActivityWhere(ctx context.Context, opts store.ActivityListOpts) (string, []any) {
var conditions []string
var args []any
if !store.IsCrossTenant(ctx) {
tenantID := store.TenantIDFromContext(ctx)
if tenantID != uuid.Nil {
conditions = append(conditions, "tenant_id = ?")
args = append(args, tenantID)
}
}
if opts.ActorType != "" {
conditions = append(conditions, "actor_type = ?")
args = append(args, opts.ActorType)
}
if opts.ActorID != "" {
conditions = append(conditions, "actor_id = ?")
args = append(args, opts.ActorID)
}
if opts.Action != "" {
conditions = append(conditions, "action = ?")
args = append(args, opts.Action)
}
if opts.EntityType != "" {
conditions = append(conditions, "entity_type = ?")
args = append(args, opts.EntityType)
}
if opts.EntityID != "" {
conditions = append(conditions, "entity_id = ?")
args = append(args, opts.EntityID)
}
if len(conditions) == 0 {
return "", nil
}
return "WHERE " + strings.Join(conditions, " AND "), args
}
+274
View File
@@ -0,0 +1,274 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"database/sql"
"fmt"
"log/slog"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// SQLiteAgentStore implements store.AgentStore backed by SQLite.
type SQLiteAgentStore struct {
db *sql.DB
// embProvider is intentionally omitted — vector search not supported in SQLite lite edition.
}
func NewSQLiteAgentStore(db *sql.DB) *SQLiteAgentStore {
return &SQLiteAgentStore{db: db}
}
// SetEmbeddingProvider is a no-op for SQLite — vector not supported.
func (s *SQLiteAgentStore) SetEmbeddingProvider(_ store.EmbeddingProvider) {}
// agentSelectCols is the column list for all agent SELECT queries.
const agentSelectCols = `id, agent_key, display_name, frontmatter, owner_id, provider, model,
context_window, max_tool_iterations, workspace, restrict_to_workspace,
tools_config, sandbox_config, subagents_config, memory_config,
compaction_config, context_pruning, other_config,
agent_type, is_default, status, budget_monthly_cents, created_at, updated_at, tenant_id`
func (s *SQLiteAgentStore) Create(ctx context.Context, agent *store.AgentData) error {
if agent.ID == uuid.Nil {
agent.ID = store.GenNewID()
}
now := time.Now()
agent.CreatedAt = now
agent.UpdatedAt = now
tenantID := agent.TenantID
if tenantID == uuid.Nil {
tenantID = store.MasterTenantID
}
_, err := s.db.ExecContext(ctx,
`INSERT INTO agents (id, agent_key, display_name, frontmatter, owner_id, provider, model,
context_window, max_tool_iterations, workspace, restrict_to_workspace,
tools_config, sandbox_config, subagents_config, memory_config,
compaction_config, context_pruning, other_config,
agent_type, is_default, status, budget_monthly_cents, created_at, updated_at, tenant_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
agent.ID, agent.AgentKey,
agent.DisplayName,
sql.NullString{String: agent.Frontmatter, Valid: agent.Frontmatter != ""},
agent.OwnerID, agent.Provider, agent.Model,
agent.ContextWindow, agent.MaxToolIterations, agent.Workspace, agent.RestrictToWorkspace,
jsonOrEmpty(agent.ToolsConfig), jsonOrNull(agent.SandboxConfig), jsonOrNull(agent.SubagentsConfig), jsonOrNull(agent.MemoryConfig),
jsonOrNull(agent.CompactionConfig), jsonOrNull(agent.ContextPruning), jsonOrEmpty(agent.OtherConfig),
agent.AgentType, agent.IsDefault, agent.Status, agent.BudgetMonthlyCents,
now, now, tenantID,
)
return err
}
func (s *SQLiteAgentStore) GetByKey(ctx context.Context, agentKey string) (*store.AgentData, error) {
var row *sql.Row
if store.IsCrossTenant(ctx) {
row = s.db.QueryRowContext(ctx,
`SELECT `+agentSelectCols+` FROM agents WHERE agent_key = ? AND deleted_at IS NULL`, agentKey)
} else {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
return nil, fmt.Errorf("agent not found: %s", agentKey)
}
row = s.db.QueryRowContext(ctx,
`SELECT `+agentSelectCols+` FROM agents WHERE agent_key = ? AND deleted_at IS NULL AND tenant_id = ?`,
agentKey, tid)
}
d, err := scanAgentRow(row)
if err != nil {
return nil, fmt.Errorf("agent not found: %s", agentKey)
}
return d, nil
}
func (s *SQLiteAgentStore) GetByID(ctx context.Context, id uuid.UUID) (*store.AgentData, error) {
var row *sql.Row
if store.IsCrossTenant(ctx) {
row = s.db.QueryRowContext(ctx,
`SELECT `+agentSelectCols+` FROM agents WHERE id = ? AND deleted_at IS NULL`, id)
} else {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
return nil, fmt.Errorf("agent not found: %s", id)
}
row = s.db.QueryRowContext(ctx,
`SELECT `+agentSelectCols+` FROM agents WHERE id = ? AND deleted_at IS NULL AND tenant_id = ?`, id, tid)
}
d, err := scanAgentRow(row)
if err != nil {
return nil, fmt.Errorf("agent not found: %s", id)
}
return d, nil
}
func (s *SQLiteAgentStore) GetByIDUnscoped(ctx context.Context, id uuid.UUID) (*store.AgentData, error) {
row := s.db.QueryRowContext(ctx,
`SELECT `+agentSelectCols+` FROM agents WHERE id = ? AND deleted_at IS NULL`, id)
d, err := scanAgentRow(row)
if err != nil {
return nil, fmt.Errorf("agent not found: %s", id)
}
return d, nil
}
func (s *SQLiteAgentStore) Update(ctx context.Context, id uuid.UUID, updates map[string]any) error {
if len(updates) == 0 {
return nil
}
// Unset existing default before setting a new one (scoped to same tenant).
if v, ok := updates["is_default"]; ok {
if isDefault, _ := v.(bool); isDefault {
if store.IsCrossTenant(ctx) {
if _, err := s.db.ExecContext(ctx,
"UPDATE agents SET is_default = 0 WHERE is_default = 1 AND id != ? AND deleted_at IS NULL", id); err != nil {
slog.Warn("agents.unset_default", "error", err)
}
} else {
tid := store.TenantIDFromContext(ctx)
if tid != uuid.Nil {
if _, err := s.db.ExecContext(ctx,
"UPDATE agents SET is_default = 0 WHERE is_default = 1 AND id != ? AND deleted_at IS NULL AND tenant_id = ?",
id, tid); err != nil {
slog.Warn("agents.unset_default", "error", err)
}
}
}
}
}
updates["updated_at"] = time.Now()
if store.IsCrossTenant(ctx) {
return execMapUpdate(ctx, s.db, "agents", id, updates)
}
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
return fmt.Errorf("agent not found: %s", id)
}
return execMapUpdateWhereTenant(ctx, s.db, "agents", updates, id, tid)
}
func (s *SQLiteAgentStore) Delete(ctx context.Context, id uuid.UUID) error {
if store.IsCrossTenant(ctx) {
_, err := s.db.ExecContext(ctx, "DELETE FROM agents WHERE id = ?", id)
return err
}
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
return fmt.Errorf("agent not found: %s", id)
}
_, err := s.db.ExecContext(ctx, "DELETE FROM agents WHERE id = ? AND tenant_id = ?", id, tid)
return err
}
func (s *SQLiteAgentStore) List(ctx context.Context, ownerID string) ([]store.AgentData, error) {
q := `SELECT ` + agentSelectCols + ` FROM agents WHERE deleted_at IS NULL`
var args []any
if ownerID != "" {
q += " AND owner_id = ?"
args = append(args, ownerID)
}
if !store.IsCrossTenant(ctx) {
tid := store.TenantIDFromContext(ctx)
if tid != uuid.Nil {
q += " AND tenant_id = ?"
args = append(args, tid)
}
}
q += " ORDER BY created_at DESC"
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
return scanAgentRows(rows)
}
func (s *SQLiteAgentStore) GetDefault(ctx context.Context) (*store.AgentData, error) {
if store.IsCrossTenant(ctx) {
row := s.db.QueryRowContext(ctx,
`SELECT `+agentSelectCols+`
FROM agents WHERE deleted_at IS NULL
ORDER BY is_default DESC, created_at ASC LIMIT 1`)
return scanAgentRow(row)
}
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
return nil, fmt.Errorf("agent not found: default")
}
row := s.db.QueryRowContext(ctx,
`SELECT `+agentSelectCols+`
FROM agents WHERE deleted_at IS NULL AND tenant_id = ?
ORDER BY is_default DESC, created_at ASC LIMIT 1`, tid)
return scanAgentRow(row)
}
// --- Scan helpers ---
type agentRowScanner interface {
Scan(dest ...any) error
}
func scanAgentRow(row agentRowScanner) (*store.AgentData, error) {
var d store.AgentData
var frontmatter sql.NullString
var toolsCfg, sandboxCfg, subagentsCfg, memoryCfg, compactionCfg, pruningCfg, otherCfg *[]byte
createdAt, updatedAt := scanTimePair()
err := row.Scan(
&d.ID, &d.AgentKey, &d.DisplayName, &frontmatter, &d.OwnerID, &d.Provider, &d.Model,
&d.ContextWindow, &d.MaxToolIterations, &d.Workspace, &d.RestrictToWorkspace,
&toolsCfg, &sandboxCfg, &subagentsCfg, &memoryCfg, &compactionCfg, &pruningCfg, &otherCfg,
&d.AgentType, &d.IsDefault, &d.Status, &d.BudgetMonthlyCents,
createdAt, updatedAt, &d.TenantID,
)
if err != nil {
return nil, err
}
d.CreatedAt = createdAt.Time
d.UpdatedAt = updatedAt.Time
if frontmatter.Valid {
d.Frontmatter = frontmatter.String
}
if toolsCfg != nil {
d.ToolsConfig = *toolsCfg
}
if sandboxCfg != nil {
d.SandboxConfig = *sandboxCfg
}
if subagentsCfg != nil {
d.SubagentsConfig = *subagentsCfg
}
if memoryCfg != nil {
d.MemoryConfig = *memoryCfg
}
if compactionCfg != nil {
d.CompactionConfig = *compactionCfg
}
if pruningCfg != nil {
d.ContextPruning = *pruningCfg
}
if otherCfg != nil {
d.OtherConfig = *otherCfg
}
return &d, nil
}
func scanAgentRows(rows *sql.Rows) ([]store.AgentData, error) {
var result []store.AgentData
for rows.Next() {
d, err := scanAgentRow(rows)
if err != nil {
continue
}
result = append(result, *d)
}
return result, rows.Err()
}
+180
View File
@@ -0,0 +1,180 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func (s *SQLiteAgentStore) ShareAgent(ctx context.Context, agentID uuid.UUID, userID, role, grantedBy string) error {
if err := store.ValidateUserID(userID); err != nil {
return err
}
if err := store.ValidateUserID(grantedBy); err != nil {
return err
}
tid := tenantIDForInsert(ctx)
_, err := s.db.ExecContext(ctx,
`INSERT INTO agent_shares (id, agent_id, user_id, role, granted_by, tenant_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (agent_id, user_id) DO UPDATE SET role = excluded.role, granted_by = excluded.granted_by`,
store.GenNewID(), agentID, userID, role, grantedBy, tid, time.Now(),
)
return err
}
func (s *SQLiteAgentStore) RevokeShare(ctx context.Context, agentID uuid.UUID, userID string) error {
if store.IsCrossTenant(ctx) {
_, err := s.db.ExecContext(ctx,
"DELETE FROM agent_shares WHERE agent_id = ? AND user_id = ?", agentID, userID)
return err
}
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
return fmt.Errorf("tenant_id required")
}
_, err := s.db.ExecContext(ctx,
"DELETE FROM agent_shares WHERE agent_id = ? AND user_id = ? AND tenant_id = ?", agentID, userID, tid)
return err
}
func (s *SQLiteAgentStore) ListShares(ctx context.Context, agentID uuid.UUID) ([]store.AgentShareData, error) {
q := "SELECT id, agent_id, user_id, role, granted_by, created_at FROM agent_shares WHERE agent_id = ?"
args := []any{agentID}
if !store.IsCrossTenant(ctx) {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
return nil, fmt.Errorf("tenant_id required")
}
q += " AND tenant_id = ?"
args = append(args, tid)
}
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var result []store.AgentShareData
for rows.Next() {
var d store.AgentShareData
var createdAt sqliteTime
if err := rows.Scan(&d.ID, &d.AgentID, &d.UserID, &d.Role, &d.GrantedBy, &createdAt); err != nil {
continue
}
d.CreatedAt = createdAt.Time
result = append(result, d)
}
return result, rows.Err()
}
func (s *SQLiteAgentStore) CanAccess(ctx context.Context, agentID uuid.UUID, userID string) (bool, string, error) {
var ownerID string
var isDefault bool
var err error
if store.IsCrossTenant(ctx) {
err = s.db.QueryRowContext(ctx,
"SELECT owner_id, is_default FROM agents WHERE id = ? AND deleted_at IS NULL", agentID,
).Scan(&ownerID, &isDefault)
} else {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
return false, "", fmt.Errorf("agent not found")
}
err = s.db.QueryRowContext(ctx,
"SELECT owner_id, is_default FROM agents WHERE id = ? AND deleted_at IS NULL AND tenant_id = ?",
agentID, tid,
).Scan(&ownerID, &isDefault)
}
if err != nil {
return false, "", fmt.Errorf("agent not found")
}
if isDefault {
if ownerID == userID {
return true, "owner", nil
}
return true, "user", nil
}
if ownerID == userID {
return true, "owner", nil
}
// Check shares
var role string
if store.IsCrossTenant(ctx) {
err = s.db.QueryRowContext(ctx,
"SELECT role FROM agent_shares WHERE agent_id = ? AND user_id = ?", agentID, userID,
).Scan(&role)
} else {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
return false, "", nil
}
err = s.db.QueryRowContext(ctx,
"SELECT role FROM agent_shares WHERE agent_id = ? AND user_id = ? AND tenant_id = ?",
agentID, userID, tid,
).Scan(&role)
}
if err != nil {
return false, "", nil
}
return true, role, nil
}
func (s *SQLiteAgentStore) ListAccessible(ctx context.Context, userID string) ([]store.AgentData, error) {
if store.IsCrossTenant(ctx) {
rows, err := s.db.QueryContext(ctx,
`SELECT `+agentSelectCols+`
FROM agents
WHERE deleted_at IS NULL AND (
owner_id = ?
OR is_default = 1
OR id IN (SELECT agent_id FROM agent_shares WHERE user_id = ?)
OR (agent_type = 'predefined' AND id IN (
SELECT agent_id FROM channel_instances ci
WHERE ci.enabled = 1
AND EXISTS (
SELECT 1 FROM json_each(json_extract(ci.config, '$.allow_from'))
WHERE json_each.value = ?
)
))
)
ORDER BY created_at DESC`, userID, userID, userID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanAgentRows(rows)
}
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
return nil, fmt.Errorf("tenant_id required")
}
rows, err := s.db.QueryContext(ctx,
`SELECT `+agentSelectCols+`
FROM agents
WHERE deleted_at IS NULL AND tenant_id = ? AND (
owner_id = ?
OR is_default = 1
OR id IN (SELECT agent_id FROM agent_shares WHERE user_id = ? AND tenant_id = ?)
OR (agent_type = 'predefined' AND id IN (
SELECT agent_id FROM channel_instances ci
WHERE ci.enabled = 1 AND ci.tenant_id = ?
AND EXISTS (
SELECT 1 FROM json_each(json_extract(ci.config, '$.allow_from'))
WHERE json_each.value = ?
)
))
)
ORDER BY created_at DESC`, tid, userID, userID, tid, tid, userID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanAgentRows(rows)
}
@@ -0,0 +1,87 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// GetByKeys returns agents matching the given keys in a single query.
// Replaces PG's = ANY($1) with a dynamic IN (?, ?, ...) clause.
func (s *SQLiteAgentStore) GetByKeys(ctx context.Context, keys []string) ([]store.AgentData, error) {
if len(keys) == 0 {
return nil, nil
}
placeholders := strings.Repeat("?,", len(keys))
placeholders = placeholders[:len(placeholders)-1] // trim trailing comma
args := make([]any, len(keys))
for i, k := range keys {
args[i] = k
}
var q string
if store.IsCrossTenant(ctx) {
q = `SELECT ` + agentSelectCols + `
FROM agents WHERE agent_key IN (` + placeholders + `) AND deleted_at IS NULL`
} else {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
return nil, fmt.Errorf("no tenant context for batch agent lookup")
}
q = `SELECT ` + agentSelectCols + `
FROM agents WHERE agent_key IN (` + placeholders + `) AND deleted_at IS NULL AND tenant_id = ?`
args = append(args, tid)
}
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("batch agent key lookup: %w", err)
}
defer rows.Close()
return scanAgentRows(rows)
}
// GetByIDs returns agents matching the given UUIDs in a single query.
// Replaces PG's = ANY($1) with a dynamic IN (?, ?, ...) clause.
func (s *SQLiteAgentStore) GetByIDs(ctx context.Context, ids []uuid.UUID) ([]store.AgentData, error) {
if len(ids) == 0 {
return nil, nil
}
placeholders := strings.Repeat("?,", len(ids))
placeholders = placeholders[:len(placeholders)-1]
args := make([]any, len(ids))
for i, id := range ids {
args[i] = id
}
var q string
if store.IsCrossTenant(ctx) {
q = `SELECT ` + agentSelectCols + `
FROM agents WHERE id IN (` + placeholders + `) AND deleted_at IS NULL`
} else {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
return nil, fmt.Errorf("no tenant context for batch agent lookup")
}
q = `SELECT ` + agentSelectCols + `
FROM agents WHERE id IN (` + placeholders + `) AND deleted_at IS NULL AND tenant_id = ?`
args = append(args, tid)
}
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("batch agent ID lookup: %w", err)
}
defer rows.Close()
return scanAgentRows(rows)
}
@@ -0,0 +1,305 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"database/sql"
"encoding/json"
"errors"
"path/filepath"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// --- Agent-level Context Files ---
func (s *SQLiteAgentStore) GetAgentContextFiles(ctx context.Context, agentID uuid.UUID) ([]store.AgentContextFileData, error) {
tClause, tArgs, err := scopeClause(ctx)
if err != nil {
return nil, err
}
rows, err := s.db.QueryContext(ctx,
"SELECT agent_id, file_name, content FROM agent_context_files WHERE agent_id = ?"+tClause,
append([]any{agentID}, tArgs...)...)
if err != nil {
return nil, err
}
defer rows.Close()
var result []store.AgentContextFileData
for rows.Next() {
var d store.AgentContextFileData
if err := rows.Scan(&d.AgentID, &d.FileName, &d.Content); err != nil {
continue
}
result = append(result, d)
}
return result, rows.Err()
}
func (s *SQLiteAgentStore) SetAgentContextFile(ctx context.Context, agentID uuid.UUID, fileName, content string) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO agent_context_files (id, agent_id, file_name, content, updated_at, tenant_id)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (agent_id, file_name) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at`,
store.GenNewID(), agentID, fileName, content, time.Now(), tenantIDForInsert(ctx),
)
return err
}
// PropagateContextFile copies an agent-level context file to all existing user
// instances that already have that file. Returns updated row count.
// SQLite does not support UPDATE...FROM, so we use a 2-step approach.
func (s *SQLiteAgentStore) PropagateContextFile(ctx context.Context, agentID uuid.UUID, fileName string) (int, error) {
tClause, tArgs, err := scopeClause(ctx)
if err != nil {
return 0, err
}
// Step 1: fetch the agent-level content.
var content string
err = s.db.QueryRowContext(ctx,
"SELECT content FROM agent_context_files WHERE agent_id = ? AND file_name = ?"+tClause,
append([]any{agentID, fileName}, tArgs...)...,
).Scan(&content)
if errors.Is(err, sql.ErrNoRows) {
return 0, nil
}
if err != nil {
return 0, err
}
// Step 2: update all matching user context files.
res, err := s.db.ExecContext(ctx,
"UPDATE user_context_files SET content = ?, updated_at = ? WHERE agent_id = ? AND file_name = ?"+tClause,
append([]any{content, time.Now(), agentID, fileName}, tArgs...)...,
)
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
return int(n), nil
}
// --- Per-user Context Files ---
func (s *SQLiteAgentStore) GetUserContextFiles(ctx context.Context, agentID uuid.UUID, userID string) ([]store.UserContextFileData, error) {
tClause, tArgs, err := scopeClause(ctx)
if err != nil {
return nil, err
}
rows, err := s.db.QueryContext(ctx,
"SELECT agent_id, user_id, file_name, content FROM user_context_files WHERE agent_id = ? AND user_id = ?"+tClause,
append([]any{agentID, userID}, tArgs...)...)
if err != nil {
return nil, err
}
defer rows.Close()
var result []store.UserContextFileData
for rows.Next() {
var d store.UserContextFileData
if err := rows.Scan(&d.AgentID, &d.UserID, &d.FileName, &d.Content); err != nil {
continue
}
result = append(result, d)
}
return result, rows.Err()
}
func (s *SQLiteAgentStore) SetUserContextFile(ctx context.Context, agentID uuid.UUID, userID, fileName, content string) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO user_context_files (id, agent_id, user_id, file_name, content, updated_at, tenant_id)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (agent_id, user_id, file_name) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at`,
store.GenNewID(), agentID, userID, fileName, content, time.Now(), tenantIDForInsert(ctx),
)
return err
}
func (s *SQLiteAgentStore) DeleteUserContextFile(ctx context.Context, agentID uuid.UUID, userID, fileName string) error {
tClause, tArgs, err := scopeClause(ctx)
if err != nil {
return err
}
_, err = s.db.ExecContext(ctx,
"DELETE FROM user_context_files WHERE agent_id = ? AND user_id = ? AND file_name = ?"+tClause,
append([]any{agentID, userID, fileName}, tArgs...)...)
return err
}
// --- User-Agent Profiles ---
// GetOrCreateUserProfile upserts a user profile and returns (isNew, effectiveWorkspace, error).
// SQLite has no xmax trick; we use INSERT OR IGNORE + UPDATE + SELECT to detect insert vs update.
func (s *SQLiteAgentStore) GetOrCreateUserProfile(ctx context.Context, agentID uuid.UUID, userID, workspace, channel string) (bool, string, error) {
effectiveWs := config.ContractHome(workspace)
if channel != "" {
effectiveWs = filepath.Join(effectiveWs, channel)
}
tid := tenantIDForInsert(ctx)
now := time.Now()
// Try insert (ignored on conflict). Use changes() to detect if row was actually inserted.
res, err := s.db.ExecContext(ctx,
`INSERT OR IGNORE INTO user_agent_profiles (agent_id, user_id, workspace, first_seen_at, last_seen_at, tenant_id)
VALUES (?, ?, NULLIF(?, ''), ?, ?, ?)`,
agentID, userID, effectiveWs, now, now, tid,
)
if err != nil {
return false, effectiveWs, err
}
inserted, _ := res.RowsAffected()
isNew := inserted > 0
// Always bump last_seen_at for existing rows.
if !isNew {
_, err = s.db.ExecContext(ctx,
`UPDATE user_agent_profiles SET last_seen_at = ? WHERE agent_id = ? AND user_id = ?`,
now, agentID, userID,
)
if err != nil {
return false, effectiveWs, err
}
}
// Read back stored workspace.
var storedWorkspace sql.NullString
err = s.db.QueryRowContext(ctx,
`SELECT workspace FROM user_agent_profiles WHERE agent_id = ? AND user_id = ?`,
agentID, userID,
).Scan(&storedWorkspace)
if err != nil {
return false, effectiveWs, err
}
ws := effectiveWs
if storedWorkspace.Valid && storedWorkspace.String != "" {
ws = storedWorkspace.String
}
return isNew, ws, nil
}
// EnsureUserProfile creates a minimal user_agent_profiles row if not exists.
func (s *SQLiteAgentStore) EnsureUserProfile(ctx context.Context, agentID uuid.UUID, userID string) error {
now := time.Now()
_, err := s.db.ExecContext(ctx,
`INSERT OR IGNORE INTO user_agent_profiles (agent_id, user_id, first_seen_at, last_seen_at, tenant_id)
VALUES (?, ?, ?, ?, ?)`,
agentID, userID, now, now, tenantIDForInsert(ctx),
)
return err
}
// --- User Instances ---
func (s *SQLiteAgentStore) ListUserInstances(ctx context.Context, agentID uuid.UUID) ([]store.UserInstanceData, error) {
tClause, tArgs, err := scopeClauseAlias(ctx, "p")
if err != nil {
return nil, err
}
subTenantFilter := ""
if !store.IsCrossTenant(ctx) {
tid := store.TenantIDFromContext(ctx)
if tid != uuid.Nil {
subTenantFilter = " AND tenant_id = ?"
// tArgs already has tid; reuse it by appending another copy below.
}
}
// Build args: subquery agent_id [+ optional tenant_id], main WHERE agent_id, scope args.
var queryArgs []any
queryArgs = append(queryArgs, agentID) // subquery: agent_id = ?
if subTenantFilter != "" {
queryArgs = append(queryArgs, tArgs[0]) // subquery: tenant_id = ?
}
queryArgs = append(queryArgs, agentID) // main WHERE: p.agent_id = ?
queryArgs = append(queryArgs, tArgs...) // scope clause args
rows, err := s.db.QueryContext(ctx, `
SELECT p.user_id,
strftime('%Y-%m-%dT%H:%M:%SZ', p.first_seen_at) AS first_seen_at,
strftime('%Y-%m-%dT%H:%M:%SZ', p.last_seen_at) AS last_seen_at,
COALESCE(fc.cnt, 0) AS file_count,
COALESCE(p.metadata, '{}')
FROM user_agent_profiles p
LEFT JOIN (
SELECT user_id, COUNT(*) AS cnt
FROM user_context_files
WHERE agent_id = ?`+subTenantFilter+`
GROUP BY user_id
) fc ON fc.user_id = p.user_id
WHERE p.agent_id = ?`+tClause+`
ORDER BY p.last_seen_at DESC
`, queryArgs...)
if err != nil {
return nil, err
}
defer rows.Close()
var result []store.UserInstanceData
for rows.Next() {
var d store.UserInstanceData
var metaJSON []byte
if err := rows.Scan(&d.UserID, &d.FirstSeenAt, &d.LastSeenAt, &d.FileCount, &metaJSON); err != nil {
continue
}
if len(metaJSON) > 0 {
json.Unmarshal(metaJSON, &d.Metadata)
}
result = append(result, d)
}
return result, rows.Err()
}
func (s *SQLiteAgentStore) UpdateUserProfileMetadata(ctx context.Context, agentID uuid.UUID, userID string, metadata map[string]string) error {
metaJSON, err := json.Marshal(metadata)
if err != nil {
return err
}
tClause, tArgs, err := scopeClause(ctx)
if err != nil {
return err
}
_, err = s.db.ExecContext(ctx,
`UPDATE user_agent_profiles SET metadata = json_patch(COALESCE(metadata, '{}'), ?)
WHERE agent_id = ? AND user_id = ?`+tClause,
append([]any{metaJSON, agentID, userID}, tArgs...)...,
)
return err
}
// --- User Overrides ---
func (s *SQLiteAgentStore) GetUserOverride(ctx context.Context, agentID uuid.UUID, userID string) (*store.UserAgentOverrideData, error) {
tClause, tArgs, err := scopeClause(ctx)
if err != nil {
return nil, err
}
var d store.UserAgentOverrideData
err = s.db.QueryRowContext(ctx,
"SELECT agent_id, user_id, provider, model FROM user_agent_overrides WHERE agent_id = ? AND user_id = ?"+tClause,
append([]any{agentID, userID}, tArgs...)...,
).Scan(&d.AgentID, &d.UserID, &d.Provider, &d.Model)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, nil
}
return &d, nil
}
func (s *SQLiteAgentStore) SetUserOverride(ctx context.Context, override *store.UserAgentOverrideData) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO user_agent_overrides (id, agent_id, user_id, provider, model, tenant_id)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (agent_id, user_id) DO UPDATE SET provider = excluded.provider, model = excluded.model`,
store.GenNewID(), override.AgentID, override.UserID, override.Provider, override.Model, tenantIDForInsert(ctx),
)
return err
}
+224
View File
@@ -0,0 +1,224 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"database/sql"
"strings"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// SQLiteAPIKeyStore implements store.APIKeyStore backed by SQLite.
type SQLiteAPIKeyStore struct {
db *sql.DB
}
// NewSQLiteAPIKeyStore creates a new SQLite-backed API key store.
func NewSQLiteAPIKeyStore(db *sql.DB) *SQLiteAPIKeyStore {
return &SQLiteAPIKeyStore{db: db}
}
func (s *SQLiteAPIKeyStore) Create(ctx context.Context, key *store.APIKeyData) error {
var ownerID *string
if key.OwnerID != "" {
ownerID = &key.OwnerID
}
var tenantID *uuid.UUID
if key.TenantID != uuid.Nil {
tenantID = &key.TenantID
}
_, err := s.db.ExecContext(ctx,
`INSERT INTO api_keys (id, name, prefix, key_hash, scopes, owner_id, tenant_id, expires_at, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
key.ID, key.Name, key.Prefix, key.KeyHash, jsonStringArray(key.Scopes),
ownerID, tenantID, key.ExpiresAt, nilStr(key.CreatedBy), key.CreatedAt, key.UpdatedAt,
)
return err
}
func (s *SQLiteAPIKeyStore) GetByHash(ctx context.Context, keyHash string) (*store.APIKeyData, error) {
row := s.db.QueryRowContext(ctx,
`SELECT id, name, prefix, key_hash, scopes, owner_id, tenant_id, expires_at, last_used_at, revoked, created_by, created_at, updated_at
FROM api_keys
WHERE key_hash = ? AND NOT revoked AND (expires_at IS NULL OR expires_at > datetime('now'))`,
keyHash,
)
var k store.APIKeyData
var createdBy *string
var ownerID *string
var tenantID *uuid.UUID
var scopesRaw []byte
var expiresAt, lastUsedAt nullSqliteTime
createdAt, updatedAt := scanTimePair()
err := row.Scan(
&k.ID, &k.Name, &k.Prefix, &k.KeyHash, &scopesRaw,
&ownerID, &tenantID, &expiresAt, &lastUsedAt, &k.Revoked, &createdBy,
createdAt, updatedAt,
)
if err != nil {
return nil, err
}
k.CreatedAt = createdAt.Time
k.UpdatedAt = updatedAt.Time
if expiresAt.Valid {
k.ExpiresAt = &expiresAt.Time
}
if lastUsedAt.Valid {
k.LastUsedAt = &lastUsedAt.Time
}
scanJSONStringArray(scopesRaw, &k.Scopes)
if createdBy != nil {
k.CreatedBy = *createdBy
}
if ownerID != nil {
k.OwnerID = *ownerID
}
if tenantID != nil {
k.TenantID = *tenantID
}
return &k, nil
}
func (s *SQLiteAPIKeyStore) List(ctx context.Context, ownerID string) ([]store.APIKeyData, error) {
var conditions []string
var args []any
if ownerID != "" {
conditions = append(conditions, "owner_id = ?")
args = append(args, ownerID)
}
// Tenant filter: include tenant-scoped keys + system keys (NULL tenant_id).
if !store.IsCrossTenant(ctx) {
tid := store.TenantIDFromContext(ctx)
if tid != uuid.Nil {
conditions = append(conditions, "(tenant_id = ? OR tenant_id IS NULL)")
args = append(args, tid)
}
}
where := ""
if len(conditions) > 0 {
where = " WHERE " + strings.Join(conditions, " AND ")
}
rows, err := s.db.QueryContext(ctx,
`SELECT id, name, prefix, scopes, owner_id, tenant_id, expires_at, last_used_at, revoked, created_by, created_at, updated_at
FROM api_keys`+where+`
ORDER BY created_at DESC`,
args...,
)
if err != nil {
return nil, err
}
defer rows.Close()
var keys []store.APIKeyData
for rows.Next() {
var k store.APIKeyData
var createdBy *string
var oID *string
var tID *uuid.UUID
var scopesRaw []byte
var expiresAt, lastUsedAt nullSqliteTime
createdAt, updatedAt := scanTimePair()
if err := rows.Scan(
&k.ID, &k.Name, &k.Prefix, &scopesRaw,
&oID, &tID, &expiresAt, &lastUsedAt, &k.Revoked, &createdBy,
createdAt, updatedAt,
); err != nil {
return nil, err
}
k.CreatedAt = createdAt.Time
k.UpdatedAt = updatedAt.Time
if expiresAt.Valid {
k.ExpiresAt = &expiresAt.Time
}
if lastUsedAt.Valid {
k.LastUsedAt = &lastUsedAt.Time
}
scanJSONStringArray(scopesRaw, &k.Scopes)
if createdBy != nil {
k.CreatedBy = *createdBy
}
if oID != nil {
k.OwnerID = *oID
}
if tID != nil {
k.TenantID = *tID
}
keys = append(keys, k)
}
return keys, rows.Err()
}
func (s *SQLiteAPIKeyStore) Revoke(ctx context.Context, id uuid.UUID, ownerID string) error {
q := "UPDATE api_keys SET revoked = 1, updated_at = ? WHERE id = ?"
args := []any{time.Now(), id}
if ownerID != "" {
q += " AND owner_id = ?"
args = append(args, ownerID)
}
if !store.IsCrossTenant(ctx) {
tid := store.TenantIDFromContext(ctx)
if tid != uuid.Nil {
q += " AND (tenant_id = ? OR tenant_id IS NULL)"
args = append(args, tid)
}
}
res, err := s.db.ExecContext(ctx, q, args...)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return sql.ErrNoRows
}
return nil
}
func (s *SQLiteAPIKeyStore) Delete(ctx context.Context, id uuid.UUID, ownerID string) error {
q := "DELETE FROM api_keys WHERE id = ?"
args := []any{id}
if ownerID != "" {
q += " AND owner_id = ?"
args = append(args, ownerID)
}
if !store.IsCrossTenant(ctx) {
tid := store.TenantIDFromContext(ctx)
if tid != uuid.Nil {
q += " AND (tenant_id = ? OR tenant_id IS NULL)"
args = append(args, tid)
}
}
res, err := s.db.ExecContext(ctx, q, args...)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return sql.ErrNoRows
}
return nil
}
func (s *SQLiteAPIKeyStore) TouchLastUsed(ctx context.Context, id uuid.UUID) error {
_, err := s.db.ExecContext(ctx,
`UPDATE api_keys SET last_used_at = ? WHERE id = ?`,
time.Now(), id,
)
return err
}
// ensure interface is satisfied at compile time
var _ store.APIKeyStore = (*SQLiteAPIKeyStore)(nil)
+229
View File
@@ -0,0 +1,229 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// SQLiteBuiltinToolStore implements store.BuiltinToolStore backed by SQLite.
type SQLiteBuiltinToolStore struct {
db *sql.DB
}
func NewSQLiteBuiltinToolStore(db *sql.DB) *SQLiteBuiltinToolStore {
return &SQLiteBuiltinToolStore{db: db}
}
const builtinToolSelectCols = `name, display_name, description, category, enabled, settings, requires, metadata, created_at, updated_at`
func (s *SQLiteBuiltinToolStore) List(ctx context.Context) ([]store.BuiltinToolDef, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT `+builtinToolSelectCols+` FROM builtin_tools ORDER BY category, name`)
if err != nil {
return nil, err
}
return s.scanTools(rows)
}
func (s *SQLiteBuiltinToolStore) ListEnabled(ctx context.Context) ([]store.BuiltinToolDef, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT `+builtinToolSelectCols+` FROM builtin_tools WHERE enabled = 1 ORDER BY category, name`)
if err != nil {
return nil, err
}
return s.scanTools(rows)
}
func (s *SQLiteBuiltinToolStore) Get(ctx context.Context, name string) (*store.BuiltinToolDef, error) {
row := s.db.QueryRowContext(ctx,
`SELECT `+builtinToolSelectCols+` FROM builtin_tools WHERE name = ?`, name)
return s.scanTool(row)
}
func (s *SQLiteBuiltinToolStore) GetSettings(ctx context.Context, name string) (json.RawMessage, error) {
var settings json.RawMessage
err := s.db.QueryRowContext(ctx,
`SELECT settings FROM builtin_tools WHERE name = ?`, name).Scan(&settings)
if err != nil {
return nil, err
}
return settings, nil
}
func (s *SQLiteBuiltinToolStore) Update(ctx context.Context, name string, updates map[string]any) error {
allowed := make(map[string]any)
if v, ok := updates["enabled"]; ok {
allowed["enabled"] = v
}
if v, ok := updates["settings"]; ok {
switch sv := v.(type) {
case json.RawMessage:
allowed["settings"] = []byte(sv)
case map[string]any:
b, err := json.Marshal(sv)
if err != nil {
return fmt.Errorf("marshal settings: %w", err)
}
allowed["settings"] = b
case []byte:
allowed["settings"] = sv
case string:
allowed["settings"] = []byte(sv)
}
}
if len(allowed) == 0 {
return nil
}
allowed["updated_at"] = time.Now()
var setClauses []string
var args []any
for col, val := range allowed {
setClauses = append(setClauses, col+" = ?")
args = append(args, val)
}
args = append(args, name)
q := fmt.Sprintf("UPDATE builtin_tools SET %s WHERE name = ?", strings.Join(setClauses, ", "))
_, err := s.db.ExecContext(ctx, q, args...)
return err
}
// Seed inserts or updates builtin tool definitions.
// Preserves user-customized enabled and settings values across upgrades.
func (s *SQLiteBuiltinToolStore) Seed(ctx context.Context, tools []store.BuiltinToolDef) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
stmt, err := tx.PrepareContext(ctx,
`INSERT INTO builtin_tools (name, display_name, description, category, enabled, settings, requires, metadata, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (name) DO UPDATE SET
display_name = excluded.display_name,
description = excluded.description,
category = excluded.category,
requires = excluded.requires,
metadata = excluded.metadata,
settings = CASE
WHEN builtin_tools.settings IS NULL OR builtin_tools.settings IN ('{}', 'null')
THEN excluded.settings
ELSE builtin_tools.settings
END,
updated_at = excluded.updated_at`)
if err != nil {
return fmt.Errorf("prepare seed stmt: %w", err)
}
defer stmt.Close()
now := time.Now()
names := make([]string, 0, len(tools))
for _, t := range tools {
settings := t.Settings
if settings == nil {
settings = json.RawMessage("{}")
}
metadata := t.Metadata
if metadata == nil {
metadata = json.RawMessage("{}")
}
_, err := stmt.ExecContext(ctx,
t.Name, t.DisplayName, t.Description, t.Category,
t.Enabled, []byte(settings), jsonStringArray(t.Requires), []byte(metadata), now, now,
)
if err != nil {
return fmt.Errorf("seed tool %s: %w", t.Name, err)
}
names = append(names, t.Name)
}
// Reconcile: remove stale entries not in the current seed list.
// SQLite has no ANY($1) — build NOT IN (?, ?, ...) instead.
if len(names) > 0 {
placeholders := strings.Repeat("?,", len(names))
placeholders = placeholders[:len(placeholders)-1]
delArgs := make([]any, len(names))
for i, n := range names {
delArgs[i] = n
}
if _, err := tx.ExecContext(ctx,
`DELETE FROM builtin_tools WHERE name NOT IN (`+placeholders+`)`, delArgs...); err != nil {
return fmt.Errorf("reconcile stale builtin tools: %w", err)
}
} else {
if _, err := tx.ExecContext(ctx, `DELETE FROM builtin_tools`); err != nil {
return fmt.Errorf("reconcile stale builtin tools: %w", err)
}
}
return tx.Commit()
}
func (s *SQLiteBuiltinToolStore) scanTool(row *sql.Row) (*store.BuiltinToolDef, error) {
var def store.BuiltinToolDef
var settings []byte
var requires []byte
var metadata []byte
createdAt, updatedAt := scanTimePair()
err := row.Scan(
&def.Name, &def.DisplayName, &def.Description, &def.Category,
&def.Enabled, &settings, &requires, &metadata, createdAt, updatedAt,
)
if err != nil {
return nil, err
}
def.CreatedAt = createdAt.Time
def.UpdatedAt = updatedAt.Time
if settings != nil {
def.Settings = json.RawMessage(settings)
}
if metadata != nil {
def.Metadata = json.RawMessage(metadata)
}
scanJSONStringArray(requires, &def.Requires)
return &def, nil
}
func (s *SQLiteBuiltinToolStore) scanTools(rows *sql.Rows) ([]store.BuiltinToolDef, error) {
defer rows.Close()
var result []store.BuiltinToolDef
for rows.Next() {
var def store.BuiltinToolDef
var settings []byte
var requires []byte
var metadata []byte
createdAt, updatedAt := scanTimePair()
if err := rows.Scan(
&def.Name, &def.DisplayName, &def.Description, &def.Category,
&def.Enabled, &settings, &requires, &metadata, createdAt, updatedAt,
); err != nil {
continue
}
def.CreatedAt = createdAt.Time
def.UpdatedAt = updatedAt.Time
if settings != nil {
def.Settings = json.RawMessage(settings)
}
if metadata != nil {
def.Metadata = json.RawMessage(metadata)
}
scanJSONStringArray(requires, &def.Requires)
result = append(result, def)
}
return result, rows.Err()
}
@@ -0,0 +1,365 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"maps"
"strings"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/crypto"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// SQLiteChannelInstanceStore implements store.ChannelInstanceStore backed by SQLite.
type SQLiteChannelInstanceStore struct {
db *sql.DB
encKey string
}
func NewSQLiteChannelInstanceStore(db *sql.DB, encryptionKey string) *SQLiteChannelInstanceStore {
return &SQLiteChannelInstanceStore{db: db, encKey: encryptionKey}
}
const channelInstanceSelectCols = `id, name, display_name, channel_type, agent_id,
credentials, config, enabled, created_by, created_at, updated_at, tenant_id`
func (s *SQLiteChannelInstanceStore) Create(ctx context.Context, inst *store.ChannelInstanceData) error {
if err := store.ValidateUserID(inst.CreatedBy); err != nil {
return err
}
if inst.ID == uuid.Nil {
inst.ID = store.GenNewID()
}
var credsBytes []byte
if len(inst.Credentials) > 0 && s.encKey != "" {
encrypted, err := crypto.Encrypt(string(inst.Credentials), s.encKey)
if err != nil {
return fmt.Errorf("encrypt credentials: %w", err)
}
credsBytes = []byte(encrypted)
} else {
credsBytes = inst.Credentials
}
now := time.Now()
inst.CreatedAt = now
inst.UpdatedAt = now
tid := tenantIDForInsert(ctx)
_, err := s.db.ExecContext(ctx,
`INSERT INTO channel_instances (id, name, display_name, channel_type, agent_id,
credentials, config, enabled, created_by, created_at, updated_at, tenant_id)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
inst.ID, inst.Name, inst.DisplayName, inst.ChannelType, inst.AgentID,
credsBytes, jsonOrEmpty(inst.Config),
inst.Enabled, inst.CreatedBy, now, now, tid,
)
return err
}
func (s *SQLiteChannelInstanceStore) Get(ctx context.Context, id uuid.UUID) (*store.ChannelInstanceData, error) {
if store.IsCrossTenant(ctx) {
row := s.db.QueryRowContext(ctx,
`SELECT `+channelInstanceSelectCols+` FROM channel_instances WHERE id = ?`, id)
return s.scanInstance(row)
}
tClause, tArgs, err := scopeClause(ctx)
if err != nil {
return nil, err
}
args := append([]any{id}, tArgs...)
row := s.db.QueryRowContext(ctx,
`SELECT `+channelInstanceSelectCols+` FROM channel_instances WHERE id = ?`+tClause, args...)
return s.scanInstance(row)
}
func (s *SQLiteChannelInstanceStore) GetByName(ctx context.Context, name string) (*store.ChannelInstanceData, error) {
if store.IsCrossTenant(ctx) {
row := s.db.QueryRowContext(ctx,
`SELECT `+channelInstanceSelectCols+` FROM channel_instances WHERE name = ?`, name)
return s.scanInstance(row)
}
tClause, tArgs, err := scopeClause(ctx)
if err != nil {
return nil, err
}
args := append([]any{name}, tArgs...)
row := s.db.QueryRowContext(ctx,
`SELECT `+channelInstanceSelectCols+` FROM channel_instances WHERE name = ?`+tClause, args...)
return s.scanInstance(row)
}
func (s *SQLiteChannelInstanceStore) scanInstance(row *sql.Row) (*store.ChannelInstanceData, error) {
var inst store.ChannelInstanceData
var displayName *string
var creds []byte
var config *[]byte
createdAt, updatedAt := scanTimePair()
err := row.Scan(
&inst.ID, &inst.Name, &displayName, &inst.ChannelType, &inst.AgentID,
&creds, &config,
&inst.Enabled, &inst.CreatedBy, createdAt, updatedAt, &inst.TenantID,
)
if err != nil {
return nil, err
}
inst.CreatedAt = createdAt.Time
inst.UpdatedAt = updatedAt.Time
inst.DisplayName = derefStr(displayName)
if config != nil {
inst.Config = *config
}
if len(creds) > 0 && s.encKey != "" {
decrypted, err := crypto.Decrypt(string(creds), s.encKey)
if err != nil {
slog.Warn("channel_instances: failed to decrypt credentials", "name", inst.Name, "error", err)
} else {
inst.Credentials = []byte(decrypted)
}
} else {
inst.Credentials = creds
}
return &inst, nil
}
func (s *SQLiteChannelInstanceStore) scanInstances(rows *sql.Rows) ([]store.ChannelInstanceData, error) {
defer rows.Close()
var result []store.ChannelInstanceData
for rows.Next() {
var inst store.ChannelInstanceData
var displayName *string
var creds []byte
var config *[]byte
createdAt, updatedAt := scanTimePair()
if err := rows.Scan(
&inst.ID, &inst.Name, &displayName, &inst.ChannelType, &inst.AgentID,
&creds, &config,
&inst.Enabled, &inst.CreatedBy, createdAt, updatedAt, &inst.TenantID,
); err != nil {
continue
}
inst.CreatedAt = createdAt.Time
inst.UpdatedAt = updatedAt.Time
inst.DisplayName = derefStr(displayName)
if config != nil {
inst.Config = *config
}
if len(creds) > 0 && s.encKey != "" {
if decrypted, err := crypto.Decrypt(string(creds), s.encKey); err == nil {
inst.Credentials = []byte(decrypted)
}
} else {
inst.Credentials = creds
}
result = append(result, inst)
}
return result, rows.Err()
}
func (s *SQLiteChannelInstanceStore) Update(ctx context.Context, id uuid.UUID, updates map[string]any) error {
if credsVal, ok := updates["credentials"]; ok && credsVal != nil {
var newCreds map[string]any
switch v := credsVal.(type) {
case map[string]any:
newCreds = v
default:
var raw []byte
switch vv := v.(type) {
case []byte:
raw = vv
case string:
raw = []byte(vv)
default:
if b, err := json.Marshal(v); err == nil {
raw = b
}
}
if len(raw) > 0 {
_ = json.Unmarshal(raw, &newCreds)
}
}
if len(newCreds) > 0 {
existing, err := s.loadExistingCreds(ctx, id)
if err != nil {
return fmt.Errorf("load existing credentials for merge: %w", err)
}
maps.Copy(existing, newCreds)
newCreds = existing
}
var credsBytes []byte
if len(newCreds) > 0 {
credsBytes, _ = json.Marshal(newCreds)
}
if len(credsBytes) > 0 && s.encKey != "" {
encrypted, err := crypto.Encrypt(string(credsBytes), s.encKey)
if err != nil {
return fmt.Errorf("encrypt credentials: %w", err)
}
credsBytes = []byte(encrypted)
}
updates["credentials"] = credsBytes
}
updates["updated_at"] = time.Now()
if store.IsCrossTenant(ctx) {
return execMapUpdate(ctx, s.db, "channel_instances", id, updates)
}
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
return fmt.Errorf("tenant_id required for update")
}
return execMapUpdateWhereTenant(ctx, s.db, "channel_instances", updates, id, tid)
}
func (s *SQLiteChannelInstanceStore) loadExistingCreds(ctx context.Context, id uuid.UUID) (map[string]any, error) {
var raw []byte
err := s.db.QueryRowContext(ctx, "SELECT credentials FROM channel_instances WHERE id = ?", id).Scan(&raw)
if err != nil || len(raw) == 0 {
return make(map[string]any), nil
}
if s.encKey != "" {
if dec, err := crypto.Decrypt(string(raw), s.encKey); err == nil {
raw = []byte(dec)
}
}
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
return make(map[string]any), nil
}
return m, nil
}
func (s *SQLiteChannelInstanceStore) Delete(ctx context.Context, id uuid.UUID) error {
if store.IsCrossTenant(ctx) {
_, err := s.db.ExecContext(ctx, "DELETE FROM channel_instances WHERE id = ?", id)
return err
}
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
return fmt.Errorf("tenant_id required")
}
_, err := s.db.ExecContext(ctx, "DELETE FROM channel_instances WHERE id = ? AND tenant_id = ?", id, tid)
return err
}
func (s *SQLiteChannelInstanceStore) ListEnabled(ctx context.Context) ([]store.ChannelInstanceData, error) {
query := `SELECT ` + channelInstanceSelectCols + ` FROM channel_instances WHERE enabled = 1`
var qArgs []any
if !store.IsCrossTenant(ctx) {
tClause, tArgs, err := scopeClause(ctx)
if err != nil {
return nil, err
}
query += tClause
qArgs = tArgs
}
query += ` ORDER BY name`
rows, err := s.db.QueryContext(ctx, query, qArgs...)
if err != nil {
return nil, err
}
return s.scanInstances(rows)
}
func (s *SQLiteChannelInstanceStore) ListAll(ctx context.Context) ([]store.ChannelInstanceData, error) {
query := `SELECT ` + channelInstanceSelectCols + ` FROM channel_instances WHERE 1=1`
var qArgs []any
if !store.IsCrossTenant(ctx) {
tClause, tArgs, err := scopeClause(ctx)
if err != nil {
return nil, err
}
query += tClause
qArgs = tArgs
}
query += ` ORDER BY name`
rows, err := s.db.QueryContext(ctx, query, qArgs...)
if err != nil {
return nil, err
}
return s.scanInstances(rows)
}
func (s *SQLiteChannelInstanceStore) ListAllInstances(ctx context.Context) ([]store.ChannelInstanceData, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT `+channelInstanceSelectCols+` FROM channel_instances ORDER BY name`)
if err != nil {
return nil, err
}
return s.scanInstances(rows)
}
func (s *SQLiteChannelInstanceStore) ListAllEnabled(ctx context.Context) ([]store.ChannelInstanceData, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT `+channelInstanceSelectCols+` FROM channel_instances WHERE enabled = 1 ORDER BY name`)
if err != nil {
return nil, err
}
return s.scanInstances(rows)
}
func buildChannelInstanceWhereSQLite(ctx context.Context, opts store.ChannelInstanceListOpts) (string, []any) {
var conditions []string
var args []any
if !store.IsCrossTenant(ctx) {
tenantID := store.TenantIDFromContext(ctx)
if tenantID != uuid.Nil {
conditions = append(conditions, "tenant_id = ?")
args = append(args, tenantID)
}
}
if opts.Search != "" {
escaped := strings.NewReplacer("%", "\\%", "_", "\\_").Replace(opts.Search)
conditions = append(conditions, "(name LIKE ? ESCAPE '\\' OR display_name LIKE ? ESCAPE '\\' OR channel_type LIKE ? ESCAPE '\\')")
p := "%" + escaped + "%"
args = append(args, p, p, p)
}
where := ""
if len(conditions) > 0 {
where = " WHERE " + strings.Join(conditions, " AND ")
}
return where, args
}
func (s *SQLiteChannelInstanceStore) ListPaged(ctx context.Context, opts store.ChannelInstanceListOpts) ([]store.ChannelInstanceData, error) {
where, args := buildChannelInstanceWhereSQLite(ctx, opts)
limit := opts.Limit
if limit <= 0 {
limit = 50
}
q := `SELECT ` + channelInstanceSelectCols + ` FROM channel_instances` + where +
fmt.Sprintf(" ORDER BY name LIMIT %d OFFSET %d", limit, opts.Offset)
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
return s.scanInstances(rows)
}
func (s *SQLiteChannelInstanceStore) CountInstances(ctx context.Context, opts store.ChannelInstanceListOpts) (int, error) {
where, args := buildChannelInstanceWhereSQLite(ctx, opts)
var count int
err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM channel_instances"+where, args...).Scan(&count)
return count, err
}
@@ -0,0 +1,101 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"database/sql"
"fmt"
"log/slog"
"time"
"github.com/nextlevelbuilder/goclaw/internal/crypto"
)
// SQLiteConfigSecretsStore implements store.ConfigSecretsStore backed by SQLite.
type SQLiteConfigSecretsStore struct {
db *sql.DB
encKey string
}
func NewSQLiteConfigSecretsStore(db *sql.DB, encryptionKey string) *SQLiteConfigSecretsStore {
return &SQLiteConfigSecretsStore{db: db, encKey: encryptionKey}
}
func (s *SQLiteConfigSecretsStore) Get(ctx context.Context, key string) (string, error) {
tid := tenantIDForInsert(ctx)
var value []byte
err := s.db.QueryRowContext(ctx,
`SELECT value FROM config_secrets WHERE key = ? AND tenant_id = ?`, key, tid).Scan(&value)
if err != nil {
return "", err
}
if len(value) > 0 && s.encKey != "" {
decrypted, err := crypto.Decrypt(string(value), s.encKey)
if err != nil {
return "", fmt.Errorf("decrypt secret %q: %w", key, err)
}
return decrypted, nil
}
return string(value), nil
}
func (s *SQLiteConfigSecretsStore) Set(ctx context.Context, key, value string) error {
var stored []byte
if s.encKey != "" {
encrypted, err := crypto.Encrypt(value, s.encKey)
if err != nil {
return fmt.Errorf("encrypt secret %q: %w", key, err)
}
stored = []byte(encrypted)
} else {
stored = []byte(value)
}
tid := tenantIDForInsert(ctx)
_, err := s.db.ExecContext(ctx,
`INSERT INTO config_secrets (key, value, updated_at, tenant_id) VALUES (?, ?, ?, ?)
ON CONFLICT (key, tenant_id) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
key, stored, time.Now(), tid,
)
return err
}
func (s *SQLiteConfigSecretsStore) Delete(ctx context.Context, key string) error {
tid := tenantIDForInsert(ctx)
_, err := s.db.ExecContext(ctx,
`DELETE FROM config_secrets WHERE key = ? AND tenant_id = ?`, key, tid)
return err
}
func (s *SQLiteConfigSecretsStore) GetAll(ctx context.Context) (map[string]string, error) {
tid := tenantIDForInsert(ctx)
rows, err := s.db.QueryContext(ctx,
`SELECT key, value FROM config_secrets WHERE tenant_id = ?`, tid)
if err != nil {
return nil, err
}
defer rows.Close()
result := make(map[string]string)
for rows.Next() {
var key string
var value []byte
if err := rows.Scan(&key, &value); err != nil {
continue
}
if len(value) > 0 && s.encKey != "" {
decrypted, err := crypto.Decrypt(string(value), s.encKey)
if err != nil {
slog.Warn("config_secrets: failed to decrypt", "key", key, "error", err)
continue
}
result[key] = decrypted
} else {
result[key] = string(value)
}
}
return result, rows.Err()
}
@@ -0,0 +1,279 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"database/sql"
"encoding/json"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
const permCacheTTL = 60 * time.Second
type permRow struct {
Scope string
ConfigType string
Permission string
}
type permCacheEntry struct {
rows []permRow
fetched time.Time
}
type fwCacheEntry struct {
rows []store.ConfigPermission
fetched time.Time
}
// SQLiteConfigPermissionStore implements store.ConfigPermissionStore backed by SQLite.
// Includes a TTL cache for CheckPermission to avoid per-request DB queries.
type SQLiteConfigPermissionStore struct {
db *sql.DB
mu sync.RWMutex
cache map[string]permCacheEntry // key: "tenantID:agentID:userID"
fwMu sync.RWMutex
fwCache map[string]fwCacheEntry // key: "tenantID:agentID:scope"
}
func NewSQLiteConfigPermissionStore(db *sql.DB) *SQLiteConfigPermissionStore {
return &SQLiteConfigPermissionStore{
db: db,
cache: make(map[string]permCacheEntry),
fwCache: make(map[string]fwCacheEntry),
}
}
// InvalidateCache clears all cached permission entries.
func (s *SQLiteConfigPermissionStore) InvalidateCache() {
s.mu.Lock()
s.cache = make(map[string]permCacheEntry)
s.mu.Unlock()
s.fwMu.Lock()
s.fwCache = make(map[string]fwCacheEntry)
s.fwMu.Unlock()
}
func (s *SQLiteConfigPermissionStore) CheckPermission(ctx context.Context, agentID uuid.UUID, scope, configType, userID string) (bool, error) {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
tid = store.MasterTenantID
}
cacheKey := tid.String() + ":" + agentID.String() + ":" + userID
s.mu.RLock()
if entry, ok := s.cache[cacheKey]; ok && time.Since(entry.fetched) < permCacheTTL {
s.mu.RUnlock()
return evalPermRows(entry.rows, scope, configType), nil
}
s.mu.RUnlock()
tClause, tArgs, err := scopeClause(ctx)
if err != nil {
return false, err
}
rows, err := s.db.QueryContext(ctx,
`SELECT scope, config_type, permission FROM agent_config_permissions
WHERE agent_id = ? AND user_id = ?`+tClause,
append([]any{agentID, userID}, tArgs...)...,
)
if err != nil {
return false, err
}
defer rows.Close()
var permRows []permRow
for rows.Next() {
var r permRow
if err := rows.Scan(&r.Scope, &r.ConfigType, &r.Permission); err != nil {
return false, err
}
permRows = append(permRows, r)
}
if err := rows.Err(); err != nil {
return false, err
}
s.mu.Lock()
s.cache[cacheKey] = permCacheEntry{rows: permRows, fetched: time.Now()}
s.mu.Unlock()
return evalPermRows(permRows, scope, configType), nil
}
func (s *SQLiteConfigPermissionStore) Grant(ctx context.Context, perm *store.ConfigPermission) error {
meta := perm.Metadata
if meta == nil {
meta = json.RawMessage("{}")
}
now := time.Now()
_, err := s.db.ExecContext(ctx,
`INSERT INTO agent_config_permissions (agent_id, scope, config_type, user_id, permission, granted_by, metadata, created_at, updated_at, tenant_id)
VALUES (?,?,?,?,?,?,?,?,?,?)
ON CONFLICT (agent_id, scope, config_type, user_id) DO UPDATE SET
permission = excluded.permission,
granted_by = excluded.granted_by,
metadata = excluded.metadata,
updated_at = excluded.updated_at`,
perm.AgentID, perm.Scope, perm.ConfigType, perm.UserID, perm.Permission, perm.GrantedBy, meta, now, now, tenantIDForInsert(ctx),
)
if err == nil {
s.InvalidateCache()
}
return err
}
func (s *SQLiteConfigPermissionStore) Revoke(ctx context.Context, agentID uuid.UUID, scope, configType, userID string) error {
tClause, tArgs, err := scopeClause(ctx)
if err != nil {
return err
}
_, err = s.db.ExecContext(ctx,
`DELETE FROM agent_config_permissions WHERE agent_id = ? AND scope = ? AND config_type = ? AND user_id = ?`+tClause,
append([]any{agentID, scope, configType, userID}, tArgs...)...,
)
if err == nil {
s.InvalidateCache()
}
return err
}
func (s *SQLiteConfigPermissionStore) List(ctx context.Context, agentID uuid.UUID, configType, scope string) ([]store.ConfigPermission, error) {
query := `SELECT id, agent_id, scope, config_type, user_id, permission, granted_by, metadata, created_at, updated_at
FROM agent_config_permissions WHERE agent_id = ?`
args := []any{agentID}
if configType != "" {
query += " AND config_type = ?"
args = append(args, configType)
}
if scope != "" {
query += " AND scope = ?"
args = append(args, scope)
}
tClause, tArgs, err := scopeClause(ctx)
if err != nil {
return nil, err
}
if tClause != "" {
query += tClause
args = append(args, tArgs...)
}
query += " ORDER BY created_at"
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
return scanConfigPermissions(rows)
}
func (s *SQLiteConfigPermissionStore) ListFileWriters(ctx context.Context, agentID uuid.UUID, scope string) ([]store.ConfigPermission, error) {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
tid = store.MasterTenantID
}
cacheKey := tid.String() + ":" + agentID.String() + ":" + scope
s.fwMu.RLock()
if entry, ok := s.fwCache[cacheKey]; ok && time.Since(entry.fetched) < permCacheTTL {
s.fwMu.RUnlock()
return entry.rows, nil
}
s.fwMu.RUnlock()
tClause, tArgs, err := scopeClause(ctx)
if err != nil {
return nil, err
}
rows, err := s.db.QueryContext(ctx,
`SELECT id, agent_id, scope, config_type, user_id, permission, granted_by, metadata, created_at, updated_at
FROM agent_config_permissions
WHERE agent_id = ? AND config_type = 'file_writer' AND scope = ? AND permission = 'allow'`+tClause+`
ORDER BY created_at`,
append([]any{agentID, scope}, tArgs...)...,
)
if err != nil {
return nil, err
}
defer rows.Close()
perms, err := scanConfigPermissions(rows)
if err != nil {
return nil, err
}
s.fwMu.Lock()
s.fwCache[cacheKey] = fwCacheEntry{rows: perms, fetched: time.Now()}
s.fwMu.Unlock()
return perms, nil
}
func scanConfigPermissions(rows *sql.Rows) ([]store.ConfigPermission, error) {
var perms []store.ConfigPermission
for rows.Next() {
var p store.ConfigPermission
var metadata []byte
createdAt, updatedAt := scanTimePair()
if err := rows.Scan(
&p.ID, &p.AgentID, &p.Scope, &p.ConfigType, &p.UserID, &p.Permission, &p.GrantedBy, &metadata, createdAt, updatedAt,
); err != nil {
return nil, err
}
p.CreatedAt = createdAt.Time
p.UpdatedAt = updatedAt.Time
p.Metadata = metadata
perms = append(perms, p)
}
if err := rows.Err(); err != nil {
return nil, err
}
return perms, nil
}
// evalPermRows evaluates cached permission rows against scope and configType (deny-first).
func evalPermRows(rows []permRow, scope, configType string) bool {
var hasDeny, hasAllow bool
for _, r := range rows {
if !matchWildcard(r.Scope, scope) || !matchWildcard(r.ConfigType, configType) {
continue
}
switch r.Permission {
case "deny":
hasDeny = true
case "allow":
hasAllow = true
}
}
if hasDeny {
return false
}
return hasAllow
}
// matchWildcard performs simple wildcard matching for scope/config_type.
func matchWildcard(pattern, value string) bool {
if pattern == "*" {
return true
}
if strings.HasSuffix(pattern, ":*") {
prefix := strings.TrimSuffix(pattern, "*")
return strings.HasPrefix(value, prefix) || value == strings.TrimSuffix(prefix, ":")
}
return pattern == value
}
// ensure interface is satisfied at compile time
var _ store.ConfigPermissionStore = (*SQLiteConfigPermissionStore)(nil)
+255
View File
@@ -0,0 +1,255 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"database/sql"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// SQLiteContactStore implements store.ContactStore backed by SQLite.
type SQLiteContactStore struct {
db *sql.DB
}
func NewSQLiteContactStore(db *sql.DB) *SQLiteContactStore {
return &SQLiteContactStore{db: db}
}
func (s *SQLiteContactStore) UpsertContact(ctx context.Context, channelType, channelInstance, senderID, userID, displayName, username, peerKind string) error {
tenantID := store.TenantIDFromContext(ctx)
if tenantID == uuid.Nil {
tenantID = store.MasterTenantID
}
// SQLite UPSERT: ON CONFLICT ... DO UPDATE SET
// NULLIF emulated with CASE WHEN
_, err := s.db.ExecContext(ctx, `
INSERT INTO channel_contacts (channel_type, channel_instance, sender_id, user_id, display_name, username, peer_kind, tenant_id)
VALUES (?, NULLIF(?,?), ?, NULLIF(?,?), NULLIF(?,?), NULLIF(?,?), NULLIF(?,?), ?)
ON CONFLICT (tenant_id, channel_type, sender_id) DO UPDATE SET
display_name = COALESCE(NULLIF(excluded.display_name,''), channel_contacts.display_name),
username = COALESCE(NULLIF(excluded.username,''), channel_contacts.username),
user_id = COALESCE(NULLIF(excluded.user_id,''), channel_contacts.user_id),
channel_instance = COALESCE(NULLIF(excluded.channel_instance,''), channel_contacts.channel_instance),
peer_kind = COALESCE(NULLIF(excluded.peer_kind,''), channel_contacts.peer_kind),
last_seen_at = CURRENT_TIMESTAMP`,
channelType,
channelInstance, "",
senderID,
userID, "",
displayName, "",
username, "",
peerKind, "",
tenantID,
)
return err
}
func contactWhereSQLite(ctx context.Context, opts store.ContactListOpts) (string, []any) {
var conditions []string
var args []any
if !store.IsCrossTenant(ctx) {
tenantID := store.TenantIDFromContext(ctx)
if tenantID != uuid.Nil {
conditions = append(conditions, "tenant_id = ?")
args = append(args, tenantID)
}
}
if opts.ChannelType != "" {
conditions = append(conditions, "channel_type = ?")
args = append(args, opts.ChannelType)
}
if opts.PeerKind != "" {
conditions = append(conditions, "peer_kind = ?")
args = append(args, opts.PeerKind)
}
if opts.Search != "" {
escaped := strings.NewReplacer("%", "\\%", "_", "\\_").Replace(opts.Search)
pattern := escaped + "%"
conditions = append(conditions, "(display_name LIKE ? ESCAPE '\\' OR username LIKE ? ESCAPE '\\' OR sender_id LIKE ? ESCAPE '\\')")
args = append(args, pattern, pattern, pattern)
}
where := ""
if len(conditions) > 0 {
where = " WHERE " + strings.Join(conditions, " AND ")
}
return where, args
}
const contactSelectCols = `id, channel_type, channel_instance, sender_id, user_id,
display_name, username, avatar_url, peer_kind, merged_id,
first_seen_at, last_seen_at`
func scanContact(rows *sql.Rows) (store.ChannelContact, error) {
var c store.ChannelContact
err := rows.Scan(
&c.ID, &c.ChannelType, &c.ChannelInstance, &c.SenderID, &c.UserID,
&c.DisplayName, &c.Username, &c.AvatarURL, &c.PeerKind, &c.MergedID,
&c.FirstSeenAt, &c.LastSeenAt,
)
return c, err
}
func (s *SQLiteContactStore) ListContacts(ctx context.Context, opts store.ContactListOpts) ([]store.ChannelContact, error) {
where, args := contactWhereSQLite(ctx, opts)
limit := opts.Limit
if limit <= 0 {
limit = 50
}
query := `SELECT ` + contactSelectCols + ` FROM channel_contacts` + where +
fmt.Sprintf(" ORDER BY last_seen_at DESC LIMIT %d", limit)
if opts.Offset > 0 {
query += fmt.Sprintf(" OFFSET %d", opts.Offset)
}
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var contacts []store.ChannelContact
for rows.Next() {
c, err := scanContact(rows)
if err != nil {
return nil, err
}
contacts = append(contacts, c)
}
return contacts, rows.Err()
}
func (s *SQLiteContactStore) CountContacts(ctx context.Context, opts store.ContactListOpts) (int, error) {
where, args := contactWhereSQLite(ctx, opts)
var count int
err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM channel_contacts"+where, args...).Scan(&count)
return count, err
}
func (s *SQLiteContactStore) GetContactsBySenderIDs(ctx context.Context, senderIDs []string) (map[string]store.ChannelContact, error) {
if len(senderIDs) == 0 {
return map[string]store.ChannelContact{}, nil
}
placeholders := make([]string, len(senderIDs))
args := make([]any, len(senderIDs))
for i, id := range senderIDs {
placeholders[i] = "?"
args[i] = id
}
// SQLite has no DISTINCT ON; emulate with GROUP BY + MAX rowid trick via subquery
query := `SELECT id, channel_type, channel_instance, sender_id, user_id,
display_name, username, avatar_url, peer_kind, merged_id,
first_seen_at, last_seen_at
FROM channel_contacts
WHERE sender_id IN (` + strings.Join(placeholders, ",") + `)
GROUP BY sender_id
ORDER BY sender_id, last_seen_at DESC`
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
result := make(map[string]store.ChannelContact, len(senderIDs))
for rows.Next() {
c, err := scanContact(rows)
if err != nil {
return nil, err
}
result[c.SenderID] = c
}
return result, rows.Err()
}
func (s *SQLiteContactStore) GetContactByID(ctx context.Context, id uuid.UUID) (*store.ChannelContact, error) {
tid := store.TenantIDFromContext(ctx)
row := s.db.QueryRowContext(ctx,
`SELECT `+contactSelectCols+`
FROM channel_contacts WHERE id = ? AND tenant_id = ?`, id, tid)
var c store.ChannelContact
if err := row.Scan(
&c.ID, &c.ChannelType, &c.ChannelInstance, &c.SenderID, &c.UserID,
&c.DisplayName, &c.Username, &c.AvatarURL, &c.PeerKind, &c.MergedID,
&c.FirstSeenAt, &c.LastSeenAt,
); err != nil {
return nil, err
}
return &c, nil
}
func (s *SQLiteContactStore) MergeContacts(ctx context.Context, contactIDs []uuid.UUID, tenantUserID uuid.UUID) error {
if len(contactIDs) == 0 {
return nil
}
tid := store.TenantIDFromContext(ctx)
placeholders := make([]string, len(contactIDs))
args := make([]any, len(contactIDs))
for i, id := range contactIDs {
placeholders[i] = "?"
args[i] = id
}
args = append(args, tenantUserID, tid)
q := fmt.Sprintf(
"UPDATE channel_contacts SET merged_id = ? WHERE id IN (%s) AND tenant_id = ?",
strings.Join(placeholders, ","),
)
_, err := s.db.ExecContext(ctx, q, args...)
return err
}
func (s *SQLiteContactStore) UnmergeContacts(ctx context.Context, contactIDs []uuid.UUID) error {
if len(contactIDs) == 0 {
return nil
}
tid := store.TenantIDFromContext(ctx)
placeholders := make([]string, len(contactIDs))
args := make([]any, len(contactIDs))
for i, id := range contactIDs {
placeholders[i] = "?"
args[i] = id
}
args = append(args, tid)
q := fmt.Sprintf(
"UPDATE channel_contacts SET merged_id = NULL WHERE id IN (%s) AND tenant_id = ?",
strings.Join(placeholders, ","),
)
_, err := s.db.ExecContext(ctx, q, args...)
return err
}
func (s *SQLiteContactStore) GetContactsByMergedID(ctx context.Context, mergedID uuid.UUID) ([]store.ChannelContact, error) {
tid := store.TenantIDFromContext(ctx)
rows, err := s.db.QueryContext(ctx,
`SELECT `+contactSelectCols+`
FROM channel_contacts WHERE merged_id = ? AND tenant_id = ?
ORDER BY last_seen_at DESC`, mergedID, tid)
if err != nil {
return nil, err
}
defer rows.Close()
var contacts []store.ChannelContact
for rows.Next() {
c, err := scanContact(rows)
if err != nil {
return nil, err
}
contacts = append(contacts, c)
}
return contacts, rows.Err()
}
+249
View File
@@ -0,0 +1,249 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"sync"
"time"
"github.com/adhocore/gronx"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/cron"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
const defaultCronCacheTTL = 2 * time.Minute
// SQLiteCronStore implements store.CronStore backed by SQLite.
type SQLiteCronStore struct {
db *sql.DB
mu sync.Mutex
baseCtx context.Context
cancelCtx context.CancelFunc
onJob func(job *store.CronJob) (*store.CronJobResult, error)
onEvent func(event store.CronEvent)
running bool
stop chan struct{}
jobCache []store.CronJob
cacheLoaded bool
cacheTime time.Time
cacheTTL time.Duration
retryCfg cron.RetryConfig
defaultTZ string
}
func NewSQLiteCronStore(db *sql.DB) *SQLiteCronStore {
return &SQLiteCronStore{db: db, cacheTTL: defaultCronCacheTTL, retryCfg: cron.DefaultRetryConfig()}
}
func (s *SQLiteCronStore) SetRetryConfig(cfg cron.RetryConfig) {
s.mu.Lock()
defer s.mu.Unlock()
s.retryCfg = cfg
}
func (s *SQLiteCronStore) SetDefaultTimezone(tz string) {
s.mu.Lock()
defer s.mu.Unlock()
s.defaultTZ = tz
}
func (s *SQLiteCronStore) Start() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.running {
return nil
}
s.baseCtx, s.cancelCtx = context.WithCancel(context.Background())
s.stop = make(chan struct{})
s.running = true
s.recomputeStaleJobs()
go s.runLoop()
slog.Info("sqlite cron service started")
return nil
}
func (s *SQLiteCronStore) Stop() {
s.mu.Lock()
defer s.mu.Unlock()
if !s.running {
return
}
close(s.stop)
if s.cancelCtx != nil {
s.cancelCtx()
}
s.running = false
}
func (s *SQLiteCronStore) SetOnJob(handler func(job *store.CronJob) (*store.CronJobResult, error)) {
s.mu.Lock()
defer s.mu.Unlock()
s.onJob = handler
}
func (s *SQLiteCronStore) SetOnEvent(handler func(event store.CronEvent)) {
s.mu.Lock()
defer s.mu.Unlock()
s.onEvent = handler
}
func (s *SQLiteCronStore) emitEvent(event store.CronEvent) {
s.mu.Lock()
fn := s.onEvent
s.mu.Unlock()
if fn != nil {
fn(event)
}
}
// --- Scan helpers ---
type cronRowScanner interface {
Scan(dest ...any) error
}
func scanCronRow(row cronRowScanner) (*store.CronJob, error) {
var id uuid.UUID
var tenantID uuid.UUID
var agentID *uuid.UUID
var userID *string
var name, scheduleKind string
var enabled, deleteAfterRun bool
var cronExpr, tz, lastStatus, lastError *string
var runAt, nextRunAt, lastRunAt *time.Time
var intervalMS *int64
var payloadJSON []byte
var createdAt, updatedAt time.Time
err := row.Scan(&id, &tenantID, &agentID, &userID, &name, &enabled, &scheduleKind, &cronExpr, &runAt, &tz,
&intervalMS, &payloadJSON, &deleteAfterRun, &nextRunAt, &lastRunAt, &lastStatus, &lastError,
&createdAt, &updatedAt)
if err != nil {
return nil, err
}
var payload store.CronPayload
if len(payloadJSON) > 0 {
if err := json.Unmarshal(payloadJSON, &payload); err != nil {
return nil, fmt.Errorf("failed to parse cron job payload: %w", err)
}
}
job := &store.CronJob{
ID: id.String(),
TenantID: tenantID,
Name: name,
Enabled: enabled,
Schedule: store.CronSchedule{Kind: scheduleKind},
Payload: payload,
CreatedAtMS: createdAt.UnixMilli(),
UpdatedAtMS: updatedAt.UnixMilli(),
DeleteAfterRun: deleteAfterRun,
}
if agentID != nil {
job.AgentID = agentID.String()
}
if userID != nil {
job.UserID = *userID
}
if cronExpr != nil {
job.Schedule.Expr = *cronExpr
}
if runAt != nil {
ms := runAt.UnixMilli()
job.Schedule.AtMS = &ms
}
if intervalMS != nil {
job.Schedule.EveryMS = intervalMS
}
if tz != nil {
job.Schedule.TZ = *tz
}
if nextRunAt != nil {
ms := nextRunAt.UnixMilli()
job.State.NextRunAtMS = &ms
}
if lastRunAt != nil {
ms := lastRunAt.UnixMilli()
job.State.LastRunAtMS = &ms
}
if lastStatus != nil {
job.State.LastStatus = *lastStatus
}
if lastError != nil {
job.State.LastError = *lastError
}
return job, nil
}
// computeNextRun calculates the next run time for a schedule.
func computeNextRun(schedule *store.CronSchedule, now time.Time, defaultTZ string) *time.Time {
switch schedule.Kind {
case "at":
if schedule.AtMS != nil {
t := time.UnixMilli(*schedule.AtMS)
if t.After(now) {
return &t
}
}
return nil
case "every":
if schedule.EveryMS != nil && *schedule.EveryMS > 0 {
t := now.Add(time.Duration(*schedule.EveryMS) * time.Millisecond)
return &t
}
return nil
case "cron":
if schedule.Expr == "" {
return nil
}
tz := schedule.TZ
if tz == "" {
tz = defaultTZ
}
evalTime := now
if tz != "" {
if loc, err := time.LoadLocation(tz); err == nil {
evalTime = now.In(loc)
}
}
nextTime, err := gronx.NextTickAfter(schedule.Expr, evalTime, false)
if err != nil {
return nil
}
utcNext := nextTime.UTC()
return &utcNext
default:
return nil
}
}
func (s *SQLiteCronStore) scanJob(ctx context.Context, id uuid.UUID) (*store.CronJob, error) {
q := `SELECT id, tenant_id, agent_id, user_id, name, enabled, schedule_kind, cron_expression, run_at, timezone,
interval_ms, payload, delete_after_run, next_run_at, last_run_at, last_status, last_error,
created_at, updated_at FROM cron_jobs WHERE id = ?`
args := []any{id}
if !store.IsCrossTenant(ctx) {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
return nil, fmt.Errorf("tenant_id required")
}
q += " AND tenant_id = ?"
args = append(args, tid)
}
row := s.db.QueryRowContext(ctx, q, args...)
return scanCronRow(row)
}
+413
View File
@@ -0,0 +1,413 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"time"
"github.com/adhocore/gronx"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func (s *SQLiteCronStore) AddJob(ctx context.Context, name string, schedule store.CronSchedule, message string, deliver bool, channel, to, agentID, userID string) (*store.CronJob, error) {
if schedule.TZ == "" && schedule.Kind == "cron" && s.defaultTZ != "" {
schedule.TZ = s.defaultTZ
}
if schedule.TZ != "" {
if _, err := time.LoadLocation(schedule.TZ); err != nil {
return nil, fmt.Errorf("invalid timezone: %s", schedule.TZ)
}
}
payload := store.CronPayload{
Kind: "agent_turn", Message: message, Deliver: deliver, Channel: channel, To: to,
}
payloadJSON, _ := json.Marshal(payload)
id := uuid.Must(uuid.NewV7())
now := time.Now()
scheduleKind := schedule.Kind
deleteAfterRun := schedule.Kind == "at"
var cronExpr, tz *string
var runAt *time.Time
if schedule.Expr != "" {
cronExpr = &schedule.Expr
}
if schedule.AtMS != nil {
t := time.UnixMilli(*schedule.AtMS)
runAt = &t
}
if schedule.TZ != "" {
tz = &schedule.TZ
}
var agentUUID *uuid.UUID
if agentID != "" {
if aid, err := uuid.Parse(agentID); err == nil {
agentUUID = &aid
}
}
var userIDPtr *string
if userID != "" {
userIDPtr = &userID
}
var intervalMS *int64
if schedule.EveryMS != nil {
intervalMS = schedule.EveryMS
}
nextRun := computeNextRun(&schedule, now, s.defaultTZ)
_, err := s.db.ExecContext(ctx,
`INSERT INTO cron_jobs (id, tenant_id, agent_id, user_id, name, enabled, schedule_kind, cron_expression, run_at, timezone,
interval_ms, payload, delete_after_run, next_run_at, created_at, updated_at)
VALUES (?,?,?,?,?,1,?,?,?,?,?,?,?,?,?,?)`,
id, tenantIDForInsert(ctx), agentUUID, userIDPtr, name, scheduleKind, cronExpr, runAt, tz,
intervalMS, payloadJSON, deleteAfterRun, nextRun, now, now,
)
if err != nil {
return nil, fmt.Errorf("create cron job: %w", err)
}
s.InvalidateCache()
job, _ := s.GetJob(ctx, id.String())
return job, nil
}
func (s *SQLiteCronStore) GetJob(ctx context.Context, jobID string) (*store.CronJob, bool) {
id, err := uuid.Parse(jobID)
if err != nil {
return nil, false
}
job, err := s.scanJob(ctx, id)
if err != nil {
return nil, false
}
return job, true
}
func (s *SQLiteCronStore) ListJobs(ctx context.Context, includeDisabled bool, agentID, userID string) []store.CronJob {
q := `SELECT id, tenant_id, agent_id, user_id, name, enabled, schedule_kind, cron_expression, run_at, timezone,
interval_ms, payload, delete_after_run, next_run_at, last_run_at, last_status, last_error,
created_at, updated_at FROM cron_jobs WHERE 1=1`
var args []any
if !includeDisabled {
q += " AND enabled = ?"
args = append(args, true)
}
if agentID != "" {
if aid, err := uuid.Parse(agentID); err == nil {
q += " AND agent_id = ?"
args = append(args, aid)
}
}
if userID != "" {
q += " AND user_id = ?"
args = append(args, userID)
}
clause, targs, tErr := scopeClause(ctx)
if tErr != nil {
slog.Warn("cron.ListJobs: tenant context missing, returning empty (fail-closed)", "error", tErr)
return nil
}
if clause != "" {
q += clause
args = append(args, targs...)
}
q += " ORDER BY created_at DESC"
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil
}
defer rows.Close()
var result []store.CronJob
for rows.Next() {
job, err := scanCronRow(rows)
if err != nil {
continue
}
result = append(result, *job)
}
if err := rows.Err(); err != nil {
slog.Warn("cron: list jobs iteration error", "error", err)
}
return result
}
func (s *SQLiteCronStore) RemoveJob(ctx context.Context, jobID string) error {
id, err := uuid.Parse(jobID)
if err != nil {
return fmt.Errorf("invalid job ID: %s", jobID)
}
q := "DELETE FROM cron_jobs WHERE id = ?"
args := []any{id}
if !store.IsCrossTenant(ctx) {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
return fmt.Errorf("tenant_id required")
}
q += " AND tenant_id = ?"
args = append(args, tid)
}
res, err := s.db.ExecContext(ctx, q, args...)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return fmt.Errorf("job not found")
}
s.InvalidateCache()
return nil
}
func (s *SQLiteCronStore) EnableJob(ctx context.Context, jobID string, enabled bool) error {
id, err := uuid.Parse(jobID)
if err != nil {
return fmt.Errorf("invalid job ID: %s", jobID)
}
q := "UPDATE cron_jobs SET enabled = ?, updated_at = ? WHERE id = ?"
args := []any{enabled, time.Now(), id}
if !store.IsCrossTenant(ctx) {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
return fmt.Errorf("tenant_id required")
}
q += " AND tenant_id = ?"
args = append(args, tid)
}
res, err := s.db.ExecContext(ctx, q, args...)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return fmt.Errorf("job not found")
}
s.InvalidateCache()
return nil
}
func (s *SQLiteCronStore) UpdateJob(ctx context.Context, jobID string, patch store.CronJobPatch) (*store.CronJob, error) {
id, err := uuid.Parse(jobID)
if err != nil {
return nil, fmt.Errorf("invalid job ID: %s", jobID)
}
updates := make(map[string]any)
if patch.Name != "" {
updates["name"] = patch.Name
}
if patch.Enabled != nil {
updates["enabled"] = *patch.Enabled
}
// Build tenant WHERE suffix for schedule reads.
tenantSuffix := ""
var tenantArg []any
if !store.IsCrossTenant(ctx) {
if tid := store.TenantIDFromContext(ctx); tid != uuid.Nil {
tenantSuffix = " AND tenant_id = ?"
tenantArg = append(tenantArg, tid)
}
}
if patch.Schedule != nil {
var curKind string
var curExpr, curTZ *string
var curIntervalMS *int64
var curRunAt *time.Time
if err := s.db.QueryRowContext(ctx,
"SELECT schedule_kind, cron_expression, timezone, interval_ms, run_at FROM cron_jobs WHERE id = ?"+tenantSuffix,
append([]any{id}, tenantArg...)...,
).Scan(&curKind, &curExpr, &curTZ, &curIntervalMS, &curRunAt); err != nil {
return nil, fmt.Errorf("failed to fetch current schedule for job %s: %w", jobID, err)
}
newKind := patch.Schedule.Kind
if newKind == "" {
newKind = curKind
}
updates["schedule_kind"] = newKind
switch newKind {
case "cron":
if patch.Schedule.Expr != "" {
updates["cron_expression"] = patch.Schedule.Expr
}
if patch.Schedule.TZ != "" {
if _, err := time.LoadLocation(patch.Schedule.TZ); err != nil {
return nil, fmt.Errorf("invalid timezone: %s", patch.Schedule.TZ)
}
updates["timezone"] = patch.Schedule.TZ
}
if curKind != "cron" {
updates["interval_ms"] = nil
updates["run_at"] = nil
}
case "every":
if patch.Schedule.EveryMS != nil {
updates["interval_ms"] = *patch.Schedule.EveryMS
}
if curKind != "every" {
updates["cron_expression"] = nil
updates["timezone"] = nil
updates["run_at"] = nil
}
case "at":
if patch.Schedule.AtMS != nil {
t := time.UnixMilli(*patch.Schedule.AtMS)
updates["run_at"] = t
}
if curKind != "at" {
updates["cron_expression"] = nil
updates["timezone"] = nil
updates["interval_ms"] = nil
}
}
merged := buildMergedSchedule(newKind, curKind, patch.Schedule, curExpr, curTZ, curIntervalMS, curRunAt)
if err := validateSchedule(&merged); err != nil {
return nil, err
}
next := computeNextRun(&merged, time.Now(), s.defaultTZ)
updates["next_run_at"] = next
}
if patch.DeleteAfterRun != nil {
updates["delete_after_run"] = *patch.DeleteAfterRun
}
if patch.AgentID != nil {
if *patch.AgentID == "" {
updates["agent_id"] = nil
} else if aid, parseErr := uuid.Parse(*patch.AgentID); parseErr == nil {
updates["agent_id"] = aid
}
}
needsPayloadUpdate := patch.Message != "" || patch.Deliver != nil || patch.Channel != nil || patch.To != nil || patch.WakeHeartbeat != nil
if needsPayloadUpdate {
var payloadJSON []byte
if scanErr := s.db.QueryRowContext(ctx, "SELECT payload FROM cron_jobs WHERE id = ?"+tenantSuffix, append([]any{id}, tenantArg...)...).Scan(&payloadJSON); scanErr == nil {
var p store.CronPayload
if len(payloadJSON) > 0 {
if err := json.Unmarshal(payloadJSON, &p); err != nil {
return nil, fmt.Errorf("failed to parse existing payload for job %s: %w", jobID, err)
}
}
if patch.Message != "" {
p.Message = patch.Message
}
if patch.Deliver != nil {
p.Deliver = *patch.Deliver
}
if patch.Channel != nil {
p.Channel = *patch.Channel
}
if patch.To != nil {
p.To = *patch.To
}
if patch.WakeHeartbeat != nil {
p.WakeHeartbeat = *patch.WakeHeartbeat
}
merged, err := json.Marshal(p)
if err != nil {
return nil, fmt.Errorf("failed to marshal payload for job %s: %w", jobID, err)
}
updates["payload"] = merged
}
}
updates["updated_at"] = time.Now()
var execErr error
if store.IsCrossTenant(ctx) {
execErr = execMapUpdate(ctx, s.db, "cron_jobs", id, updates)
} else {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
execErr = fmt.Errorf("tenant_id required for update")
} else {
execErr = execMapUpdateWhereTenant(ctx, s.db, "cron_jobs", updates, id, tid)
}
}
if execErr != nil {
return nil, execErr
}
s.InvalidateCache()
job, _ := s.scanJob(ctx, id)
return job, nil
}
func buildMergedSchedule(newKind, curKind string, patch *store.CronSchedule, curExpr, curTZ *string, curIntervalMS *int64, curRunAt *time.Time) store.CronSchedule {
merged := store.CronSchedule{Kind: newKind}
switch newKind {
case "cron":
if patch.Expr != "" {
merged.Expr = patch.Expr
} else if curExpr != nil {
merged.Expr = *curExpr
}
if patch.TZ != "" {
merged.TZ = patch.TZ
} else if curTZ != nil && newKind == curKind {
merged.TZ = *curTZ
}
case "every":
if patch.EveryMS != nil {
merged.EveryMS = patch.EveryMS
} else if curIntervalMS != nil {
merged.EveryMS = curIntervalMS
}
case "at":
if patch.AtMS != nil {
merged.AtMS = patch.AtMS
} else if curRunAt != nil {
ms := curRunAt.UnixMilli()
merged.AtMS = &ms
}
}
return merged
}
func validateSchedule(s *store.CronSchedule) error {
switch s.Kind {
case "cron":
if s.Expr == "" {
return fmt.Errorf("cron schedule requires expr")
}
gx := gronx.New()
if !gx.IsValid(s.Expr) {
return fmt.Errorf("invalid cron expression: %s", s.Expr)
}
case "every":
if s.EveryMS == nil || *s.EveryMS <= 0 {
return fmt.Errorf("every schedule requires positive everyMs")
}
case "at":
if s.AtMS == nil {
return fmt.Errorf("at schedule requires atMs")
}
}
return nil
}
+137
View File
@@ -0,0 +1,137 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"database/sql"
"fmt"
"log/slog"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func (s *SQLiteCronStore) RunJob(ctx context.Context, jobID string, force bool) (bool, string, error) {
job, ok := s.GetJob(ctx, jobID)
if !ok {
return false, "", fmt.Errorf("job %s not found", jobID)
}
s.mu.Lock()
handler := s.onJob
s.mu.Unlock()
if handler == nil {
return false, "", fmt.Errorf("no job handler configured")
}
if id, parseErr := uuid.Parse(jobID); parseErr == nil {
if _, execErr := s.db.ExecContext(ctx, "UPDATE cron_jobs SET last_status = 'running', updated_at = ? WHERE id = ?", time.Now(), id); execErr != nil {
slog.Warn("cron: failed to mark job running", "job", jobID, "error", execErr)
}
}
s.InvalidateCache()
s.emitEvent(store.CronEvent{Action: "running", JobID: job.ID, JobName: job.Name, UserID: job.UserID})
s.executeOneJob(*job, handler)
s.mu.Lock()
s.cacheLoaded = false
s.mu.Unlock()
return true, "", nil
}
func (s *SQLiteCronStore) GetRunLog(ctx context.Context, jobID string, limit, offset int) ([]store.CronRunLogEntry, int) {
if limit <= 0 {
limit = 20
}
if offset < 0 {
offset = 0
}
const cols = "r.job_id, r.status, r.error, r.summary, r.ran_at, COALESCE(r.duration_ms, 0), COALESCE(r.input_tokens, 0), COALESCE(r.output_tokens, 0)"
// Tenant isolation via JOIN with cron_jobs.
var tenantJoin, tenantWhere string
var tenantArgs []any
if !store.IsCrossTenant(ctx) {
tid := store.TenantIDFromContext(ctx)
if tid != uuid.Nil {
tenantJoin = " JOIN cron_jobs j ON r.job_id = j.id"
tenantWhere = " AND j.tenant_id = ?"
tenantArgs = append(tenantArgs, tid)
}
}
var total int
var rows *sql.Rows
var err error
if jobID != "" {
id, parseErr := uuid.Parse(jobID)
if parseErr != nil {
return nil, 0
}
countQ := fmt.Sprintf("SELECT COUNT(*) FROM cron_run_logs r%s WHERE r.job_id = ?%s", tenantJoin, tenantWhere)
countArgs := append(tenantArgs, id)
s.db.QueryRowContext(ctx, countQ, countArgs...).Scan(&total)
dataQ := fmt.Sprintf("SELECT %s FROM cron_run_logs r%s WHERE r.job_id = ?%s ORDER BY r.ran_at DESC LIMIT ? OFFSET ?",
cols, tenantJoin, tenantWhere)
dataArgs := append(tenantArgs, id, limit, offset)
rows, err = s.db.QueryContext(ctx, dataQ, dataArgs...)
} else {
countQ := fmt.Sprintf("SELECT COUNT(*) FROM cron_run_logs r%s WHERE 1=1%s", tenantJoin, tenantWhere)
s.db.QueryRowContext(ctx, countQ, tenantArgs...).Scan(&total)
dataQ := fmt.Sprintf("SELECT %s FROM cron_run_logs r%s WHERE 1=1%s ORDER BY r.ran_at DESC LIMIT ? OFFSET ?",
cols, tenantJoin, tenantWhere)
dataArgs := append(tenantArgs, limit, offset)
rows, err = s.db.QueryContext(ctx, dataQ, dataArgs...)
}
if err != nil {
return nil, 0
}
defer rows.Close()
var result []store.CronRunLogEntry
for rows.Next() {
var jobUUID uuid.UUID
var status string
var errStr, summary *string
var ranAt time.Time
var durationMS int64
var inputTokens, outputTokens int
if err := rows.Scan(&jobUUID, &status, &errStr, &summary, &ranAt, &durationMS, &inputTokens, &outputTokens); err != nil {
continue
}
result = append(result, store.CronRunLogEntry{
Ts: ranAt.UnixMilli(),
JobID: jobUUID.String(),
Status: status,
Error: derefStr(errStr),
Summary: derefStr(summary),
DurationMS: durationMS,
InputTokens: inputTokens,
OutputTokens: outputTokens,
})
}
if rErr := rows.Err(); rErr != nil {
slog.Warn("cron: run log iteration error", "error", rErr)
}
return result, total
}
func (s *SQLiteCronStore) Status() map[string]any {
s.mu.Lock()
defer s.mu.Unlock()
var count int64
s.db.QueryRow("SELECT COUNT(*) FROM cron_jobs WHERE enabled = 1").Scan(&count)
return map[string]any{
"enabled": s.running,
"jobs": count,
}
}
@@ -0,0 +1,253 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"log/slog"
"sync"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/cron"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func (s *SQLiteCronStore) GetDueJobs(now time.Time) []store.CronJob {
s.mu.Lock()
if !s.cacheLoaded || time.Since(s.cacheTime) > s.cacheTTL {
s.refreshJobCache()
}
jobs := s.jobCache
s.mu.Unlock()
var due []store.CronJob
for i := range jobs {
if jobs[i].Enabled && jobs[i].State.NextRunAtMS != nil {
nextRun := time.UnixMilli(*jobs[i].State.NextRunAtMS)
if !nextRun.After(now) {
due = append(due, jobs[i])
}
}
}
return due
}
// refreshJobCache reloads all enabled jobs from DB. Must be called with mu held.
func (s *SQLiteCronStore) refreshJobCache() {
rows, err := s.db.QueryContext(s.baseCtx,
`SELECT id, tenant_id, agent_id, user_id, name, enabled, schedule_kind, cron_expression, run_at, timezone,
interval_ms, payload, delete_after_run, next_run_at, last_run_at, last_status, last_error,
created_at, updated_at FROM cron_jobs WHERE enabled = 1`)
if err != nil {
return
}
defer rows.Close()
s.jobCache = nil
for rows.Next() {
job, err := scanCronRow(rows)
if err != nil {
continue
}
s.jobCache = append(s.jobCache, *job)
}
if err := rows.Err(); err != nil {
slog.Warn("cron: cache refresh iteration error", "error", err)
}
s.cacheLoaded = true
s.cacheTime = time.Now()
}
// InvalidateCache forces a cache refresh on the next GetDueJobs call.
func (s *SQLiteCronStore) InvalidateCache() {
s.mu.Lock()
s.cacheLoaded = false
s.mu.Unlock()
}
// recomputeStaleJobs fixes enabled jobs with next_run_at = NULL on startup.
func (s *SQLiteCronStore) recomputeStaleJobs() {
rows, err := s.db.QueryContext(s.baseCtx,
`SELECT id, schedule_kind, cron_expression, run_at, timezone, interval_ms
FROM cron_jobs WHERE enabled = 1 AND next_run_at IS NULL`)
if err != nil {
slog.Warn("cron: failed to query stale jobs", "error", err)
return
}
defer rows.Close()
now := time.Now()
var fixed int
for rows.Next() {
var id uuid.UUID
var scheduleKind string
var cronExpr, tz *string
var runAt *time.Time
var intervalMS *int64
if err := rows.Scan(&id, &scheduleKind, &cronExpr, &runAt, &tz, &intervalMS); err != nil {
continue
}
schedule := store.CronSchedule{Kind: scheduleKind}
if cronExpr != nil {
schedule.Expr = *cronExpr
}
if runAt != nil {
ms := runAt.UnixMilli()
schedule.AtMS = &ms
}
if intervalMS != nil {
schedule.EveryMS = intervalMS
}
if tz != nil {
schedule.TZ = *tz
}
next := computeNextRun(&schedule, now, s.defaultTZ)
if next == nil {
if scheduleKind == "at" {
s.db.ExecContext(s.baseCtx, "UPDATE cron_jobs SET enabled = 0, updated_at = ? WHERE id = ?", now, id)
}
continue
}
s.db.ExecContext(s.baseCtx, "UPDATE cron_jobs SET next_run_at = ?, updated_at = ? WHERE id = ?", *next, now, id)
fixed++
}
if err := rows.Err(); err != nil {
slog.Warn("cron: recompute stale iteration error", "error", err)
}
if fixed > 0 {
slog.Info("cron: recomputed stale next_run_at on startup", "fixed", fixed)
}
}
func (s *SQLiteCronStore) runLoop() {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case <-s.stop:
return
case <-ticker.C:
s.checkAndRunDueJobs()
}
}
}
func (s *SQLiteCronStore) checkAndRunDueJobs() {
dueJobs := s.GetDueJobs(time.Now())
if len(dueJobs) == 0 {
return
}
s.mu.Lock()
handler := s.onJob
s.mu.Unlock()
if handler == nil {
return
}
// Clear next_run for all due jobs first to prevent duplicate fires.
for _, job := range dueJobs {
if id, parseErr := uuid.Parse(job.ID); parseErr == nil {
s.db.ExecContext(s.baseCtx, "UPDATE cron_jobs SET next_run_at = NULL WHERE id = ?", id)
}
}
// Execute jobs in parallel — scheduler enforces per-session serialization.
var wg sync.WaitGroup
for _, job := range dueJobs {
wg.Add(1)
go func(job store.CronJob) {
defer wg.Done()
s.executeOneJob(job, handler)
}(job)
}
wg.Wait()
s.mu.Lock()
s.cacheLoaded = false
s.mu.Unlock()
}
// executeOneJob runs a single cron job with retry, logs the result, and updates next_run_at.
func (s *SQLiteCronStore) executeOneJob(job store.CronJob, handler func(job *store.CronJob) (*store.CronJobResult, error)) {
startTime := time.Now()
var lastResult *store.CronJobResult
resultStr, attempts, err := cron.ExecuteWithRetry(func() (string, error) {
r, e := handler(&job)
if e != nil {
return "", e
}
lastResult = r
if r != nil {
return r.Content, nil
}
return "", nil
}, s.retryCfg)
durationMS := time.Since(startTime).Milliseconds()
if attempts > 1 {
slog.Info("cron job retried", "id", job.ID, "attempts", attempts, "success", err == nil)
}
now := time.Now()
status := "ok"
var lastError *string
if err != nil {
status = "error"
errStr := err.Error()
lastError = &errStr
}
var inputTokens, outputTokens int
if lastResult != nil {
inputTokens = lastResult.InputTokens
outputTokens = lastResult.OutputTokens
}
logID := uuid.Must(uuid.NewV7())
var summary *string
if err == nil {
truncated := cron.TruncateOutput(resultStr)
summary = &truncated
}
if id, parseErr := uuid.Parse(job.ID); parseErr == nil {
var agentUUID *uuid.UUID
if aid, aidErr := uuid.Parse(job.AgentID); aidErr == nil {
agentUUID = &aid
}
s.db.ExecContext(s.baseCtx,
`INSERT INTO cron_run_logs (id, job_id, agent_id, status, error, summary, duration_ms, input_tokens, output_tokens, ran_at)
VALUES (?,?,?,?,?,?,?,?,?,?)`,
logID, id, agentUUID, status, lastError, summary, durationMS, inputTokens, outputTokens, now,
)
}
if job.DeleteAfterRun {
if id, parseErr := uuid.Parse(job.ID); parseErr == nil {
s.db.ExecContext(s.baseCtx, "DELETE FROM cron_jobs WHERE id = ?", id)
}
} else if id, parseErr := uuid.Parse(job.ID); parseErr == nil {
schedule := job.Schedule
next := computeNextRun(&schedule, now, s.defaultTZ)
s.db.ExecContext(s.baseCtx,
"UPDATE cron_jobs SET last_run_at = ?, last_status = ?, last_error = ?, next_run_at = ?, updated_at = ? WHERE id = ?",
now, status, lastError, next, now, id,
)
}
evt := store.CronEvent{Action: "completed", JobID: job.ID, JobName: job.Name, UserID: job.UserID, Status: status}
if err != nil {
evt.Action = "error"
evt.Error = err.Error()
}
s.emitEvent(evt)
}
+57
View File
@@ -0,0 +1,57 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"fmt"
"log/slog"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// NewSQLiteStores creates all stores backed by SQLite.
// Mirrors pg.NewPGStores() — returns the same *store.Stores struct.
func NewSQLiteStores(cfg store.StoreConfig) (*store.Stores, error) {
db, err := OpenDB(cfg.SQLitePath)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
// Apply schema (create tables on first run, migrate on upgrade).
if err := EnsureSchema(db); err != nil {
db.Close()
return nil, fmt.Errorf("ensure schema: %w", err)
}
slog.Info("sqlite stores initialized", "path", cfg.SQLitePath)
return &store.Stores{
DB: db,
Sessions: NewSQLiteSessionStore(db),
Agents: NewSQLiteAgentStore(db),
Providers: NewSQLiteProviderStore(db, cfg.EncryptionKey),
Tracing: NewSQLiteTracingStore(db),
ConfigSecrets: NewSQLiteConfigSecretsStore(db, cfg.EncryptionKey),
BuiltinTools: NewSQLiteBuiltinToolStore(db),
Heartbeats: NewSQLiteHeartbeatStore(db),
Tenants: NewSQLiteTenantStore(db),
BuiltinToolTenantCfgs: NewSQLiteBuiltinToolTenantConfigStore(db),
SkillTenantCfgs: NewSQLiteSkillTenantConfigStore(db),
SystemConfigs: NewSQLiteSystemConfigStore(db),
Snapshots: NewSQLiteSnapshotStore(db),
Cron: NewSQLiteCronStore(db),
ChannelInstances: NewSQLiteChannelInstanceStore(db, cfg.EncryptionKey),
Pairing: NewSQLitePairingStore(db),
PendingMessages: NewSQLitePendingMessageStore(db),
Contacts: NewSQLiteContactStore(db),
Teams: NewSQLiteTeamStore(db),
Skills: NewSQLiteSkillStore(db, cfg.SkillsStorageDir),
MCP: NewSQLiteMCPServerStore(db, cfg.EncryptionKey),
Activity: NewSQLiteActivityStore(db),
APIKeys: NewSQLiteAPIKeyStore(db),
ConfigPermissions: NewSQLiteConfigPermissionStore(db),
Memory: NewSQLiteMemoryStore(db),
// Phase 2 Batch B+C stores (nil = gracefully skipped by gateway):
// AgentLinks, KnowledgeGraph, SecureCLI
}, nil
}
+371
View File
@@ -0,0 +1,371 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"database/sql"
"encoding/json"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// SQLiteHeartbeatStore implements store.HeartbeatStore backed by SQLite.
type SQLiteHeartbeatStore struct {
db *sql.DB
mu sync.Mutex
onEvent func(store.HeartbeatEvent)
dueCache []store.AgentHeartbeat
cacheLoaded bool
cacheTime time.Time
cacheTTL time.Duration
}
const defaultHeartbeatCacheTTL = 30 * time.Second
func NewSQLiteHeartbeatStore(db *sql.DB) *SQLiteHeartbeatStore {
return &SQLiteHeartbeatStore{db: db, cacheTTL: defaultHeartbeatCacheTTL}
}
func (s *SQLiteHeartbeatStore) SetOnEvent(fn func(store.HeartbeatEvent)) {
s.mu.Lock()
defer s.mu.Unlock()
s.onEvent = fn
}
func (s *SQLiteHeartbeatStore) emitEvent(event store.HeartbeatEvent) {
s.mu.Lock()
fn := s.onEvent
s.mu.Unlock()
if fn != nil {
fn(event)
}
}
func (s *SQLiteHeartbeatStore) InvalidateCache() {
s.mu.Lock()
defer s.mu.Unlock()
s.cacheLoaded = false
}
const heartbeatSelectCols = `id, agent_id, enabled, interval_sec, prompt, provider_id, model,
isolated_session, light_context, ack_max_chars, max_retries,
active_hours_start, active_hours_end, timezone,
channel, chat_id,
next_run_at, last_run_at, last_status, last_error,
run_count, suppress_count, metadata, created_at, updated_at`
func scanHeartbeat(row interface {
Scan(...any) error
}) (*store.AgentHeartbeat, error) {
var hb store.AgentHeartbeat
var metadata []byte
var nextRunAt, lastRunAt nullSqliteTime
createdAt, updatedAt := scanTimePair()
err := row.Scan(
&hb.ID, &hb.AgentID, &hb.Enabled, &hb.IntervalSec, &hb.Prompt, &hb.ProviderID, &hb.Model,
&hb.IsolatedSession, &hb.LightContext, &hb.AckMaxChars, &hb.MaxRetries,
&hb.ActiveHoursStart, &hb.ActiveHoursEnd, &hb.Timezone,
&hb.Channel, &hb.ChatID,
&nextRunAt, &lastRunAt, &hb.LastStatus, &hb.LastError,
&hb.RunCount, &hb.SuppressCount, &metadata, createdAt, updatedAt,
)
if err != nil {
return nil, err
}
hb.CreatedAt = createdAt.Time
hb.UpdatedAt = updatedAt.Time
if nextRunAt.Valid {
hb.NextRunAt = &nextRunAt.Time
}
if lastRunAt.Valid {
hb.LastRunAt = &lastRunAt.Time
}
hb.Metadata = metadata
return &hb, nil
}
func (s *SQLiteHeartbeatStore) Get(ctx context.Context, agentID uuid.UUID) (*store.AgentHeartbeat, error) {
row := s.db.QueryRowContext(ctx,
`SELECT `+heartbeatSelectCols+` FROM agent_heartbeats WHERE agent_id = ?`, agentID,
)
return scanHeartbeat(row)
}
func (s *SQLiteHeartbeatStore) Upsert(ctx context.Context, hb *store.AgentHeartbeat) error {
meta := hb.Metadata
if meta == nil {
meta = json.RawMessage("{}")
}
now := time.Now().UTC()
createdAt, updatedAt := scanTimePair()
err := s.db.QueryRowContext(ctx,
`INSERT INTO agent_heartbeats
(agent_id, enabled, interval_sec, prompt, provider_id, model,
isolated_session, light_context, ack_max_chars, max_retries,
active_hours_start, active_hours_end, timezone,
channel, chat_id, next_run_at, metadata, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT (agent_id) DO UPDATE SET
enabled = excluded.enabled,
interval_sec = excluded.interval_sec,
prompt = excluded.prompt,
provider_id = excluded.provider_id,
model = excluded.model,
isolated_session = excluded.isolated_session,
light_context = excluded.light_context,
ack_max_chars = excluded.ack_max_chars,
max_retries = excluded.max_retries,
active_hours_start = excluded.active_hours_start,
active_hours_end = excluded.active_hours_end,
timezone = excluded.timezone,
channel = excluded.channel,
chat_id = excluded.chat_id,
next_run_at = excluded.next_run_at,
metadata = excluded.metadata,
updated_at = excluded.updated_at
RETURNING id, created_at, updated_at`,
hb.AgentID, hb.Enabled, hb.IntervalSec, hb.Prompt, hb.ProviderID, hb.Model,
hb.IsolatedSession, hb.LightContext, hb.AckMaxChars, hb.MaxRetries,
hb.ActiveHoursStart, hb.ActiveHoursEnd, hb.Timezone,
hb.Channel, hb.ChatID, hb.NextRunAt, meta, now, now,
).Scan(&hb.ID, createdAt, updatedAt)
if err != nil {
return err
}
hb.CreatedAt = createdAt.Time
hb.UpdatedAt = updatedAt.Time
s.InvalidateCache()
return nil
}
func (s *SQLiteHeartbeatStore) ListDue(ctx context.Context, now time.Time) ([]store.AgentHeartbeat, error) {
s.mu.Lock()
if s.cacheLoaded && time.Since(s.cacheTime) < s.cacheTTL {
cached := s.dueCache
s.mu.Unlock()
var due []store.AgentHeartbeat
for _, hb := range cached {
if hb.NextRunAt != nil && !hb.NextRunAt.After(now) {
due = append(due, hb)
}
}
return due, nil
}
s.mu.Unlock()
rows, err := s.db.QueryContext(ctx,
`SELECT `+heartbeatSelectCols+`
FROM agent_heartbeats
WHERE enabled = 1 AND next_run_at IS NOT NULL`)
if err != nil {
return nil, err
}
defer rows.Close()
var all []store.AgentHeartbeat
for rows.Next() {
hb, err := scanHeartbeat(rows)
if err != nil {
return nil, err
}
all = append(all, *hb)
}
if err := rows.Err(); err != nil {
return nil, err
}
s.mu.Lock()
s.dueCache = all
s.cacheLoaded = true
s.cacheTime = time.Now()
s.mu.Unlock()
var due []store.AgentHeartbeat
for _, hb := range all {
if hb.NextRunAt != nil && !hb.NextRunAt.After(now) {
due = append(due, hb)
}
}
return due, nil
}
func (s *SQLiteHeartbeatStore) UpdateState(ctx context.Context, id uuid.UUID, state store.HeartbeatState) error {
_, err := s.db.ExecContext(ctx,
`UPDATE agent_heartbeats SET
next_run_at = ?, last_run_at = ?, last_status = ?, last_error = ?,
run_count = ?, suppress_count = ?, updated_at = ?
WHERE id = ?`,
state.NextRunAt, state.LastRunAt, state.LastStatus, state.LastError,
state.RunCount, state.SuppressCount, time.Now().UTC(), id,
)
if err == nil {
s.InvalidateCache()
}
return err
}
func (s *SQLiteHeartbeatStore) Delete(ctx context.Context, agentID uuid.UUID) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM agent_heartbeats WHERE agent_id = ?`, agentID)
if err == nil {
s.InvalidateCache()
}
return err
}
func (s *SQLiteHeartbeatStore) InsertLog(ctx context.Context, log *store.HeartbeatRunLog) error {
meta := log.Metadata
if meta == nil {
meta = json.RawMessage("{}")
}
_, err := s.db.ExecContext(ctx,
`INSERT INTO heartbeat_run_logs
(heartbeat_id, agent_id, status, summary, error,
duration_ms, input_tokens, output_tokens, skip_reason, metadata, ran_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
log.HeartbeatID, log.AgentID, log.Status, log.Summary, log.Error,
log.DurationMS, log.InputTokens, log.OutputTokens, log.SkipReason, meta, log.RanAt,
)
return err
}
func (s *SQLiteHeartbeatStore) ListLogs(ctx context.Context, agentID uuid.UUID, limit, offset int) ([]store.HeartbeatRunLog, int, error) {
if limit <= 0 {
limit = 20
}
var total int
if err := s.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM heartbeat_run_logs WHERE agent_id = ?`, agentID,
).Scan(&total); err != nil {
return nil, 0, err
}
rows, err := s.db.QueryContext(ctx,
`SELECT id, heartbeat_id, agent_id, status, summary, error,
duration_ms, input_tokens, output_tokens, skip_reason, metadata, ran_at, created_at
FROM heartbeat_run_logs WHERE agent_id = ?
ORDER BY ran_at DESC LIMIT ? OFFSET ?`,
agentID, limit, offset,
)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var logs []store.HeartbeatRunLog
for rows.Next() {
var l store.HeartbeatRunLog
var metadata []byte
var ranAt, createdAt sqliteTime
if err := rows.Scan(
&l.ID, &l.HeartbeatID, &l.AgentID, &l.Status, &l.Summary, &l.Error,
&l.DurationMS, &l.InputTokens, &l.OutputTokens, &l.SkipReason, &metadata, &ranAt, &createdAt,
); err != nil {
return nil, 0, err
}
l.RanAt = ranAt.Time
l.CreatedAt = createdAt.Time
l.Metadata = metadata
logs = append(logs, l)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
return logs, total, nil
}
// ListDeliveryTargets returns distinct (channel, chatID, title, kind) pairs from sessions.
// SQLite adaptation: replaces DISTINCT ON + split_part with GROUP BY + substr/instr + Go-side dedup.
// Session key format: agent:{key}:{channel}:{kind}:{chatId}
func (s *SQLiteHeartbeatStore) ListDeliveryTargets(ctx context.Context, agentID uuid.UUID) ([]store.DeliveryTarget, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT
s.channel,
substr(s.session_key, instr(s.session_key||':', ':') + 1) AS raw_tail,
json_extract(s.metadata, '$.chat_title') AS chat_title,
s.session_key,
cc.display_name,
cc.username
FROM sessions s
LEFT JOIN channel_contacts cc
ON cc.channel_type = s.channel
WHERE s.agent_id = ?
AND s.channel IS NOT NULL AND s.channel != ''
AND s.session_key NOT LIKE '%:heartbeat%'
AND s.session_key NOT LIKE '%:cron%'
AND s.session_key NOT LIKE '%:subagent%'
AND s.session_key NOT LIKE '%:team:%'
GROUP BY s.channel, s.session_key`,
agentID,
)
if err != nil {
return nil, err
}
defer rows.Close()
seen := make(map[string]bool)
var targets []store.DeliveryTarget
for rows.Next() {
var channel, rawTail, sessionKey string
var chatTitle, displayName, username *string
if err := rows.Scan(&channel, &rawTail, &chatTitle, &sessionKey, &displayName, &username); err != nil {
return nil, err
}
// Parse chatId: 5th colon-separated segment of session_key.
chatID := extractSessionKeyPart(sessionKey, 5)
if chatID == "" {
continue
}
key := channel + ":" + chatID
if seen[key] {
continue
}
seen[key] = true
title := ""
switch {
case chatTitle != nil && *chatTitle != "":
title = *chatTitle
case displayName != nil && *displayName != "":
title = *displayName
case username != nil && *username != "":
title = "@" + *username
}
kind := "other"
switch {
case strings.Contains(sessionKey, ":group:"):
kind = "group"
case strings.Contains(sessionKey, ":direct:"):
kind = "dm"
}
targets = append(targets, store.DeliveryTarget{
Channel: channel,
ChatID: chatID,
Title: title,
Kind: kind,
})
}
if err := rows.Err(); err != nil {
return nil, err
}
return targets, nil
}
// extractSessionKeyPart returns the n-th colon-delimited segment (1-indexed).
func extractSessionKeyPart(key string, n int) string {
parts := strings.SplitN(key, ":", n+1)
if len(parts) < n {
return ""
}
return parts[n-1]
}
+220
View File
@@ -0,0 +1,220 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"regexp"
"strings"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// validColumnName matches safe SQL identifiers (letters, digits, underscores).
var validColumnName = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
// --- Nullable helpers ---
func nilStr(s string) *string {
if s == "" {
return nil
}
return &s
}
func nilInt(v int) *int {
if v == 0 {
return nil
}
return &v
}
func nilUUID(u *uuid.UUID) *uuid.UUID {
if u == nil || *u == uuid.Nil {
return nil
}
return u
}
func nilTime(t *time.Time) *time.Time {
if t == nil || t.IsZero() {
return nil
}
return t
}
func derefStr(s *string) string {
if s == nil {
return ""
}
return *s
}
func derefUUID(u *uuid.UUID) uuid.UUID {
if u == nil {
return uuid.Nil
}
return *u
}
// --- JSON helpers ---
func jsonOrEmpty(data []byte) []byte {
if data == nil {
return []byte("{}")
}
return data
}
func jsonOrEmptyArray(data []byte) []byte {
if data == nil {
return []byte("[]")
}
return data
}
func jsonOrNull(data json.RawMessage) any {
if data == nil {
return nil
}
// Return []byte for consistency with PG helpers (store implementations expect []byte).
return []byte(data)
}
func derefBytes(b *[]byte) []byte {
if b == nil {
return nil
}
return *b
}
// jsonStringArray converts a Go string slice to a JSON array string for SQLite storage.
// SQLite stores arrays as JSON text (no native array type).
func jsonStringArray(arr []string) string {
if arr == nil {
return "[]"
}
data, _ := json.Marshal(arr)
return string(data)
}
// scanJSONStringArray parses a JSON array stored as TEXT into a Go string slice.
func scanJSONStringArray(data []byte, dest *[]string) {
if data == nil || len(data) == 0 {
return
}
_ = json.Unmarshal(data, dest)
}
// sqliteVal marshals complex Go types (maps, slices) to JSON strings
// since the SQLite driver cannot serialize them directly.
func sqliteVal(v any) any {
if v == nil {
return nil
}
switch v.(type) {
case string, int, int64, float64, bool, time.Time, []byte, json.RawMessage:
return v
}
// For maps, slices, etc. — marshal to JSON string.
b, err := json.Marshal(v)
if err != nil {
return nil
}
return string(b)
}
// --- Dynamic UPDATE helper ---
// execMapUpdate builds and runs a dynamic UPDATE with ? placeholders.
func execMapUpdate(ctx context.Context, db *sql.DB, table string, id uuid.UUID, updates map[string]any) error {
if len(updates) == 0 {
return nil
}
var setClauses []string
var args []any
for col, val := range updates {
if !validColumnName.MatchString(col) {
slog.Warn("security.invalid_column_name", "table", table, "column", col)
return fmt.Errorf("invalid column name: %q", col)
}
setClauses = append(setClauses, col+" = ?")
args = append(args, sqliteVal(val))
}
// Auto-set updated_at for tables that have the column.
if _, ok := updates["updated_at"]; !ok && tableHasUpdatedAt(table) {
setClauses = append(setClauses, "updated_at = ?")
args = append(args, time.Now().UTC())
}
args = append(args, id)
q := fmt.Sprintf("UPDATE %s SET %s WHERE id = ?", table, strings.Join(setClauses, ", "))
_, err := db.ExecContext(ctx, q, args...)
return err
}
var tablesWithUpdatedAt = map[string]bool{
"agents": true, "llm_providers": true, "sessions": true,
"channel_instances": true, "cron_jobs": true,
"skills": true, "mcp_servers": true, "agent_links": true,
"agent_teams": true, "team_tasks": true, "builtin_tools": true,
"agent_context_files": true, "user_context_files": true,
"user_agent_overrides": true, "config_secrets": true,
"memory_documents": true, "memory_chunks": true, "embedding_cache": true,
"secure_cli_binaries": true, "tenants": true,
}
func tableHasUpdatedAt(table string) bool {
return tablesWithUpdatedAt[table]
}
// --- Tenant filter helpers ---
func tenantIDForInsert(ctx context.Context) uuid.UUID {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
return store.MasterTenantID
}
return tid
}
func requireTenantID(ctx context.Context) (uuid.UUID, error) {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
return uuid.Nil, fmt.Errorf("tenant_id required")
}
return tid, nil
}
// execMapUpdateWhereTenant builds and runs a dynamic UPDATE with ? placeholders,
// adding both id and tenant_id to the WHERE clause for tenant-scoped updates.
func execMapUpdateWhereTenant(ctx context.Context, db *sql.DB, table string, updates map[string]any, id, tenantID uuid.UUID) error {
if len(updates) == 0 {
return nil
}
var setClauses []string
var args []any
for col, val := range updates {
if !validColumnName.MatchString(col) {
slog.Warn("security.invalid_column_name", "table", table, "column", col)
return fmt.Errorf("invalid column name: %q", col)
}
setClauses = append(setClauses, col+" = ?")
args = append(args, sqliteVal(val))
}
if _, ok := updates["updated_at"]; !ok && tableHasUpdatedAt(table) {
setClauses = append(setClauses, "updated_at = ?")
args = append(args, time.Now().UTC())
}
args = append(args, id, tenantID)
q := fmt.Sprintf("UPDATE %s SET %s WHERE id = ? AND tenant_id = ?",
table, strings.Join(setClauses, ", "))
_, err := db.ExecContext(ctx, q, args...)
return err
}
+272
View File
@@ -0,0 +1,272 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/crypto"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// SQLiteMCPServerStore implements store.MCPServerStore backed by SQLite.
type SQLiteMCPServerStore struct {
db *sql.DB
encKey string
}
func NewSQLiteMCPServerStore(db *sql.DB, encryptionKey string) *SQLiteMCPServerStore {
return &SQLiteMCPServerStore{db: db, encKey: encryptionKey}
}
func (s *SQLiteMCPServerStore) CreateServer(ctx context.Context, srv *store.MCPServerData) error {
if err := store.ValidateUserID(srv.CreatedBy); err != nil {
return err
}
if srv.ID == uuid.Nil {
srv.ID = store.GenNewID()
}
apiKey := srv.APIKey
if s.encKey != "" && apiKey != "" {
encrypted, err := crypto.Encrypt(apiKey, s.encKey)
if err != nil {
return fmt.Errorf("encrypt api key: %w", err)
}
apiKey = encrypted
}
now := time.Now().UTC()
srv.CreatedAt = now
srv.UpdatedAt = now
encHeaders := s.encryptJSON(jsonOrEmpty(srv.Headers))
encEnv := s.encryptJSON(jsonOrEmpty(srv.Env))
tenantID := store.TenantIDFromContext(ctx)
if tenantID == uuid.Nil {
tenantID = store.MasterTenantID
}
_, err := s.db.ExecContext(ctx,
`INSERT INTO mcp_servers (id, name, display_name, transport, command, args, url, headers, env,
api_key, tool_prefix, timeout_sec, settings, enabled, created_by, created_at, updated_at, tenant_id)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
srv.ID, srv.Name, nilStr(srv.DisplayName), srv.Transport, nilStr(srv.Command),
jsonOrEmpty(srv.Args), nilStr(srv.URL), encHeaders, encEnv,
nilStr(apiKey), nilStr(srv.ToolPrefix), srv.TimeoutSec,
jsonOrEmpty(srv.Settings), srv.Enabled, srv.CreatedBy, now, now, tenantID,
)
return err
}
func (s *SQLiteMCPServerStore) GetServer(ctx context.Context, id uuid.UUID) (*store.MCPServerData, error) {
if store.IsCrossTenant(ctx) {
return s.scanServer(s.db.QueryRowContext(ctx,
`SELECT id, name, display_name, transport, command, args, url, headers, env,
api_key, tool_prefix, timeout_sec, settings, enabled, created_by, created_at, updated_at
FROM mcp_servers WHERE id = ?`, id))
}
tenantID := store.TenantIDFromContext(ctx)
if tenantID == uuid.Nil {
return nil, sql.ErrNoRows
}
return s.scanServer(s.db.QueryRowContext(ctx,
`SELECT id, name, display_name, transport, command, args, url, headers, env,
api_key, tool_prefix, timeout_sec, settings, enabled, created_by, created_at, updated_at
FROM mcp_servers WHERE id = ? AND tenant_id = ?`, id, tenantID))
}
func (s *SQLiteMCPServerStore) GetServerByName(ctx context.Context, name string) (*store.MCPServerData, error) {
if store.IsCrossTenant(ctx) {
return s.scanServer(s.db.QueryRowContext(ctx,
`SELECT id, name, display_name, transport, command, args, url, headers, env,
api_key, tool_prefix, timeout_sec, settings, enabled, created_by, created_at, updated_at
FROM mcp_servers WHERE name = ?`, name))
}
tenantID := store.TenantIDFromContext(ctx)
if tenantID == uuid.Nil {
return nil, sql.ErrNoRows
}
return s.scanServer(s.db.QueryRowContext(ctx,
`SELECT id, name, display_name, transport, command, args, url, headers, env,
api_key, tool_prefix, timeout_sec, settings, enabled, created_by, created_at, updated_at
FROM mcp_servers WHERE name = ? AND tenant_id = ?`, name, tenantID))
}
func (s *SQLiteMCPServerStore) scanServer(row *sql.Row) (*store.MCPServerData, error) {
var srv store.MCPServerData
var displayName, command, url, apiKey, toolPrefix *string
var args, headers, env *[]byte
createdAt, updatedAt := scanTimePair()
err := row.Scan(
&srv.ID, &srv.Name, &displayName, &srv.Transport, &command,
&args, &url, &headers, &env,
&apiKey, &toolPrefix, &srv.TimeoutSec,
&srv.Settings, &srv.Enabled, &srv.CreatedBy, createdAt, updatedAt,
)
if err != nil {
return nil, err
}
srv.CreatedAt = createdAt.Time
srv.UpdatedAt = updatedAt.Time
srv.DisplayName = derefStr(displayName)
srv.Command = derefStr(command)
srv.URL = derefStr(url)
srv.ToolPrefix = derefStr(toolPrefix)
srv.Args = derefBytes(args)
srv.Headers = s.decryptJSON(derefBytes(headers))
srv.Env = s.decryptJSON(derefBytes(env))
if apiKey != nil && *apiKey != "" && s.encKey != "" {
decrypted, err := crypto.Decrypt(*apiKey, s.encKey)
if err != nil {
slog.Warn("mcp: failed to decrypt api key", "server", srv.Name, "error", err)
} else {
srv.APIKey = decrypted
}
} else {
srv.APIKey = derefStr(apiKey)
}
return &srv, nil
}
func (s *SQLiteMCPServerStore) ListServers(ctx context.Context) ([]store.MCPServerData, error) {
query := `SELECT id, name, display_name, transport, command, args, url, headers, env,
api_key, tool_prefix, timeout_sec, settings, enabled, created_by, created_at, updated_at
FROM mcp_servers`
var qArgs []any
if !store.IsCrossTenant(ctx) {
tenantID := store.TenantIDFromContext(ctx)
if tenantID == uuid.Nil {
return []store.MCPServerData{}, nil
}
query += ` WHERE tenant_id = ?`
qArgs = append(qArgs, tenantID)
}
query += ` ORDER BY name`
rows, err := s.db.QueryContext(ctx, query, qArgs...)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]store.MCPServerData, 0)
for rows.Next() {
var srv store.MCPServerData
var displayName, command, url, apiKey, toolPrefix *string
var args, headers, env *[]byte
createdAt, updatedAt := scanTimePair()
if err := rows.Scan(
&srv.ID, &srv.Name, &displayName, &srv.Transport, &command,
&args, &url, &headers, &env,
&apiKey, &toolPrefix, &srv.TimeoutSec,
&srv.Settings, &srv.Enabled, &srv.CreatedBy, createdAt, updatedAt,
); err != nil {
continue
}
srv.CreatedAt = createdAt.Time
srv.UpdatedAt = updatedAt.Time
srv.DisplayName = derefStr(displayName)
srv.Command = derefStr(command)
srv.URL = derefStr(url)
srv.ToolPrefix = derefStr(toolPrefix)
srv.Args = derefBytes(args)
srv.Headers = s.decryptJSON(derefBytes(headers))
srv.Env = s.decryptJSON(derefBytes(env))
if apiKey != nil && *apiKey != "" && s.encKey != "" {
if decrypted, err := crypto.Decrypt(*apiKey, s.encKey); err == nil {
srv.APIKey = decrypted
}
} else {
srv.APIKey = derefStr(apiKey)
}
result = append(result, srv)
}
return result, rows.Err()
}
func (s *SQLiteMCPServerStore) UpdateServer(ctx context.Context, id uuid.UUID, updates map[string]any) error {
if key, ok := updates["api_key"]; ok {
if keyStr, isStr := key.(string); isStr && keyStr != "" && s.encKey != "" {
encrypted, err := crypto.Encrypt(keyStr, s.encKey)
if err != nil {
return fmt.Errorf("encrypt api key: %w", err)
}
updates["api_key"] = encrypted
}
}
for _, field := range []string{"env", "headers"} {
if v, ok := updates[field]; ok {
var raw []byte
switch val := v.(type) {
case json.RawMessage:
raw = []byte(val)
default:
raw, _ = json.Marshal(val)
}
if len(raw) > 0 {
updates[field] = json.RawMessage(s.encryptJSON(raw))
}
}
}
updates["updated_at"] = time.Now().UTC()
if store.IsCrossTenant(ctx) {
return execMapUpdate(ctx, s.db, "mcp_servers", id, updates)
}
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
return fmt.Errorf("tenant_id required for update")
}
return execMapUpdateWhereTenant(ctx, s.db, "mcp_servers", updates, id, tid)
}
func (s *SQLiteMCPServerStore) DeleteServer(ctx context.Context, id uuid.UUID) error {
if store.IsCrossTenant(ctx) {
_, err := s.db.ExecContext(ctx, "DELETE FROM mcp_servers WHERE id = ?", id)
return err
}
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
return fmt.Errorf("tenant_id required")
}
_, err := s.db.ExecContext(ctx, "DELETE FROM mcp_servers WHERE id = ? AND tenant_id = ?", id, tid)
return err
}
// encryptJSON encrypts a JSON blob by wrapping ciphertext as a JSON string.
// Unencrypted: {"key":"val"} (JSON object). Encrypted: "aes-gcm:..." (JSON string).
func (s *SQLiteMCPServerStore) encryptJSON(data []byte) []byte {
if s.encKey == "" || len(data) == 0 || string(data) == "{}" || string(data) == "null" {
return data
}
enc, err := crypto.Encrypt(string(data), s.encKey)
if err != nil {
slog.Warn("mcp: failed to encrypt json", "error", err)
return data
}
wrapped, _ := json.Marshal(enc)
return wrapped
}
// decryptJSON decrypts a JSON blob if it is an encrypted JSON string.
func (s *SQLiteMCPServerStore) decryptJSON(data []byte) []byte {
if s.encKey == "" || len(data) == 0 || data[0] != '"' {
return data
}
var encStr string
if json.Unmarshal(data, &encStr) != nil {
return data
}
dec, err := crypto.Decrypt(encStr, s.encKey)
if err != nil {
slog.Warn("mcp: failed to decrypt json", "error", err)
return data
}
return []byte(dec)
}
@@ -0,0 +1,391 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/crypto"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// --- Agent Grants ---
func (s *SQLiteMCPServerStore) GrantToAgent(ctx context.Context, g *store.MCPAgentGrant) error {
if err := store.ValidateUserID(g.GrantedBy); err != nil {
return err
}
if g.ID == uuid.Nil {
g.ID = store.GenNewID()
}
g.CreatedAt = time.Now().UTC()
_, err := s.db.ExecContext(ctx,
`INSERT INTO mcp_agent_grants (id, server_id, agent_id, enabled, tool_allow, tool_deny, config_overrides, granted_by, created_at, tenant_id)
VALUES (?,?,?,?,?,?,?,?,?,?)
ON CONFLICT (server_id, agent_id) DO UPDATE SET
enabled = excluded.enabled, tool_allow = excluded.tool_allow,
tool_deny = excluded.tool_deny, config_overrides = excluded.config_overrides,
granted_by = excluded.granted_by`,
g.ID, g.ServerID, g.AgentID, g.Enabled,
jsonOrNull(g.ToolAllow), jsonOrNull(g.ToolDeny), jsonOrNull(g.ConfigOverrides),
g.GrantedBy, g.CreatedAt, tenantIDForInsert(ctx),
)
return err
}
func (s *SQLiteMCPServerStore) RevokeFromAgent(ctx context.Context, serverID, agentID uuid.UUID) error {
tClause, tArgs, err := scopeClause(ctx)
if err != nil {
return err
}
_, err = s.db.ExecContext(ctx,
"DELETE FROM mcp_agent_grants WHERE server_id = ? AND agent_id = ?"+tClause,
append([]any{serverID, agentID}, tArgs...)...)
return err
}
func (s *SQLiteMCPServerStore) ListAgentGrants(ctx context.Context, agentID uuid.UUID) ([]store.MCPAgentGrant, error) {
tClause, tArgs, err := scopeClause(ctx)
if err != nil {
return nil, err
}
rows, err := s.db.QueryContext(ctx,
`SELECT id, server_id, agent_id, enabled, tool_allow, tool_deny, config_overrides, granted_by, created_at
FROM mcp_agent_grants WHERE agent_id = ?`+tClause,
append([]any{agentID}, tArgs...)...)
if err != nil {
return nil, err
}
defer rows.Close()
var result []store.MCPAgentGrant
for rows.Next() {
var g store.MCPAgentGrant
var toolAllow, toolDeny, configOverrides *string
var createdAt sqliteTime
if err := rows.Scan(&g.ID, &g.ServerID, &g.AgentID, &g.Enabled,
&toolAllow, &toolDeny, &configOverrides, &g.GrantedBy, &createdAt); err != nil {
continue
}
if toolAllow != nil {
g.ToolAllow = json.RawMessage(*toolAllow)
}
if toolDeny != nil {
g.ToolDeny = json.RawMessage(*toolDeny)
}
if configOverrides != nil {
g.ConfigOverrides = json.RawMessage(*configOverrides)
}
g.CreatedAt = createdAt.Time
result = append(result, g)
}
return result, rows.Err()
}
func (s *SQLiteMCPServerStore) ListServerGrants(ctx context.Context, serverID uuid.UUID) ([]store.MCPAgentGrant, error) {
tClause, tArgs, err := scopeClause(ctx)
if err != nil {
return nil, err
}
rows, err := s.db.QueryContext(ctx,
`SELECT id, server_id, agent_id, enabled,
COALESCE(tool_allow, '[]'), COALESCE(tool_deny, '[]'),
COALESCE(config_overrides, '{}'), granted_by, created_at
FROM mcp_agent_grants WHERE server_id = ?`+tClause+` ORDER BY created_at`,
append([]any{serverID}, tArgs...)...)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]store.MCPAgentGrant, 0)
for rows.Next() {
var g store.MCPAgentGrant
var toolAllow, toolDeny, configOverrides string
var createdAt sqliteTime
if err := rows.Scan(&g.ID, &g.ServerID, &g.AgentID, &g.Enabled,
&toolAllow, &toolDeny, &configOverrides, &g.GrantedBy, &createdAt); err != nil {
slog.Warn("mcp.list_server_grants.scan", "error", err)
continue
}
g.ToolAllow = json.RawMessage(toolAllow)
g.ToolDeny = json.RawMessage(toolDeny)
g.ConfigOverrides = json.RawMessage(configOverrides)
g.CreatedAt = createdAt.Time
result = append(result, g)
}
return result, rows.Err()
}
// --- Counts ---
func (s *SQLiteMCPServerStore) CountAgentGrantsByServer(ctx context.Context) (map[uuid.UUID]int, error) {
tClause, tArgs, err := scopeClause(ctx)
if err != nil {
return nil, err
}
rows, err := s.db.QueryContext(ctx,
`SELECT server_id, COUNT(*) FROM mcp_agent_grants WHERE 1=1`+tClause+` GROUP BY server_id`,
tArgs...)
if err != nil {
return nil, err
}
defer rows.Close()
result := make(map[uuid.UUID]int)
for rows.Next() {
var serverID uuid.UUID
var count int
if err := rows.Scan(&serverID, &count); err != nil {
continue
}
result[serverID] = count
}
return result, rows.Err()
}
// --- User Grants ---
func (s *SQLiteMCPServerStore) GrantToUser(ctx context.Context, g *store.MCPUserGrant) error {
if err := store.ValidateUserID(g.UserID); err != nil {
return err
}
if err := store.ValidateUserID(g.GrantedBy); err != nil {
return err
}
if g.ID == uuid.Nil {
g.ID = store.GenNewID()
}
g.CreatedAt = time.Now().UTC()
_, err := s.db.ExecContext(ctx,
`INSERT INTO mcp_user_grants (id, server_id, user_id, enabled, tool_allow, tool_deny, granted_by, created_at, tenant_id)
VALUES (?,?,?,?,?,?,?,?,?)
ON CONFLICT (server_id, user_id) DO UPDATE SET
enabled = excluded.enabled, tool_allow = excluded.tool_allow,
tool_deny = excluded.tool_deny, granted_by = excluded.granted_by`,
g.ID, g.ServerID, g.UserID, g.Enabled,
jsonOrNull(g.ToolAllow), jsonOrNull(g.ToolDeny),
g.GrantedBy, g.CreatedAt, tenantIDForInsert(ctx),
)
return err
}
func (s *SQLiteMCPServerStore) RevokeFromUser(ctx context.Context, serverID uuid.UUID, userID string) error {
tClause, tArgs, err := scopeClause(ctx)
if err != nil {
return err
}
_, err = s.db.ExecContext(ctx,
"DELETE FROM mcp_user_grants WHERE server_id = ? AND user_id = ?"+tClause,
append([]any{serverID, userID}, tArgs...)...)
return err
}
// --- Resolution ---
func (s *SQLiteMCPServerStore) ListAccessible(ctx context.Context, agentID uuid.UUID, userID string) ([]store.MCPAccessInfo, error) {
tClause, tArgs, err := scopeClauseAlias(ctx, "ms")
if err != nil {
return nil, err
}
rows, err := s.db.QueryContext(ctx,
`SELECT ms.id, ms.name, ms.display_name, ms.transport, ms.command, ms.args, ms.url, ms.headers, ms.env,
ms.api_key, ms.tool_prefix, ms.timeout_sec, ms.settings, ms.enabled, ms.created_by, ms.created_at, ms.updated_at,
mag.tool_allow, mag.tool_deny
FROM mcp_servers ms
INNER JOIN mcp_agent_grants mag ON ms.id = mag.server_id AND mag.agent_id = ? AND mag.enabled = 1
LEFT JOIN mcp_user_grants mug ON ms.id = mug.server_id AND mug.user_id = ?
WHERE ms.enabled = 1
AND (mug.id IS NULL OR mug.enabled = 1)`+tClause,
append([]any{agentID, userID}, tArgs...)...)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]store.MCPAccessInfo, 0)
for rows.Next() {
var srv store.MCPServerData
var displayName, command, url, apiKey, toolPrefix *string
var args, headers, env *[]byte
var toolAllowJSON, toolDenyJSON *[]byte
createdAt, updatedAt := scanTimePair()
if err := rows.Scan(
&srv.ID, &srv.Name, &displayName, &srv.Transport, &command,
&args, &url, &headers, &env,
&apiKey, &toolPrefix, &srv.TimeoutSec,
&srv.Settings, &srv.Enabled, &srv.CreatedBy, createdAt, updatedAt,
&toolAllowJSON, &toolDenyJSON,
); err != nil {
continue
}
srv.CreatedAt = createdAt.Time
srv.UpdatedAt = updatedAt.Time
srv.DisplayName = derefStr(displayName)
srv.Command = derefStr(command)
srv.URL = derefStr(url)
srv.ToolPrefix = derefStr(toolPrefix)
srv.Args = derefBytes(args)
srv.Headers = s.decryptJSON(derefBytes(headers))
srv.Env = s.decryptJSON(derefBytes(env))
if apiKey != nil && *apiKey != "" && s.encKey != "" {
if decrypted, err := crypto.Decrypt(*apiKey, s.encKey); err == nil {
srv.APIKey = decrypted
}
} else {
srv.APIKey = derefStr(apiKey)
}
info := store.MCPAccessInfo{Server: srv}
if toolAllowJSON != nil {
json.Unmarshal(*toolAllowJSON, &info.ToolAllow)
}
if toolDenyJSON != nil {
json.Unmarshal(*toolDenyJSON, &info.ToolDeny)
}
result = append(result, info)
}
return result, rows.Err()
}
// --- Access Requests ---
func (s *SQLiteMCPServerStore) CreateRequest(ctx context.Context, req *store.MCPAccessRequest) error {
if err := store.ValidateUserID(req.RequestedBy); err != nil {
return err
}
if req.ID == uuid.Nil {
req.ID = store.GenNewID()
}
req.Status = "pending"
req.CreatedAt = time.Now().UTC()
_, err := s.db.ExecContext(ctx,
`INSERT INTO mcp_access_requests (id, server_id, agent_id, user_id, scope, status, reason, tool_allow, requested_by, created_at, tenant_id)
VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
req.ID, req.ServerID, nilUUID(req.AgentID), nilStr(req.UserID),
req.Scope, req.Status, nilStr(req.Reason),
jsonOrNull(req.ToolAllow), req.RequestedBy, req.CreatedAt, tenantIDForInsert(ctx),
)
return err
}
func (s *SQLiteMCPServerStore) ListPendingRequests(ctx context.Context) ([]store.MCPAccessRequest, error) {
tClause, tArgs, err := scopeClause(ctx)
if err != nil {
return nil, err
}
rows, err := s.db.QueryContext(ctx,
`SELECT id, server_id, agent_id, user_id, scope, status, reason, tool_allow, requested_by,
reviewed_by, reviewed_at, review_note, created_at
FROM mcp_access_requests WHERE status = 'pending'`+tClause+` ORDER BY created_at`,
tArgs...)
if err != nil {
return nil, err
}
defer rows.Close()
var result []store.MCPAccessRequest
for rows.Next() {
var r store.MCPAccessRequest
var agentID *uuid.UUID
var userID, reviewedBy, reviewNote *string
var reviewedAtSt, createdAtSt sqliteTime
if err := rows.Scan(&r.ID, &r.ServerID, &agentID, &userID, &r.Scope, &r.Status,
&r.Reason, &r.ToolAllow, &r.RequestedBy,
&reviewedBy, &reviewedAtSt, &reviewNote, &createdAtSt); err != nil {
continue
}
if !reviewedAtSt.Time.IsZero() {
r.ReviewedAt = &reviewedAtSt.Time
}
r.CreatedAt = createdAtSt.Time
r.AgentID = agentID
r.UserID = derefStr(userID)
r.ReviewedBy = derefStr(reviewedBy)
r.ReviewNote = derefStr(reviewNote)
result = append(result, r)
}
return result, rows.Err()
}
func (s *SQLiteMCPServerStore) ReviewRequest(ctx context.Context, requestID uuid.UUID, approved bool, reviewedBy, note string) error {
if err := store.ValidateUserID(reviewedBy); err != nil {
return err
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
tClause, tArgs, err := scopeClause(ctx)
if err != nil {
return err
}
var req store.MCPAccessRequest
var agentID *uuid.UUID
var userID *string
err = tx.QueryRowContext(ctx,
`SELECT id, server_id, agent_id, user_id, scope, status, tool_allow
FROM mcp_access_requests WHERE id = ? AND status = 'pending'`+tClause,
append([]any{requestID}, tArgs...)...,
).Scan(&req.ID, &req.ServerID, &agentID, &userID, &req.Scope, &req.Status, &req.ToolAllow)
if err != nil {
return fmt.Errorf("request not found or not pending: %w", err)
}
status := "rejected"
if approved {
status = "approved"
}
now := time.Now().UTC()
_, err = tx.ExecContext(ctx,
`UPDATE mcp_access_requests SET status = ?, reviewed_by = ?, reviewed_at = ?, review_note = ? WHERE id = ?`,
status, reviewedBy, now, nilStr(note), requestID,
)
if err != nil {
return err
}
if approved {
switch req.Scope {
case "agent":
if agentID == nil {
return fmt.Errorf("agent_id required for agent scope")
}
_, err = tx.ExecContext(ctx,
`INSERT INTO mcp_agent_grants (id, server_id, agent_id, enabled, tool_allow, granted_by, created_at, tenant_id)
VALUES (?,?,?,1,?,?,?,?)
ON CONFLICT (server_id, agent_id) DO UPDATE SET enabled = 1, tool_allow = excluded.tool_allow, granted_by = excluded.granted_by`,
store.GenNewID(), req.ServerID, *agentID, jsonOrNull(req.ToolAllow), reviewedBy, now, tenantIDForInsert(ctx),
)
case "user":
if userID == nil || *userID == "" {
return fmt.Errorf("user_id required for user scope")
}
_, err = tx.ExecContext(ctx,
`INSERT INTO mcp_user_grants (id, server_id, user_id, enabled, tool_allow, granted_by, created_at, tenant_id)
VALUES (?,?,?,1,?,?,?,?)
ON CONFLICT (server_id, user_id) DO UPDATE SET enabled = 1, tool_allow = excluded.tool_allow, granted_by = excluded.granted_by`,
store.GenNewID(), req.ServerID, *userID, jsonOrNull(req.ToolAllow), reviewedBy, now, tenantIDForInsert(ctx),
)
default:
return fmt.Errorf("unknown scope: %s", req.Scope)
}
if err != nil {
return err
}
}
return tx.Commit()
}
@@ -0,0 +1,103 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/crypto"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// GetUserCredentials returns per-user credential overrides for an MCP server.
// Returns (nil, nil) if no per-user credentials exist.
func (s *SQLiteMCPServerStore) GetUserCredentials(ctx context.Context, serverID uuid.UUID, userID string) (*store.MCPUserCredentials, error) {
tid := tenantIDForInsert(ctx)
var apiKey sql.NullString
var headersEnc, envEnc []byte
err := s.db.QueryRowContext(ctx,
`SELECT api_key, headers, env FROM mcp_user_credentials
WHERE server_id = ? AND user_id = ? AND tenant_id = ?`,
serverID, userID, tid,
).Scan(&apiKey, &headersEnc, &envEnc)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
creds := &store.MCPUserCredentials{}
if apiKey.Valid && apiKey.String != "" && s.encKey != "" {
if dec, err := crypto.Decrypt(apiKey.String, s.encKey); err == nil {
creds.APIKey = dec
}
} else if apiKey.Valid {
creds.APIKey = apiKey.String
}
if len(headersEnc) > 0 {
dec := s.decryptJSON(headersEnc)
json.Unmarshal(dec, &creds.Headers)
}
if len(envEnc) > 0 {
dec := s.decryptJSON(envEnc)
json.Unmarshal(dec, &creds.Env)
}
return creds, nil
}
// SetUserCredentials creates or updates per-user MCP credentials.
func (s *SQLiteMCPServerStore) SetUserCredentials(ctx context.Context, serverID uuid.UUID, userID string, creds store.MCPUserCredentials) error {
tid := tenantIDForInsert(ctx)
var apiKeyEnc sql.NullString
if creds.APIKey != "" && s.encKey != "" {
enc, err := crypto.Encrypt(creds.APIKey, s.encKey)
if err != nil {
return fmt.Errorf("encrypt mcp user api_key: %w", err)
}
apiKeyEnc = sql.NullString{String: enc, Valid: true}
} else if creds.APIKey != "" {
apiKeyEnc = sql.NullString{String: creds.APIKey, Valid: true}
}
var headersEnc, envEnc []byte
if len(creds.Headers) > 0 {
raw, _ := json.Marshal(creds.Headers)
headersEnc = s.encryptJSON(raw)
}
if len(creds.Env) > 0 {
raw, _ := json.Marshal(creds.Env)
envEnc = s.encryptJSON(raw)
}
now := time.Now().UTC()
id := store.GenNewID()
_, err := s.db.ExecContext(ctx,
`INSERT INTO mcp_user_credentials (id, server_id, user_id, api_key, headers, env, tenant_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (server_id, user_id, tenant_id) DO UPDATE SET
api_key = excluded.api_key, headers = excluded.headers, env = excluded.env, updated_at = excluded.updated_at`,
id, serverID, userID, apiKeyEnc, headersEnc, envEnc, tid, now, now,
)
return err
}
// DeleteUserCredentials removes per-user MCP credentials.
func (s *SQLiteMCPServerStore) DeleteUserCredentials(ctx context.Context, serverID uuid.UUID, userID string) error {
tid := tenantIDForInsert(ctx)
_, err := s.db.ExecContext(ctx,
`DELETE FROM mcp_user_credentials WHERE server_id = ? AND user_id = ? AND tenant_id = ?`,
serverID, userID, tid,
)
return err
}
+86
View File
@@ -0,0 +1,86 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"database/sql"
"sync"
"time"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// SQLiteMemoryStore implements store.MemoryStore backed by SQLite.
// Vector search is not available in the Lite edition (VectorSearch: false).
// FTS uses simple LIKE queries instead of tsvector.
type SQLiteMemoryStore struct {
db *sql.DB
provider store.EmbeddingProvider
mu sync.RWMutex
cfg SQLiteMemoryConfig
}
// SQLiteMemoryConfig configures the SQLite memory store.
type SQLiteMemoryConfig struct {
MaxChunkLen int
ChunkOverlap int
MaxResults int
TextWeight float64
VectorWeight float64
}
// DefaultSQLiteMemoryConfig returns sensible defaults.
func DefaultSQLiteMemoryConfig() SQLiteMemoryConfig {
return SQLiteMemoryConfig{
MaxChunkLen: 1000,
ChunkOverlap: 200,
MaxResults: 6,
TextWeight: 1.0,
VectorWeight: 0.0, // no vector search in SQLite edition
}
}
// NewSQLiteMemoryStore creates a new SQLite-backed memory store.
func NewSQLiteMemoryStore(db *sql.DB) *SQLiteMemoryStore {
return &SQLiteMemoryStore{db: db, cfg: DefaultSQLiteMemoryConfig()}
}
// SetEmbeddingProvider stores the provider reference but embeddings are not
// persisted in SQLite (no vector column). Kept for interface compatibility.
func (s *SQLiteMemoryStore) SetEmbeddingProvider(provider store.EmbeddingProvider) {
s.provider = provider
}
// UpdateChunkConfig updates chunk splitting parameters at runtime.
func (s *SQLiteMemoryStore) UpdateChunkConfig(maxChunkLen, chunkOverlap int) {
s.mu.Lock()
defer s.mu.Unlock()
if maxChunkLen > 0 {
s.cfg.MaxChunkLen = maxChunkLen
}
if chunkOverlap >= 0 {
s.cfg.ChunkOverlap = chunkOverlap
}
}
// chunkConfig returns current chunk parameters (thread-safe).
func (s *SQLiteMemoryStore) chunkConfig() (maxLen, overlap int) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.cfg.MaxChunkLen, s.cfg.ChunkOverlap
}
func (s *SQLiteMemoryStore) Close() error { return nil }
// scanDocumentRow scans (path, hash, user_id, updated_at) into DocumentInfo.
func scanDocumentRow(path, hash string, uid *string, updatedAt time.Time) store.DocumentInfo {
info := store.DocumentInfo{
Path: path,
Hash: hash,
UpdatedAt: updatedAt.UnixMilli(),
}
if uid != nil {
info.UserID = *uid
}
return info
}
+401
View File
@@ -0,0 +1,401 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"database/sql"
"fmt"
"log/slog"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/memory"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func (s *SQLiteMemoryStore) GetDocument(ctx context.Context, agentID, userID, path string) (string, error) {
aid := agentID
var content string
var err error
if userID == "" {
tc, tcArgs, tcErr := scopeClause(ctx)
if tcErr != nil {
return "", tcErr
}
err = s.db.QueryRowContext(ctx,
"SELECT content FROM memory_documents WHERE agent_id = ? AND path = ? AND user_id IS NULL"+tc,
append([]any{aid, path}, tcArgs...)...).Scan(&content)
} else {
tc, tcArgs, tcErr := scopeClause(ctx)
if tcErr != nil {
return "", tcErr
}
err = s.db.QueryRowContext(ctx,
"SELECT content FROM memory_documents WHERE agent_id = ? AND path = ? AND user_id = ?"+tc,
append([]any{aid, path, userID}, tcArgs...)...).Scan(&content)
}
if err != nil {
return "", err
}
return content, nil
}
func (s *SQLiteMemoryStore) PutDocument(ctx context.Context, agentID, userID, path, content string) error {
hash := memory.ContentHash(content)
id := uuid.Must(uuid.NewV7()).String()
now := time.Now().UTC()
tid := tenantIDForInsert(ctx).String()
var uid *string
if userID != "" {
uid = &userID
}
_, err := s.db.ExecContext(ctx,
`INSERT INTO memory_documents (id, agent_id, user_id, path, content, hash, tenant_id, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (agent_id, COALESCE(user_id, ''), path)
DO UPDATE SET content = excluded.content, hash = excluded.hash,
tenant_id = excluded.tenant_id, updated_at = excluded.updated_at`,
id, agentID, uid, path, content, hash, tid, now,
)
return err
}
func (s *SQLiteMemoryStore) DeleteDocument(ctx context.Context, agentID, userID, path string) error {
var res sql.Result
var err error
if userID == "" {
tc, tcArgs, tcErr := scopeClause(ctx)
if tcErr != nil {
return tcErr
}
res, err = s.db.ExecContext(ctx,
"DELETE FROM memory_documents WHERE agent_id = ? AND path = ? AND user_id IS NULL"+tc,
append([]any{agentID, path}, tcArgs...)...)
} else {
tc, tcArgs, tcErr := scopeClause(ctx)
if tcErr != nil {
return tcErr
}
res, err = s.db.ExecContext(ctx,
"DELETE FROM memory_documents WHERE agent_id = ? AND path = ? AND user_id = ?"+tc,
append([]any{agentID, path, userID}, tcArgs...)...)
}
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("document not found: %s", path)
}
return nil
}
func (s *SQLiteMemoryStore) ListDocuments(ctx context.Context, agentID, userID string) ([]store.DocumentInfo, error) {
var rows *sql.Rows
var err error
if userID == "" {
tc, tcArgs, tcErr := scopeClause(ctx)
if tcErr != nil {
return nil, tcErr
}
rows, err = s.db.QueryContext(ctx,
"SELECT path, hash, user_id, updated_at FROM memory_documents WHERE agent_id = ? AND user_id IS NULL"+tc,
append([]any{agentID}, tcArgs...)...)
} else {
tc, tcArgs, tcErr := scopeClause(ctx)
if tcErr != nil {
return nil, tcErr
}
rows, err = s.db.QueryContext(ctx,
"SELECT path, hash, user_id, updated_at FROM memory_documents WHERE agent_id = ? AND (user_id IS NULL OR user_id = ?)"+tc,
append([]any{agentID, userID}, tcArgs...)...)
}
if err != nil {
return nil, err
}
defer rows.Close()
var result []store.DocumentInfo
for rows.Next() {
var path, hash string
var uid *string
var updatedAt time.Time
if err := rows.Scan(&path, &hash, &uid, &updatedAt); err != nil {
continue
}
result = append(result, scanDocumentRow(path, hash, uid, updatedAt))
}
if err := rows.Err(); err != nil {
return nil, err
}
return result, nil
}
// IndexDocument chunks a document and stores chunks (without embeddings in SQLite).
func (s *SQLiteMemoryStore) IndexDocument(ctx context.Context, agentID, userID, path string) error {
content, err := s.GetDocument(ctx, agentID, userID, path)
if err != nil {
return err
}
// Get document ID
var docID string
if userID == "" {
tc, tcArgs, tcErr := scopeClause(ctx)
if tcErr != nil {
return tcErr
}
err = s.db.QueryRowContext(ctx,
"SELECT id FROM memory_documents WHERE agent_id = ? AND path = ? AND user_id IS NULL"+tc,
append([]any{agentID, path}, tcArgs...)...).Scan(&docID)
} else {
tc, tcArgs, tcErr := scopeClause(ctx)
if tcErr != nil {
return tcErr
}
err = s.db.QueryRowContext(ctx,
"SELECT id FROM memory_documents WHERE agent_id = ? AND path = ? AND user_id = ?"+tc,
append([]any{agentID, path, userID}, tcArgs...)...).Scan(&docID)
}
if err != nil {
return err
}
// Delete old chunks
if _, delErr := s.db.ExecContext(ctx, "DELETE FROM memory_chunks WHERE document_id = ?", docID); delErr != nil {
return fmt.Errorf("delete old chunks: %w", delErr)
}
// Resolve chunk config: per-agent override → global default
chunkLen, chunkOverlap := s.chunkConfig()
if rc := store.RunContextFromCtx(ctx); rc != nil && rc.MemoryCfg != nil {
if rc.MemoryCfg.MaxChunkLen > 0 {
chunkLen = rc.MemoryCfg.MaxChunkLen
}
if rc.MemoryCfg.ChunkOverlap > 0 {
chunkOverlap = rc.MemoryCfg.ChunkOverlap
}
}
chunks := memory.ChunkText(content, chunkLen, chunkOverlap)
if len(chunks) == 0 {
return nil
}
tid := tenantIDForInsert(ctx).String()
var uid *string
if userID != "" {
uid = &userID
}
for _, tc := range chunks {
hash := memory.ContentHash(tc.Text)
chunkID := uuid.Must(uuid.NewV7()).String()
now := time.Now().UTC()
if _, err := s.db.ExecContext(ctx,
`INSERT INTO memory_chunks (id, agent_id, document_id, user_id, path, start_line, end_line, hash, text, tenant_id, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT DO NOTHING`,
chunkID, agentID, docID, uid, path, tc.StartLine, tc.EndLine, hash, tc.Text, tid, now,
); err != nil {
slog.Warn("memory: insert chunk failed", "path", path, "error", err)
}
}
return nil
}
func (s *SQLiteMemoryStore) IndexAll(ctx context.Context, agentID, userID string) error {
docs, err := s.ListDocuments(ctx, agentID, userID)
if err != nil {
return err
}
for _, doc := range docs {
s.IndexDocument(ctx, agentID, doc.UserID, doc.Path)
}
return nil
}
// ListAllDocumentsGlobal returns all documents across all agents (admin overview).
func (s *SQLiteMemoryStore) ListAllDocumentsGlobal(ctx context.Context) ([]store.DocumentInfo, error) {
var q string
var args []any
if !store.IsCrossTenant(ctx) {
tid, err := requireTenantID(ctx)
if err != nil {
return nil, err
}
q = `SELECT agent_id, path, hash, user_id, updated_at
FROM memory_documents WHERE tenant_id = ? ORDER BY updated_at DESC`
args = []any{tid.String()}
} else {
q = `SELECT agent_id, path, hash, user_id, updated_at
FROM memory_documents ORDER BY updated_at DESC`
}
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var result []store.DocumentInfo
for rows.Next() {
var agentID, path, hash string
var uid *string
var updatedAt time.Time
if err := rows.Scan(&agentID, &path, &hash, &uid, &updatedAt); err != nil {
continue
}
info := scanDocumentRow(path, hash, uid, updatedAt)
info.AgentID = agentID
result = append(result, info)
}
if err := rows.Err(); err != nil {
return nil, err
}
return result, nil
}
// ListAllDocuments returns all documents for an agent across all users.
func (s *SQLiteMemoryStore) ListAllDocuments(ctx context.Context, agentID string) ([]store.DocumentInfo, error) {
tc, tcArgs, err := scopeClause(ctx)
if err != nil {
return nil, err
}
rows, err := s.db.QueryContext(ctx,
`SELECT agent_id, path, hash, user_id, updated_at
FROM memory_documents WHERE agent_id = ?`+tc+`
ORDER BY updated_at DESC`,
append([]any{agentID}, tcArgs...)...)
if err != nil {
return nil, err
}
defer rows.Close()
var result []store.DocumentInfo
for rows.Next() {
var aID, path, hash string
var uid *string
var updatedAt time.Time
if err := rows.Scan(&aID, &path, &hash, &uid, &updatedAt); err != nil {
continue
}
info := scanDocumentRow(path, hash, uid, updatedAt)
info.AgentID = aID
result = append(result, info)
}
if err := rows.Err(); err != nil {
return nil, err
}
return result, nil
}
// GetDocumentDetail returns full document info with chunk count.
// EmbeddedCount is always 0 (no embedding column in SQLite).
func (s *SQLiteMemoryStore) GetDocumentDetail(ctx context.Context, agentID, userID, path string) (*store.DocumentDetail, error) {
var q string
var args []any
if userID == "" {
tc, tcArgs, err := scopeClauseAlias(ctx, "d")
if err != nil {
return nil, err
}
q = `SELECT d.path, d.content, d.hash, d.user_id, d.created_at, d.updated_at,
COUNT(c.id) AS chunk_count
FROM memory_documents d
LEFT JOIN memory_chunks c ON c.document_id = d.id
WHERE d.agent_id = ? AND d.path = ? AND d.user_id IS NULL` + tc + `
GROUP BY d.id`
args = append([]any{agentID, path}, tcArgs...)
} else {
tc, tcArgs, err := scopeClauseAlias(ctx, "d")
if err != nil {
return nil, err
}
q = `SELECT d.path, d.content, d.hash, d.user_id, d.created_at, d.updated_at,
COUNT(c.id) AS chunk_count
FROM memory_documents d
LEFT JOIN memory_chunks c ON c.document_id = d.id
WHERE d.agent_id = ? AND d.path = ? AND d.user_id = ?` + tc + `
GROUP BY d.id`
args = append([]any{agentID, path, userID}, tcArgs...)
}
var detail store.DocumentDetail
var uid *string
var createdAt, updatedAt time.Time
err := s.db.QueryRowContext(ctx, q, args...).Scan(
&detail.Path, &detail.Content, &detail.Hash, &uid,
&createdAt, &updatedAt, &detail.ChunkCount,
)
if err != nil {
return nil, err
}
if uid != nil {
detail.UserID = *uid
}
detail.CreatedAt = createdAt.UnixMilli()
detail.UpdatedAt = updatedAt.UnixMilli()
// EmbeddedCount always 0 — no embedding column in SQLite
return &detail, nil
}
// ListChunks returns chunks for a document identified by agent, user, and path.
func (s *SQLiteMemoryStore) ListChunks(ctx context.Context, agentID, userID, path string) ([]store.ChunkInfo, error) {
var q string
var args []any
if userID == "" {
tc, tcArgs, err := scopeClauseAlias(ctx, "d")
if err != nil {
return nil, err
}
q = `SELECT c.id, c.start_line, c.end_line, c.text
FROM memory_chunks c
JOIN memory_documents d ON c.document_id = d.id
WHERE d.agent_id = ? AND d.path = ? AND d.user_id IS NULL` + tc + `
ORDER BY c.start_line`
args = append([]any{agentID, path}, tcArgs...)
} else {
tc, tcArgs, err := scopeClauseAlias(ctx, "d")
if err != nil {
return nil, err
}
q = `SELECT c.id, c.start_line, c.end_line, c.text
FROM memory_chunks c
JOIN memory_documents d ON c.document_id = d.id
WHERE d.agent_id = ? AND d.path = ? AND d.user_id = ?` + tc + `
ORDER BY c.start_line`
args = append([]any{agentID, path, userID}, tcArgs...)
}
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var result []store.ChunkInfo
for rows.Next() {
var ci store.ChunkInfo
if err := rows.Scan(&ci.ID, &ci.StartLine, &ci.EndLine, &ci.TextPreview); err != nil {
continue
}
// HasEmbedding always false — no embedding column in SQLite
result = append(result, ci)
}
if err := rows.Err(); err != nil {
return nil, err
}
return result, nil
}
+120
View File
@@ -0,0 +1,120 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"strings"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// Search performs LIKE-based text search over memory_chunks.
// Vector search is not available in the SQLite edition — VectorSearch is always false.
// Merges global (user_id IS NULL) + per-user chunks, with user boost.
func (s *SQLiteMemoryStore) Search(ctx context.Context, query string, agentID, userID string, opts store.MemorySearchOptions) ([]store.MemorySearchResult, error) {
maxResults := opts.MaxResults
if maxResults <= 0 {
maxResults = s.cfg.MaxResults
}
results, err := s.likeSearch(ctx, query, agentID, userID, maxResults*2)
if err != nil {
return nil, err
}
// Apply filters and cap results
var filtered []store.MemorySearchResult
for _, r := range results {
if opts.MinScore > 0 && r.Score < opts.MinScore {
continue
}
if opts.PathPrefix != "" && !strings.HasPrefix(r.Path, opts.PathPrefix) {
continue
}
filtered = append(filtered, r)
if len(filtered) >= maxResults {
break
}
}
return filtered, nil
}
// likeSearch performs a case-insensitive LIKE search across chunk text.
// Returns results scored 1.0 (global) or 1.2 (personal, boosted).
func (s *SQLiteMemoryStore) likeSearch(ctx context.Context, query, agentID, userID string, limit int) ([]store.MemorySearchResult, error) {
pattern := "%" + escapeLike(query) + "%"
var q string
var args []any
if userID != "" {
tc, tcArgs, err := scopeClause(ctx)
if err != nil {
return nil, err
}
q = `SELECT path, start_line, end_line, text, user_id
FROM memory_chunks
WHERE agent_id = ? AND (user_id IS NULL OR user_id = ?)
AND text LIKE ? ESCAPE '\'` + tc + `
ORDER BY user_id DESC
LIMIT ?`
args = append([]any{agentID, userID, pattern}, tcArgs...)
args = append(args, limit)
} else {
tc, tcArgs, err := scopeClause(ctx)
if err != nil {
return nil, err
}
q = `SELECT path, start_line, end_line, text, user_id
FROM memory_chunks
WHERE agent_id = ? AND user_id IS NULL
AND text LIKE ? ESCAPE '\'` + tc + `
LIMIT ?`
args = append([]any{agentID, pattern}, tcArgs...)
args = append(args, limit)
}
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var results []store.MemorySearchResult
for rows.Next() {
var path, text string
var startLine, endLine int
var uid *string
if err := rows.Scan(&path, &startLine, &endLine, &text, &uid); err != nil {
continue
}
scope := "global"
score := 1.0
if uid != nil && *uid != "" {
scope = "personal"
score = 1.2 // personal boost, mirrors PG hybrid merge
}
results = append(results, store.MemorySearchResult{
Path: path,
StartLine: startLine,
EndLine: endLine,
Score: score,
Snippet: text,
Source: "memory",
Scope: scope,
})
}
if err := rows.Err(); err != nil {
return nil, err
}
return results, nil
}
// escapeLike escapes special LIKE metacharacters: % _ \
func escapeLike(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, `%`, `\%`)
s = strings.ReplaceAll(s, `_`, `\_`)
return s
}
+243
View File
@@ -0,0 +1,243 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"crypto/rand"
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
const (
codeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
codeLength = 8
codeTTL = 60 * time.Minute
pairedDeviceTTL = 30 * 24 * time.Hour
maxPendingPerAccount = 3
)
// SQLitePairingStore implements store.PairingStore backed by SQLite.
type SQLitePairingStore struct {
db *sql.DB
onRequest func(code, senderID, channel, chatID string)
}
func NewSQLitePairingStore(db *sql.DB) *SQLitePairingStore {
return &SQLitePairingStore{db: db}
}
func (s *SQLitePairingStore) SetOnRequest(cb func(code, senderID, channel, chatID string)) {
s.onRequest = cb
}
func (s *SQLitePairingStore) RequestPairing(ctx context.Context, senderID, channel, chatID, accountID string, metadata map[string]string) (string, error) {
tid := tenantIDForInsert(ctx)
now := time.Now()
s.db.ExecContext(ctx, "DELETE FROM pairing_requests WHERE expires_at < ?", now)
var count int64
s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM pairing_requests WHERE account_id = ? AND tenant_id = ?", accountID, tid).Scan(&count)
if count >= maxPendingPerAccount {
return "", fmt.Errorf("max pending pairing requests (%d) exceeded", maxPendingPerAccount)
}
var existingCode string
err := s.db.QueryRowContext(ctx, "SELECT code FROM pairing_requests WHERE sender_id = ? AND channel = ? AND tenant_id = ?", senderID, channel, tid).Scan(&existingCode)
if err == nil {
return existingCode, nil
}
metaJSON := []byte("{}")
if len(metadata) > 0 {
metaJSON, _ = json.Marshal(metadata)
}
code := generatePairingCode()
_, err = s.db.ExecContext(ctx,
`INSERT INTO pairing_requests (id, code, sender_id, channel, chat_id, account_id, expires_at, created_at, metadata, tenant_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
uuid.Must(uuid.NewV7()), code, senderID, channel, chatID, accountID, now.Add(codeTTL), now, metaJSON, tid,
)
if err != nil {
return "", fmt.Errorf("create pairing request: %w", err)
}
if s.onRequest != nil {
go s.onRequest(code, senderID, channel, chatID)
}
return code, nil
}
func (s *SQLitePairingStore) ApprovePairing(ctx context.Context, code, approvedBy string) (*store.PairedDeviceData, error) {
now := time.Now()
s.db.ExecContext(ctx, "DELETE FROM pairing_requests WHERE expires_at < ?", now)
var reqID uuid.UUID
var senderID, channel, chatID string
var metaJSON []byte
var reqTenantID uuid.UUID
err := s.db.QueryRowContext(ctx,
"SELECT id, sender_id, channel, chat_id, COALESCE(metadata, '{}'), tenant_id FROM pairing_requests WHERE code = ?", code,
).Scan(&reqID, &senderID, &channel, &chatID, &metaJSON, &reqTenantID)
if err != nil {
return nil, fmt.Errorf("pairing code %s not found or expired", code)
}
s.db.ExecContext(ctx, "DELETE FROM pairing_requests WHERE id = ?", reqID)
expiresAt := now.Add(pairedDeviceTTL)
_, err = s.db.ExecContext(ctx,
`INSERT INTO paired_devices (id, sender_id, channel, chat_id, paired_by, paired_at, metadata, expires_at, tenant_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
uuid.Must(uuid.NewV7()), senderID, channel, chatID, approvedBy, now, metaJSON, expiresAt, reqTenantID,
)
if err != nil {
return nil, fmt.Errorf("create paired device: %w", err)
}
var meta map[string]string
if len(metaJSON) > 0 {
json.Unmarshal(metaJSON, &meta)
}
return &store.PairedDeviceData{
SenderID: senderID,
Channel: channel,
ChatID: chatID,
PairedAt: now.UnixMilli(),
PairedBy: approvedBy,
Metadata: meta,
}, nil
}
func (s *SQLitePairingStore) DenyPairing(ctx context.Context, code string) error {
result, err := s.db.ExecContext(ctx, "DELETE FROM pairing_requests WHERE code = ?", code)
if err != nil {
return err
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("pairing code %s not found or expired", code)
}
return nil
}
func (s *SQLitePairingStore) RevokePairing(ctx context.Context, senderID, channel string) error {
tid := tenantIDForInsert(ctx)
result, err := s.db.ExecContext(ctx, "DELETE FROM paired_devices WHERE sender_id = ? AND channel = ? AND tenant_id = ?", senderID, channel, tid)
if err != nil {
return err
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("paired device not found: %s/%s", channel, senderID)
}
return nil
}
func (s *SQLitePairingStore) IsPaired(ctx context.Context, senderID, channel string) (bool, error) {
tid := tenantIDForInsert(ctx)
var count int64
err := s.db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM paired_devices WHERE sender_id = ? AND channel = ? AND tenant_id = ? AND (expires_at IS NULL OR expires_at > ?)",
senderID, channel, tid, time.Now(),
).Scan(&count)
if err != nil {
return false, fmt.Errorf("pairing check query: %w", err)
}
return count > 0, nil
}
func (s *SQLitePairingStore) ListPending(ctx context.Context) []store.PairingRequestData {
tid := tenantIDForInsert(ctx)
now := time.Now()
s.db.ExecContext(ctx, "DELETE FROM pairing_requests WHERE expires_at < ?", now)
rows, err := s.db.QueryContext(ctx,
`SELECT code, sender_id, channel, chat_id, account_id, created_at, expires_at, COALESCE(metadata, '{}')
FROM pairing_requests WHERE tenant_id = ? ORDER BY created_at DESC`, tid)
if err != nil {
return nil
}
defer rows.Close()
var result []store.PairingRequestData
for rows.Next() {
var d store.PairingRequestData
var createdAt, expiresAt time.Time
var metaJSON []byte
if err := rows.Scan(&d.Code, &d.SenderID, &d.Channel, &d.ChatID, &d.AccountID, &createdAt, &expiresAt, &metaJSON); err != nil {
continue
}
d.CreatedAt = createdAt.UnixMilli()
d.ExpiresAt = expiresAt.UnixMilli()
if len(metaJSON) > 0 {
json.Unmarshal(metaJSON, &d.Metadata)
}
result = append(result, d)
}
if err := rows.Err(); err != nil {
slog.Warn("pairing: list pending iteration error", "error", err)
}
if result == nil {
return []store.PairingRequestData{}
}
return result
}
func (s *SQLitePairingStore) ListPaired(ctx context.Context) []store.PairedDeviceData {
tid := tenantIDForInsert(ctx)
now := time.Now()
s.db.ExecContext(ctx, "DELETE FROM paired_devices WHERE expires_at IS NOT NULL AND expires_at < ?", now)
rows, err := s.db.QueryContext(ctx,
`SELECT sender_id, channel, chat_id, paired_by, paired_at, COALESCE(metadata, '{}')
FROM paired_devices WHERE tenant_id = ? ORDER BY paired_at DESC`, tid)
if err != nil {
return nil
}
defer rows.Close()
var result []store.PairedDeviceData
for rows.Next() {
var d store.PairedDeviceData
var pairedAt time.Time
var metaJSON []byte
if err := rows.Scan(&d.SenderID, &d.Channel, &d.ChatID, &d.PairedBy, &pairedAt, &metaJSON); err != nil {
continue
}
d.PairedAt = pairedAt.UnixMilli()
if len(metaJSON) > 0 {
json.Unmarshal(metaJSON, &d.Metadata)
}
result = append(result, d)
}
if err := rows.Err(); err != nil {
slog.Warn("pairing: list paired iteration error", "error", err)
}
if result == nil {
return []store.PairedDeviceData{}
}
return result
}
func generatePairingCode() string {
b := make([]byte, codeLength)
rand.Read(b)
code := make([]byte, codeLength)
for i := range code {
code[i] = codeAlphabet[int(b[i])%len(codeAlphabet)]
}
return string(code)
}
@@ -0,0 +1,286 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// SQLitePendingMessageStore implements store.PendingMessageStore backed by SQLite.
type SQLitePendingMessageStore struct {
db *sql.DB
}
func NewSQLitePendingMessageStore(db *sql.DB) *SQLitePendingMessageStore {
return &SQLitePendingMessageStore{db: db}
}
func (s *SQLitePendingMessageStore) AppendBatch(ctx context.Context, msgs []store.PendingMessage) error {
if len(msgs) == 0 {
return nil
}
const cols = 11
placeholders := make([]string, len(msgs))
args := make([]any, 0, len(msgs)*cols)
now := time.Now()
tid := tenantIDForInsert(ctx)
for i := range msgs {
if msgs[i].ID == uuid.Nil {
msgs[i].ID = uuid.Must(uuid.NewV7())
}
placeholders[i] = "(?,?,?,?,?,?,?,?,?,?,?)"
args = append(args, msgs[i].ID, msgs[i].ChannelName, msgs[i].HistoryKey,
msgs[i].Sender, msgs[i].SenderID, msgs[i].Body, msgs[i].PlatformMsgID, msgs[i].IsSummary, now, now, tid)
}
_, err := s.db.ExecContext(ctx,
`INSERT INTO channel_pending_messages (id, channel_name, history_key, sender, sender_id, body, platform_msg_id, is_summary, created_at, updated_at, tenant_id)
VALUES `+strings.Join(placeholders, ","),
args...,
)
return err
}
func (s *SQLitePendingMessageStore) ListByKey(ctx context.Context, channelName, historyKey string) ([]store.PendingMessage, error) {
tClause, tArgs, err := scopeClause(ctx)
if err != nil {
return nil, err
}
args := append([]any{channelName, historyKey}, tArgs...)
rows, err := s.db.QueryContext(ctx,
`SELECT id, channel_name, history_key, sender, sender_id, body, platform_msg_id, is_summary, created_at, updated_at
FROM channel_pending_messages
WHERE channel_name = ? AND history_key = ?`+tClause+`
ORDER BY created_at ASC`,
args...,
)
if err != nil {
return nil, err
}
defer rows.Close()
var result []store.PendingMessage
for rows.Next() {
var m store.PendingMessage
createdAt, updatedAt := scanTimePair()
if err := rows.Scan(&m.ID, &m.ChannelName, &m.HistoryKey, &m.Sender, &m.SenderID, &m.Body, &m.PlatformMsgID, &m.IsSummary, createdAt, updatedAt); err != nil {
return nil, err
}
m.CreatedAt = createdAt.Time
m.UpdatedAt = updatedAt.Time
result = append(result, m)
}
return result, rows.Err()
}
func (s *SQLitePendingMessageStore) DeleteByKey(ctx context.Context, channelName, historyKey string) error {
tClause, tArgs, err := scopeClause(ctx)
if err != nil {
return err
}
args := append([]any{channelName, historyKey}, tArgs...)
_, err = s.db.ExecContext(ctx,
`DELETE FROM channel_pending_messages WHERE channel_name = ? AND history_key = ?`+tClause,
args...,
)
return err
}
func (s *SQLitePendingMessageStore) Compact(ctx context.Context, deleteIDs []uuid.UUID, summary *store.PendingMessage) error {
if len(deleteIDs) == 0 {
return nil
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin compact tx: %w", err)
}
defer tx.Rollback()
placeholders := make([]string, len(deleteIDs))
args := make([]any, len(deleteIDs))
for i, id := range deleteIDs {
placeholders[i] = "?"
args[i] = id
}
res, err := tx.ExecContext(ctx,
fmt.Sprintf("DELETE FROM channel_pending_messages WHERE id IN (%s)", strings.Join(placeholders, ",")),
args...,
)
if err != nil {
return fmt.Errorf("compact delete: %w", err)
}
affected, _ := res.RowsAffected()
if affected == 0 {
return nil
}
if summary.ID == uuid.Nil {
summary.ID = uuid.Must(uuid.NewV7())
}
now := time.Now()
_, err = tx.ExecContext(ctx,
`INSERT INTO channel_pending_messages (id, channel_name, history_key, sender, sender_id, body, platform_msg_id, is_summary, created_at, updated_at, tenant_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
summary.ID, summary.ChannelName, summary.HistoryKey, summary.Sender, summary.SenderID, summary.Body, summary.PlatformMsgID, true, now, now, tenantIDForInsert(ctx),
)
if err != nil {
return fmt.Errorf("compact insert summary: %w", err)
}
return tx.Commit()
}
func (s *SQLitePendingMessageStore) DeleteStale(ctx context.Context, olderThan time.Duration) (int64, error) {
cutoff := time.Now().Add(-olderThan)
tid := tenantIDForInsert(ctx)
result, err := s.db.ExecContext(ctx,
`DELETE FROM channel_pending_messages WHERE updated_at < ? AND tenant_id = ?`,
cutoff, tid,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
func (s *SQLitePendingMessageStore) ListGroups(ctx context.Context) ([]store.PendingMessageGroup, error) {
tClause, tArgs, err := scopeClause(ctx)
if err != nil {
return nil, err
}
// SQLite: BOOL_OR → MAX(is_summary), EXISTS subquery logic preserved
q := `SELECT channel_name, history_key,
COUNT(*) AS message_count,
MAX(is_summary) AND NOT EXISTS (
SELECT 1 FROM channel_pending_messages n
WHERE n.channel_name = m.channel_name
AND n.history_key = m.history_key
AND NOT n.is_summary
AND n.created_at > (
SELECT MAX(s.created_at)
FROM channel_pending_messages s
WHERE s.channel_name = m.channel_name
AND s.history_key = m.history_key
AND s.is_summary
)
) AS has_summary,
MAX(created_at) AS last_activity
FROM channel_pending_messages m
WHERE 1=1` + tClause + `
GROUP BY channel_name, history_key
ORDER BY last_activity DESC`
rows, err := s.db.QueryContext(ctx, q, tArgs...)
if err != nil {
return nil, err
}
defer rows.Close()
var result []store.PendingMessageGroup
for rows.Next() {
var g store.PendingMessageGroup
if err := rows.Scan(&g.ChannelName, &g.HistoryKey, &g.MessageCount, &g.HasSummary, &g.LastActivity); err != nil {
return nil, err
}
result = append(result, g)
}
return result, rows.Err()
}
func (s *SQLitePendingMessageStore) CountAll(ctx context.Context) (int64, error) {
tClause, tArgs, err := scopeClause(ctx)
if err != nil {
return 0, err
}
var count int64
err = s.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM channel_pending_messages WHERE 1=1`+tClause,
tArgs...,
).Scan(&count)
return count, err
}
func (s *SQLitePendingMessageStore) CountByKey(ctx context.Context, channelName, historyKey string) (int, error) {
tClause, tArgs, err := scopeClause(ctx)
if err != nil {
return 0, err
}
args := append([]any{channelName, historyKey}, tArgs...)
var count int
err = s.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM channel_pending_messages WHERE channel_name = ? AND history_key = ?`+tClause,
args...,
).Scan(&count)
return count, err
}
func (s *SQLitePendingMessageStore) ResolveGroupTitles(ctx context.Context, groups []store.PendingMessageGroup) (map[string]string, error) {
if len(groups) == 0 {
return nil, nil
}
// Build OR conditions using LIKE with ? placeholders
conditions := make([]string, 0, len(groups))
args := make([]any, 0, len(groups)*2)
for _, g := range groups {
conditions = append(conditions, "(session_key LIKE '%:' || ? || ':group:' || ? || '%')")
args = append(args, g.ChannelName, g.HistoryKey)
}
if !store.IsCrossTenant(ctx) {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
tid = store.MasterTenantID
}
args = append(args, tid)
}
tenantFilter := ""
if !store.IsCrossTenant(ctx) {
tenantFilter = " AND tenant_id = ?"
}
rows, err := s.db.QueryContext(ctx,
"SELECT session_key, json_extract(metadata, '$.chat_title')"+
" FROM sessions"+
" WHERE json_extract(metadata, '$.chat_title') != ''"+
" AND ("+strings.Join(conditions, " OR ")+")"+tenantFilter,
args...,
)
if err != nil {
return nil, err
}
defer rows.Close()
result := make(map[string]string)
for rows.Next() {
var sessionKey, title string
if err := rows.Scan(&sessionKey, &title); err != nil {
return nil, err
}
for _, g := range groups {
pattern := ":" + g.ChannelName + ":group:" + g.HistoryKey
if strings.Contains(sessionKey, pattern) {
mapKey := g.ChannelName + ":" + g.HistoryKey
if _, exists := result[mapKey]; !exists {
result[mapKey] = title
}
break
}
}
}
return result, rows.Err()
}
+52
View File
@@ -0,0 +1,52 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"database/sql"
"fmt"
"log/slog"
_ "modernc.org/sqlite" // pure-Go SQLite driver
)
// OpenDB opens a SQLite database at the given path with WAL mode and recommended pragmas.
// Uses modernc.org/sqlite (pure Go, zero CGo).
//
// Desktop app concurrency model:
// - WAL mode: allows concurrent readers alongside a single writer
// - MaxOpenConns(2): limits contention; one reader + one writer can proceed in parallel
// - busy_timeout=15000: writer retries for 15s before SQLITE_BUSY error
// - _txlock=immediate: write transactions acquire lock immediately (fail-fast on contention)
func OpenDB(path string) (*sql.DB, error) {
dsn := fmt.Sprintf("file:%s?_txlock=immediate&_foreign_keys=ON", path)
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
// SQLite is single-writer; WAL allows concurrent readers.
// Keep pool small to reduce lock contention.
db.SetMaxOpenConns(2)
// Set PRAGMAs explicitly — DSN params may not be applied by modernc.org/sqlite.
pragmas := []string{
"PRAGMA journal_mode = WAL",
"PRAGMA busy_timeout = 15000",
"PRAGMA synchronous = NORMAL",
"PRAGMA cache_size = -8000", // 8MB cache
}
for _, p := range pragmas {
if _, err := db.Exec(p); err != nil {
slog.Warn("sqlite pragma failed", "pragma", p, "error", err)
}
}
// Verify connection works.
if err := db.Ping(); err != nil {
db.Close()
return nil, fmt.Errorf("ping sqlite: %w", err)
}
return db, nil
}
+199
View File
@@ -0,0 +1,199 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"database/sql"
"fmt"
"log/slog"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/crypto"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// SQLiteProviderStore implements store.ProviderStore backed by SQLite.
type SQLiteProviderStore struct {
db *sql.DB
encKey string // AES-256 encryption key for API keys (empty = plain text)
}
func NewSQLiteProviderStore(db *sql.DB, encryptionKey string) *SQLiteProviderStore {
if encryptionKey != "" {
slog.Info("provider store: API key encryption enabled")
} else {
slog.Warn("provider store: API key encryption disabled (plain text storage)")
}
return &SQLiteProviderStore{db: db, encKey: encryptionKey}
}
func (s *SQLiteProviderStore) CreateProvider(ctx context.Context, p *store.LLMProviderData) error {
if p.ID == uuid.Nil {
p.ID = store.GenNewID()
}
apiKey := p.APIKey
if s.encKey != "" && apiKey != "" {
encrypted, err := crypto.Encrypt(apiKey, s.encKey)
if err != nil {
return fmt.Errorf("encrypt api key: %w", err)
}
apiKey = encrypted
}
settings := p.Settings
if len(settings) == 0 {
settings = []byte("{}")
}
now := time.Now()
p.CreatedAt = now
p.UpdatedAt = now
tid := tenantIDForInsert(ctx)
p.TenantID = tid
_, err := s.db.ExecContext(ctx,
`INSERT INTO llm_providers (id, name, display_name, provider_type, api_base, api_key, enabled, settings, created_at, updated_at, tenant_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
p.ID, p.Name, p.DisplayName, p.ProviderType, p.APIBase, apiKey, p.Enabled, settings, now, now, tid,
)
return err
}
func (s *SQLiteProviderStore) GetProvider(ctx context.Context, id uuid.UUID) (*store.LLMProviderData, error) {
tClause, tArgs, err := scopeClause(ctx)
if err != nil {
return nil, err
}
var p store.LLMProviderData
var apiKey string
createdAt, updatedAt := scanTimePair()
args := append([]any{id}, tArgs...)
err = s.db.QueryRowContext(ctx,
`SELECT id, name, display_name, provider_type, api_base, api_key, enabled, settings, created_at, updated_at, tenant_id
FROM llm_providers WHERE id = ?`+tClause,
args...,
).Scan(&p.ID, &p.Name, &p.DisplayName, &p.ProviderType, &p.APIBase, &apiKey, &p.Enabled, &p.Settings, createdAt, updatedAt, &p.TenantID)
if err != nil {
return nil, fmt.Errorf("provider not found: %s", id)
}
p.CreatedAt = createdAt.Time
p.UpdatedAt = updatedAt.Time
p.APIKey = s.decryptKey(apiKey, p.Name)
return &p, nil
}
func (s *SQLiteProviderStore) GetProviderByName(ctx context.Context, name string) (*store.LLMProviderData, error) {
tClause, tArgs, err := scopeClause(ctx)
if err != nil {
return nil, err
}
var p store.LLMProviderData
var apiKey string
createdAt, updatedAt := scanTimePair()
args := append([]any{name}, tArgs...)
err = s.db.QueryRowContext(ctx,
`SELECT id, name, display_name, provider_type, api_base, api_key, enabled, settings, created_at, updated_at, tenant_id
FROM llm_providers WHERE name = ?`+tClause,
args...,
).Scan(&p.ID, &p.Name, &p.DisplayName, &p.ProviderType, &p.APIBase, &apiKey, &p.Enabled, &p.Settings, createdAt, updatedAt, &p.TenantID)
if err != nil {
return nil, fmt.Errorf("provider not found: %s", name)
}
p.CreatedAt = createdAt.Time
p.UpdatedAt = updatedAt.Time
p.APIKey = s.decryptKey(apiKey, p.Name)
return &p, nil
}
func (s *SQLiteProviderStore) ListProviders(ctx context.Context) ([]store.LLMProviderData, error) {
tClause, tArgs, err := scopeClause(ctx)
if err != nil {
return nil, err
}
q := `SELECT id, name, display_name, provider_type, api_base, api_key, enabled, settings, created_at, updated_at, tenant_id
FROM llm_providers WHERE true` + tClause + ` ORDER BY name`
rows, err := s.db.QueryContext(ctx, q, tArgs...)
if err != nil {
return nil, err
}
return s.scanProviders(rows)
}
// ListAllProviders returns all providers across all tenants. Server-internal only.
func (s *SQLiteProviderStore) ListAllProviders(ctx context.Context) ([]store.LLMProviderData, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, name, display_name, provider_type, api_base, api_key, enabled, settings, created_at, updated_at, tenant_id
FROM llm_providers ORDER BY name`)
if err != nil {
return nil, err
}
return s.scanProviders(rows)
}
func (s *SQLiteProviderStore) UpdateProvider(ctx context.Context, id uuid.UUID, updates map[string]any) error {
if apiKey, ok := updates["api_key"]; ok && s.encKey != "" {
if keyStr, ok := apiKey.(string); ok && keyStr != "" {
encrypted, err := crypto.Encrypt(keyStr, s.encKey)
if err != nil {
return fmt.Errorf("encrypt api key: %w", err)
}
updates["api_key"] = encrypted
}
}
if store.IsCrossTenant(ctx) {
return execMapUpdate(ctx, s.db, "llm_providers", id, updates)
}
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
return fmt.Errorf("tenant_id required")
}
return execMapUpdateWhereTenant(ctx, s.db, "llm_providers", updates, id, tid)
}
func (s *SQLiteProviderStore) DeleteProvider(ctx context.Context, id uuid.UUID) error {
tClause, tArgs, err := scopeClause(ctx)
if err != nil {
return err
}
args := append([]any{id}, tArgs...)
_, err = s.db.ExecContext(ctx,
"DELETE FROM llm_providers WHERE id = ?"+tClause,
args...,
)
return err
}
func (s *SQLiteProviderStore) decryptKey(apiKey, providerName string) string {
if s.encKey != "" && apiKey != "" {
decrypted, err := crypto.Decrypt(apiKey, s.encKey)
if err != nil {
slog.Warn("failed to decrypt provider API key", "provider", providerName, "error", err)
return apiKey
}
return decrypted
}
return apiKey
}
func (s *SQLiteProviderStore) scanProviders(rows *sql.Rows) ([]store.LLMProviderData, error) {
defer rows.Close()
var result []store.LLMProviderData
for rows.Next() {
var p store.LLMProviderData
var apiKey string
createdAt, updatedAt := scanTimePair()
if err := rows.Scan(&p.ID, &p.Name, &p.DisplayName, &p.ProviderType, &p.APIBase, &apiKey, &p.Enabled, &p.Settings, createdAt, updatedAt, &p.TenantID); err != nil {
slog.Error("providers.scan", "error", err)
continue
}
p.CreatedAt = createdAt.Time
p.UpdatedAt = updatedAt.Time
p.APIKey = s.decryptKey(apiKey, p.Name)
result = append(result, p)
}
return result, rows.Err()
}
+95
View File
@@ -0,0 +1,95 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"database/sql"
"fmt"
"strings"
"time"
)
// sqliteTime wraps time.Time to handle SQLite's text-based timestamp storage.
// modernc.org/sqlite returns timestamps as strings; this type implements sql.Scanner
// to parse them automatically during rows.Scan().
type sqliteTime struct {
Time time.Time
}
// Scan implements sql.Scanner for SQLite text timestamps.
func (st *sqliteTime) Scan(src any) error {
if src == nil {
st.Time = time.Time{}
return nil
}
switch v := src.(type) {
case time.Time:
st.Time = v
return nil
case string:
return st.parseString(v)
case []byte:
return st.parseString(string(v))
default:
return fmt.Errorf("sqliteTime: unsupported type %T", src)
}
}
var timeFormats = []string{
time.RFC3339Nano,
time.RFC3339,
"2006-01-02T15:04:05.000Z",
"2006-01-02T15:04:05Z",
"2006-01-02 15:04:05.999999999 -0700 MST",
"2006-01-02 15:04:05.999999999 -0700 -07",
"2006-01-02 15:04:05.999999999",
"2006-01-02 15:04:05",
"2006-01-02",
}
func (st *sqliteTime) parseString(s string) error {
// Strip Go monotonic clock suffix (e.g. " m=+12.492694459")
if idx := strings.Index(s, " m="); idx > 0 {
s = s[:idx]
}
for _, layout := range timeFormats {
if t, err := time.Parse(layout, s); err == nil {
st.Time = t
return nil
}
}
return fmt.Errorf("sqliteTime: cannot parse %q", s)
}
// scanTimePair is a convenience for scanning created_at + updated_at columns.
// After scan, copy .Time back to the target time.Time fields.
func scanTimePair() (createdAt, updatedAt *sqliteTime) {
return &sqliteTime{}, &sqliteTime{}
}
// nullSqliteTime wraps sql.NullTime equivalent for nullable timestamp columns.
type nullSqliteTime struct {
Time time.Time
Valid bool
}
// Scan implements sql.Scanner for nullable SQLite text timestamps.
func (nt *nullSqliteTime) Scan(src any) error {
if src == nil {
nt.Time = time.Time{}
nt.Valid = false
return nil
}
st := &sqliteTime{}
if err := st.Scan(src); err != nil {
return err
}
nt.Time = st.Time
nt.Valid = true
return nil
}
// NullTime returns sql.NullTime from nullSqliteTime.
func (nt *nullSqliteTime) NullTime() sql.NullTime {
return sql.NullTime{Time: nt.Time, Valid: nt.Valid}
}
+116
View File
@@ -0,0 +1,116 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"database/sql"
_ "embed"
"fmt"
"log/slog"
)
//go:embed schema.sql
var schemaSQL string
// SchemaVersion is the current SQLite schema version.
// Bump this when adding new migration steps below.
const SchemaVersion = 1
// migrations maps version → SQL to apply when upgrading FROM that version.
// schema.sql always represents the LATEST full schema (for fresh DBs).
// Existing DBs are patched incrementally via these steps.
//
// Example: to add a new column in the future:
//
// var migrations = map[int]string{
// 1: `ALTER TABLE agents ADD COLUMN new_col TEXT DEFAULT '';`,
// }
//
// Then bump SchemaVersion to 2.
var migrations = map[int]string{
// Version 1 is the initial schema — no patch needed (schema.sql covers it).
}
// EnsureSchema creates tables if they don't exist and applies incremental migrations.
//
// Flow:
// 1. Fresh DB (no schema_version row) → apply full schema.sql + set version = SchemaVersion
// 2. Existing DB with version < SchemaVersion → apply patches sequentially
// 3. Existing DB with version == SchemaVersion → no-op
// 4. Always: seed master tenant (idempotent)
func EnsureSchema(db *sql.DB) error {
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER NOT NULL PRIMARY KEY
)`); err != nil {
return fmt.Errorf("create schema_version: %w", err)
}
var current int
err := db.QueryRow("SELECT version FROM schema_version LIMIT 1").Scan(&current)
if err == sql.ErrNoRows {
// Fresh database — apply full schema.
slog.Info("sqlite: applying initial schema", "version", SchemaVersion)
tx, txErr := db.Begin()
if txErr != nil {
return fmt.Errorf("begin schema tx: %w", txErr)
}
if _, err := tx.Exec(schemaSQL); err != nil {
tx.Rollback()
return fmt.Errorf("apply schema: %w", err)
}
if _, err := tx.Exec("INSERT INTO schema_version (version) VALUES (?)", SchemaVersion); err != nil {
tx.Rollback()
return fmt.Errorf("set schema version: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit schema tx: %w", err)
}
return seedMasterTenant(db)
}
if err != nil {
return fmt.Errorf("read schema version: %w", err)
}
// Apply incremental migrations for existing DBs.
if current < SchemaVersion {
slog.Info("sqlite: migrating schema", "from", current, "to", SchemaVersion)
for v := current; v < SchemaVersion; v++ {
patch, ok := migrations[v]
if !ok {
return fmt.Errorf("sqlite: missing migration for version %d → %d", v, v+1)
}
tx, txErr := db.Begin()
if txErr != nil {
return fmt.Errorf("begin migration tx v%d: %w", v, txErr)
}
if _, err := tx.Exec(patch); err != nil {
tx.Rollback()
return fmt.Errorf("apply migration v%d: %w", v, err)
}
if _, err := tx.Exec(
"UPDATE schema_version SET version = ? WHERE version = ?", v+1, v,
); err != nil {
tx.Rollback()
return fmt.Errorf("update schema version v%d: %w", v, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit migration v%d: %w", v, err)
}
slog.Info("sqlite: applied migration", "version", v+1)
}
}
return seedMasterTenant(db)
}
// seedMasterTenant ensures the master tenant row exists (idempotent).
func seedMasterTenant(db *sql.DB) error {
_, err := db.Exec(
`INSERT OR IGNORE INTO tenants (id, name, slug, status) VALUES (?, 'Master', 'master', 'active')`,
"0193a5b0-7000-7000-8000-000000000001",
)
if err != nil {
slog.Warn("sqlite: seed master tenant failed", "error", err)
}
return nil
}
File diff suppressed because it is too large. Load diff
+49
View File
@@ -0,0 +1,49 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"fmt"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// scopeClause extracts QueryScope from context and generates SQLite WHERE conditions
// using ? placeholders (instead of PG's $N positional params).
func scopeClause(ctx context.Context) (clause string, args []any, err error) {
scope, err := store.ScopeFromContext(ctx)
if err != nil {
return "", nil, err
}
clause = " AND tenant_id = ?"
args = []any{scope.TenantID}
if scope.ProjectID != nil {
clause += " AND project_id = ?"
args = append(args, *scope.ProjectID)
}
return clause, args, nil
}
// scopeClauseAlias is like scopeClause but qualifies columns with a table alias.
// SECURITY: alias is interpolated — callers MUST pass hardcoded string literals only.
func scopeClauseAlias(ctx context.Context, alias string) (clause string, args []any, err error) {
for _, c := range alias {
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_') {
return "", nil, fmt.Errorf("invalid table alias: %q", alias)
}
}
scope, err := store.ScopeFromContext(ctx)
if err != nil {
return "", nil, err
}
clause = " AND " + alias + ".tenant_id = ?"
args = []any{scope.TenantID}
if scope.ProjectID != nil {
clause += " AND " + alias + ".project_id = ?"
args = append(args, *scope.ProjectID)
}
return clause, args, nil
}
+333
View File
@@ -0,0 +1,333 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"database/sql"
"encoding/json"
"maps"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// SQLiteSessionStore implements store.SessionStore backed by SQLite.
type SQLiteSessionStore struct {
db *sql.DB
mu sync.RWMutex
cache map[string]*store.SessionData
// OnDelete is called with the session key when a session is deleted.
OnDelete func(sessionKey string)
}
func NewSQLiteSessionStore(db *sql.DB) *SQLiteSessionStore {
// No migrateLegacyWSKeys — SQLite has no regexp_replace.
return &SQLiteSessionStore{db: db, cache: make(map[string]*store.SessionData)}
}
// sessionCacheKey prefixes session key with tenant UUID to prevent cross-tenant cache collisions.
func sessionCacheKey(ctx context.Context, key string) string {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
tid = store.MasterTenantID
}
return tid.String() + ":" + key
}
func (s *SQLiteSessionStore) GetOrCreate(ctx context.Context, key string) *store.SessionData {
s.mu.Lock()
defer s.mu.Unlock()
if cached, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
return cached
}
data := s.loadFromDB(ctx, key)
if data != nil {
s.cache[sessionCacheKey(ctx, key)] = data
return data
}
now := time.Now()
data = &store.SessionData{
Key: key,
Messages: []providers.Message{},
Created: now,
Updated: now,
}
// Extract team_id from team session keys (agent:{agentId}:team:{teamId}:{chatId}).
var teamID *uuid.UUID
if parts := strings.SplitN(key, ":", 5); len(parts) >= 4 && parts[2] == "team" {
if tid, err := uuid.Parse(parts[3]); err == nil {
teamID = &tid
data.TeamID = teamID
}
}
s.cache[sessionCacheKey(ctx, key)] = data
msgsJSON, _ := json.Marshal([]providers.Message{})
s.db.ExecContext(ctx,
`INSERT INTO sessions (id, session_key, messages, created_at, updated_at, team_id, tenant_id)
VALUES (?,?,?,?,?,?,?) ON CONFLICT (tenant_id, session_key) DO NOTHING`,
uuid.Must(uuid.NewV7()), key, msgsJSON, now, now, teamID, tenantIDForInsert(ctx),
)
return data
}
// Get returns the session if it exists (cache or DB), nil otherwise. Never creates.
func (s *SQLiteSessionStore) Get(ctx context.Context, key string) *store.SessionData {
s.mu.RLock()
if cached, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
s.mu.RUnlock()
return cached
}
s.mu.RUnlock()
s.mu.Lock()
defer s.mu.Unlock()
if cached, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
return cached
}
data := s.loadFromDB(ctx, key)
if data != nil {
s.cache[sessionCacheKey(ctx, key)] = data
}
return data
}
func (s *SQLiteSessionStore) AddMessage(ctx context.Context, key string, msg providers.Message) {
s.mu.Lock()
defer s.mu.Unlock()
if msg.CreatedAt == nil {
now := time.Now().UTC()
msg.CreatedAt = &now
}
data := s.getOrInit(ctx, key)
data.Messages = append(data.Messages, msg)
data.Updated = time.Now()
}
func (s *SQLiteSessionStore) GetHistory(ctx context.Context, key string) []providers.Message {
s.mu.RLock()
if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
msgs := make([]providers.Message, len(data.Messages))
copy(msgs, data.Messages)
s.mu.RUnlock()
return msgs
}
s.mu.RUnlock()
s.mu.Lock()
defer s.mu.Unlock()
if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
msgs := make([]providers.Message, len(data.Messages))
copy(msgs, data.Messages)
return msgs
}
data := s.loadFromDB(ctx, key)
if data == nil {
return nil
}
s.cache[sessionCacheKey(ctx, key)] = data
msgs := make([]providers.Message, len(data.Messages))
copy(msgs, data.Messages)
return msgs
}
func (s *SQLiteSessionStore) GetSummary(ctx context.Context, key string) string {
s.mu.RLock()
defer s.mu.RUnlock()
if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
return data.Summary
}
return ""
}
func (s *SQLiteSessionStore) SetSummary(ctx context.Context, key, summary string) {
s.mu.Lock()
defer s.mu.Unlock()
if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
data.Summary = summary
data.Updated = time.Now()
}
}
func (s *SQLiteSessionStore) GetLabel(ctx context.Context, key string) string {
s.mu.RLock()
defer s.mu.RUnlock()
if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
return data.Label
}
return ""
}
func (s *SQLiteSessionStore) SetLabel(ctx context.Context, key, label string) {
s.mu.Lock()
defer s.mu.Unlock()
if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
data.Label = label
data.Updated = time.Now()
}
}
func (s *SQLiteSessionStore) GetSessionMetadata(ctx context.Context, key string) map[string]string {
s.mu.RLock()
defer s.mu.RUnlock()
if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok && data.Metadata != nil {
out := make(map[string]string, len(data.Metadata))
maps.Copy(out, data.Metadata)
return out
}
return nil
}
func (s *SQLiteSessionStore) SetSessionMetadata(ctx context.Context, key string, metadata map[string]string) {
s.mu.Lock()
defer s.mu.Unlock()
data := s.getOrInit(ctx, key)
if data.Metadata == nil {
data.Metadata = make(map[string]string)
}
maps.Copy(data.Metadata, metadata)
data.Updated = time.Now()
}
func (s *SQLiteSessionStore) SetAgentInfo(ctx context.Context, key string, agentUUID uuid.UUID, userID string) {
s.mu.Lock()
defer s.mu.Unlock()
data := s.getOrInit(ctx, key)
if agentUUID != uuid.Nil {
data.AgentUUID = agentUUID
}
if userID != "" {
data.UserID = userID
}
}
func (s *SQLiteSessionStore) UpdateMetadata(ctx context.Context, key, model, provider, channel string) {
s.mu.Lock()
defer s.mu.Unlock()
if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
if model != "" {
data.Model = model
}
if provider != "" {
data.Provider = provider
}
if channel != "" {
data.Channel = channel
}
}
}
// --- helpers ---
func (s *SQLiteSessionStore) getOrInit(ctx context.Context, key string) *store.SessionData {
if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
return data
}
data := s.loadFromDB(ctx, key)
if data != nil {
s.cache[sessionCacheKey(ctx, key)] = data
return data
}
now := time.Now()
data = &store.SessionData{
Key: key,
Messages: []providers.Message{},
Created: now,
Updated: now,
}
s.cache[sessionCacheKey(ctx, key)] = data
msgsJSON, _ := json.Marshal([]providers.Message{})
s.db.ExecContext(ctx,
`INSERT INTO sessions (id, session_key, messages, created_at, updated_at, tenant_id)
VALUES (?,?,?,?,?,?) ON CONFLICT (tenant_id, session_key) DO NOTHING`,
uuid.Must(uuid.NewV7()), key, msgsJSON, now, now, tenantIDForInsert(ctx),
)
return data
}
func (s *SQLiteSessionStore) loadFromDB(ctx context.Context, key string) *store.SessionData {
var sessionKey string
var msgsJSON []byte
var summary, model, provider, channel, label, spawnedBy, userID *string
var agentID, teamID *uuid.UUID
var inputTokens, outputTokens int64
var compactionCount, memoryFlushCompactionCount, spawnDepth int
var memoryFlushAt int64
createdAt, updatedAt := scanTimePair()
var metaJSON *[]byte
tid := tenantIDForInsert(ctx)
err := s.db.QueryRowContext(ctx,
`SELECT session_key, messages, summary, model, provider, channel,
input_tokens, output_tokens, compaction_count,
memory_flush_compaction_count, memory_flush_at,
label, spawned_by, spawn_depth, agent_id, user_id,
COALESCE(metadata, '{}'), created_at, updated_at, team_id
FROM sessions WHERE session_key = ? AND tenant_id = ?`, key, tid,
).Scan(&sessionKey, &msgsJSON, &summary, &model, &provider, &channel,
&inputTokens, &outputTokens, &compactionCount,
&memoryFlushCompactionCount, &memoryFlushAt,
&label, &spawnedBy, &spawnDepth, &agentID, &userID,
&metaJSON, createdAt, updatedAt, &teamID)
if err != nil {
return nil
}
var msgs []providers.Message
json.Unmarshal(msgsJSON, &msgs)
var meta map[string]string
if metaJSON != nil {
json.Unmarshal(*metaJSON, &meta)
}
return &store.SessionData{
Key: sessionKey,
Messages: msgs,
Summary: derefStr(summary),
Created: createdAt.Time,
Updated: updatedAt.Time,
AgentUUID: derefUUID(agentID),
UserID: derefStr(userID),
TeamID: teamID,
Model: derefStr(model),
Provider: derefStr(provider),
Channel: derefStr(channel),
InputTokens: inputTokens,
OutputTokens: outputTokens,
CompactionCount: compactionCount,
MemoryFlushCompactionCount: memoryFlushCompactionCount,
MemoryFlushAt: memoryFlushAt,
Label: derefStr(label),
SpawnedBy: derefStr(spawnedBy),
SpawnDepth: spawnDepth,
Metadata: meta,
}
}
func nilSessionUUID(u uuid.UUID) *uuid.UUID {
if u == uuid.Nil {
return nil
}
return &u
}
+246
View File
@@ -0,0 +1,246 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// buildSessionFilter builds a dynamic WHERE clause from SessionListOpts using ? placeholders.
func buildSessionFilter(opts store.SessionListOpts, tableAlias string) (string, []any) {
prefix := ""
if tableAlias != "" {
prefix = tableAlias + "."
}
var conditions []string
var args []any
if opts.AgentID != "" {
conditions = append(conditions, prefix+"session_key LIKE ?")
args = append(args, "agent:"+opts.AgentID+":%")
}
if opts.Channel != "" {
conditions = append(conditions, prefix+"session_key LIKE ?")
args = append(args, "agent:%:"+opts.Channel+":%")
}
if opts.UserID != "" {
conditions = append(conditions, prefix+"user_id = ?")
args = append(args, opts.UserID)
}
if opts.TenantID != uuid.Nil {
conditions = append(conditions, prefix+"tenant_id = ?")
args = append(args, opts.TenantID)
}
if len(conditions) == 0 {
return "", nil
}
return " WHERE " + strings.Join(conditions, " AND "), args
}
func (s *SQLiteSessionStore) List(ctx context.Context, agentID string) []store.SessionInfo {
var conditions []string
var args []any
if agentID != "" {
conditions = append(conditions, "session_key LIKE ?")
args = append(args, "agent:"+agentID+":%")
}
if !store.IsCrossTenant(ctx) {
tid := store.TenantIDFromContext(ctx)
if tid != uuid.Nil {
conditions = append(conditions, "tenant_id = ?")
args = append(args, tid)
}
}
where := ""
if len(conditions) > 0 {
where = " WHERE " + strings.Join(conditions, " AND ")
}
rows, err := s.db.QueryContext(ctx,
"SELECT session_key, messages, created_at, updated_at, label, channel, user_id, COALESCE(metadata, '{}') FROM sessions"+where+" ORDER BY updated_at DESC",
args...)
if err != nil {
return nil
}
defer rows.Close()
var result []store.SessionInfo
for rows.Next() {
var key string
var msgsJSON []byte
stCreated, stUpdated := scanTimePair()
var label, channel, userID *string
var metaJSON []byte
if err := rows.Scan(&key, &msgsJSON, stCreated, stUpdated, &label, &channel, &userID, &metaJSON); err != nil {
continue
}
var msgs []providers.Message
json.Unmarshal(msgsJSON, &msgs)
var meta map[string]string
if len(metaJSON) > 0 {
json.Unmarshal(metaJSON, &meta)
}
result = append(result, store.SessionInfo{
Key: key,
MessageCount: len(msgs),
Created: stCreated.Time,
Updated: stUpdated.Time,
Label: derefStr(label),
Channel: derefStr(channel),
UserID: derefStr(userID),
Metadata: meta,
})
}
return result
}
func (s *SQLiteSessionStore) ListPaged(ctx context.Context, opts store.SessionListOpts) store.SessionListResult {
limit := opts.Limit
if limit <= 0 {
limit = 20
}
offset := max(opts.Offset, 0)
where, whereArgs := buildSessionFilter(opts, "")
var total int
countQ := "SELECT COUNT(*) FROM sessions" + where
if err := s.db.QueryRowContext(ctx, countQ, whereArgs...).Scan(&total); err != nil {
return store.SessionListResult{Sessions: []store.SessionInfo{}, Total: 0}
}
// Use json_array_length (SQLite built-in) instead of jsonb_array_length.
selectQ := fmt.Sprintf(`SELECT session_key, json_array_length(messages), created_at, updated_at, label, channel, user_id, COALESCE(metadata, '{}')
FROM sessions%s ORDER BY updated_at DESC LIMIT ? OFFSET ?`, where)
selectArgs := append(append([]any{}, whereArgs...), limit, offset)
rows, err := s.db.QueryContext(ctx, selectQ, selectArgs...)
if err != nil {
return store.SessionListResult{Sessions: []store.SessionInfo{}, Total: total}
}
defer rows.Close()
var result []store.SessionInfo
for rows.Next() {
var key string
var msgCount int
stCreated, stUpdated := scanTimePair()
var label, channel, userID *string
var metaJSON []byte
if err := rows.Scan(&key, &msgCount, stCreated, stUpdated, &label, &channel, &userID, &metaJSON); err != nil {
continue
}
var meta map[string]string
if len(metaJSON) > 0 {
json.Unmarshal(metaJSON, &meta)
}
result = append(result, store.SessionInfo{
Key: key,
MessageCount: msgCount,
Created: stCreated.Time,
Updated: stUpdated.Time,
Label: derefStr(label),
Channel: derefStr(channel),
UserID: derefStr(userID),
Metadata: meta,
})
}
if result == nil {
result = []store.SessionInfo{}
}
return store.SessionListResult{Sessions: result, Total: total}
}
// ListPagedRich returns enriched session info for API responses (includes model, tokens, agent name).
func (s *SQLiteSessionStore) ListPagedRich(ctx context.Context, opts store.SessionListOpts) store.SessionListRichResult {
limit := opts.Limit
if limit <= 0 {
limit = 20
}
offset := max(opts.Offset, 0)
where, whereArgs := buildSessionFilter(opts, "s")
var total int
countQ := "SELECT COUNT(*) FROM sessions s" + where
if err := s.db.QueryRowContext(ctx, countQ, whereArgs...).Scan(&total); err != nil {
return store.SessionListRichResult{Sessions: []store.SessionInfoRich{}, Total: 0}
}
// Use json_array_length and length() instead of PG-specific functions.
const richCols = `s.session_key, json_array_length(s.messages), s.created_at, s.updated_at,
s.label, s.channel, s.user_id, COALESCE(s.metadata, '{}'),
s.model, s.provider, s.input_tokens, s.output_tokens,
COALESCE(a.display_name, ''),
length(s.messages) / 4 + 12000,
COALESCE(a.context_window, 200000),
s.compaction_count`
selectQ := fmt.Sprintf(`SELECT %s
FROM sessions s LEFT JOIN agents a ON s.agent_id = a.id
%s ORDER BY s.updated_at DESC LIMIT ? OFFSET ?`, richCols, where)
selectArgs := append(append([]any{}, whereArgs...), limit, offset)
rows, err := s.db.QueryContext(ctx, selectQ, selectArgs...)
if err != nil {
return store.SessionListRichResult{Sessions: []store.SessionInfoRich{}, Total: total}
}
defer rows.Close()
var result []store.SessionInfoRich
for rows.Next() {
var key string
var msgCount int
stCreated, stUpdated := scanTimePair()
var label, channel, userID *string
var metaJSON []byte
var model, provider *string
var inputTokens, outputTokens int64
var agentName string
var estimatedTokens, contextWindow, compactionCount int
if err := rows.Scan(&key, &msgCount, stCreated, stUpdated, &label, &channel, &userID, &metaJSON,
&model, &provider, &inputTokens, &outputTokens, &agentName,
&estimatedTokens, &contextWindow, &compactionCount); err != nil {
continue
}
var meta map[string]string
if len(metaJSON) > 0 {
json.Unmarshal(metaJSON, &meta)
}
result = append(result, store.SessionInfoRich{
SessionInfo: store.SessionInfo{
Key: key,
MessageCount: msgCount,
Created: stCreated.Time,
Updated: stUpdated.Time,
Label: derefStr(label),
Channel: derefStr(channel),
UserID: derefStr(userID),
Metadata: meta,
},
Model: derefStr(model),
Provider: derefStr(provider),
InputTokens: inputTokens,
OutputTokens: outputTokens,
AgentName: agentName,
EstimatedTokens: estimatedTokens,
ContextWindow: contextWindow,
CompactionCount: compactionCount,
})
}
if result == nil {
result = []store.SessionInfoRich{}
}
return store.SessionListRichResult{Sessions: result, Total: total}
}
+136
View File
@@ -0,0 +1,136 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"encoding/json"
"log/slog"
"strings"
"time"
"github.com/nextlevelbuilder/goclaw/internal/providers"
)
func (s *SQLiteSessionStore) Save(ctx context.Context, key string) error {
s.mu.RLock()
data, ok := s.cache[sessionCacheKey(ctx, key)]
if !ok {
s.mu.RUnlock()
return nil
}
snapshot := *data
msgs := make([]providers.Message, len(data.Messages))
copy(msgs, data.Messages)
snapshot.Messages = msgs
s.mu.RUnlock()
msgsJSON, _ := json.Marshal(snapshot.Messages)
metaJSON := []byte("{}")
if len(snapshot.Metadata) > 0 {
metaJSON, _ = json.Marshal(snapshot.Metadata)
}
_, err := s.db.ExecContext(ctx,
`UPDATE sessions SET
messages = ?, summary = ?, model = ?, provider = ?, channel = ?,
input_tokens = ?, output_tokens = ?, compaction_count = ?,
memory_flush_compaction_count = ?, memory_flush_at = ?,
label = ?, spawned_by = ?, spawn_depth = ?,
agent_id = ?, user_id = ?, metadata = ?, updated_at = ?,
team_id = ?
WHERE session_key = ? AND tenant_id = ?`,
msgsJSON, nilStr(snapshot.Summary), nilStr(snapshot.Model), nilStr(snapshot.Provider), nilStr(snapshot.Channel),
snapshot.InputTokens, snapshot.OutputTokens, snapshot.CompactionCount,
snapshot.MemoryFlushCompactionCount, snapshot.MemoryFlushAt,
nilStr(snapshot.Label), nilStr(snapshot.SpawnedBy), snapshot.SpawnDepth,
nilSessionUUID(snapshot.AgentUUID), nilStr(snapshot.UserID), metaJSON, snapshot.Updated,
snapshot.TeamID,
key, tenantIDForInsert(ctx),
)
return err
}
func (s *SQLiteSessionStore) TruncateHistory(ctx context.Context, key string, keepLast int) {
s.mu.Lock()
defer s.mu.Unlock()
if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
if keepLast <= 0 {
data.Messages = []providers.Message{}
} else if len(data.Messages) > keepLast {
data.Messages = data.Messages[len(data.Messages)-keepLast:]
}
data.Updated = time.Now()
}
}
func (s *SQLiteSessionStore) SetHistory(ctx context.Context, key string, msgs []providers.Message) {
s.mu.Lock()
defer s.mu.Unlock()
if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
data.Messages = msgs
data.Updated = time.Now()
}
}
func (s *SQLiteSessionStore) Reset(ctx context.Context, key string) {
s.mu.Lock()
if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
data.Messages = []providers.Message{}
data.Summary = ""
data.Updated = time.Now()
s.mu.Unlock()
return
}
s.mu.Unlock()
// Session not in cache — clear directly in DB.
tid := tenantIDForInsert(ctx)
if _, err := s.db.ExecContext(ctx,
`UPDATE sessions SET messages = '[]', summary = '', updated_at = ?
WHERE session_key = ? AND tenant_id = ?`,
time.Now(), key, tid,
); err != nil {
slog.Warn("sessions.reset_db_fallback_failed", "key", key, "error", err)
}
}
func (s *SQLiteSessionStore) Delete(ctx context.Context, key string) error {
s.mu.Lock()
delete(s.cache, sessionCacheKey(ctx, key))
s.mu.Unlock()
if s.OnDelete != nil {
s.OnDelete(key)
}
tid := tenantIDForInsert(ctx)
_, err := s.db.ExecContext(ctx, "DELETE FROM sessions WHERE session_key = ? AND tenant_id = ?", key, tid)
return err
}
func (s *SQLiteSessionStore) LastUsedChannel(ctx context.Context, agentID string) (string, string) {
prefix := "agent:" + agentID + ":%"
tid := tenantIDForInsert(ctx)
var sessionKey string
err := s.db.QueryRowContext(ctx,
`SELECT session_key FROM sessions
WHERE session_key LIKE ?
AND session_key NOT LIKE ?
AND session_key NOT LIKE ?
AND tenant_id = ?
ORDER BY updated_at DESC LIMIT 1`,
prefix,
"agent:"+agentID+":cron:%",
"agent:"+agentID+":subagent:%",
tid,
).Scan(&sessionKey)
if err != nil {
return "", ""
}
parts := strings.SplitN(sessionKey, ":", 5)
if len(parts) >= 5 {
return parts[2], parts[4]
}
return "", ""
}
@@ -0,0 +1,96 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"time"
)
func (s *SQLiteSessionStore) AccumulateTokens(ctx context.Context, key string, input, output int64) {
s.mu.Lock()
defer s.mu.Unlock()
if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
data.InputTokens += input
data.OutputTokens += output
}
}
func (s *SQLiteSessionStore) IncrementCompaction(ctx context.Context, key string) {
s.mu.Lock()
defer s.mu.Unlock()
if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
data.CompactionCount++
}
}
func (s *SQLiteSessionStore) GetCompactionCount(ctx context.Context, key string) int {
s.mu.RLock()
defer s.mu.RUnlock()
if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
return data.CompactionCount
}
return 0
}
func (s *SQLiteSessionStore) GetMemoryFlushCompactionCount(ctx context.Context, key string) int {
s.mu.RLock()
defer s.mu.RUnlock()
if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
return data.MemoryFlushCompactionCount
}
return -1
}
func (s *SQLiteSessionStore) SetMemoryFlushDone(ctx context.Context, key string) {
s.mu.Lock()
defer s.mu.Unlock()
if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
data.MemoryFlushCompactionCount = data.CompactionCount
data.MemoryFlushAt = time.Now().UnixMilli()
}
}
func (s *SQLiteSessionStore) SetSpawnInfo(ctx context.Context, key, spawnedBy string, depth int) {
s.mu.Lock()
defer s.mu.Unlock()
if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
data.SpawnedBy = spawnedBy
data.SpawnDepth = depth
}
}
func (s *SQLiteSessionStore) SetContextWindow(ctx context.Context, key string, cw int) {
s.mu.Lock()
defer s.mu.Unlock()
if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
data.ContextWindow = cw
}
}
func (s *SQLiteSessionStore) GetContextWindow(ctx context.Context, key string) int {
s.mu.RLock()
defer s.mu.RUnlock()
if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
return data.ContextWindow
}
return 0
}
func (s *SQLiteSessionStore) SetLastPromptTokens(ctx context.Context, key string, tokens, msgCount int) {
s.mu.Lock()
defer s.mu.Unlock()
if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
data.LastPromptTokens = tokens
data.LastMessageCount = msgCount
}
}
func (s *SQLiteSessionStore) GetLastPromptTokens(ctx context.Context, key string) (int, int) {
s.mu.RLock()
defer s.mu.RUnlock()
if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
return data.LastPromptTokens, data.LastMessageCount
}
return 0, 0
}
+264
View File
@@ -0,0 +1,264 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"os"
"regexp"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
const defaultSkillsCacheTTL = 5 * time.Minute
// skillFrontmatterRe matches YAML frontmatter (--- delimited) at the start of a file.
var skillFrontmatterRe = regexp.MustCompile(`(?s)^---\n(.*?)\n---\n?`)
// SQLiteSkillStore implements store.SkillStore backed by SQLite.
type SQLiteSkillStore struct {
db *sql.DB
baseDir string
mu sync.RWMutex
version atomic.Int64
listCache map[uuid.UUID]*skillListCacheEntry
ttl time.Duration
}
type skillListCacheEntry struct {
skills []store.SkillInfo
ver int64
time time.Time
}
func NewSQLiteSkillStore(db *sql.DB, baseDir string) *SQLiteSkillStore {
return &SQLiteSkillStore{
db: db,
baseDir: baseDir,
listCache: make(map[uuid.UUID]*skillListCacheEntry),
ttl: defaultSkillsCacheTTL,
}
}
func (s *SQLiteSkillStore) Version() int64 { return s.version.Load() }
func (s *SQLiteSkillStore) BumpVersion() { s.version.Store(time.Now().UnixMilli()) }
func (s *SQLiteSkillStore) Dirs() []string { return []string{s.baseDir} }
func (s *SQLiteSkillStore) ListSkills(ctx context.Context) []store.SkillInfo {
currentVer := s.version.Load()
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
tid = store.MasterTenantID
}
s.mu.RLock()
if entry := s.listCache[tid]; entry != nil && entry.ver == currentVer && time.Since(entry.time) < s.ttl {
result := entry.skills
s.mu.RUnlock()
return result
}
s.mu.RUnlock()
rows, err := s.db.QueryContext(ctx,
`SELECT id, name, slug, description, visibility, tags, version, is_system, status, enabled, deps, frontmatter, file_path
FROM skills WHERE (status IN ('active', 'archived') OR is_system = 1) AND (is_system = 1 OR tenant_id = ?)
ORDER BY name`, tid)
if err != nil {
return nil
}
defer rows.Close()
var result []store.SkillInfo
for rows.Next() {
var id uuid.UUID
var name, slug, visibility, status string
var desc *string
var tagsJSON []byte
var version int
var isSystem, enabled bool
var depsRaw, fmRaw []byte
var filePath *string
if err := rows.Scan(&id, &name, &slug, &desc, &visibility, &tagsJSON, &version,
&isSystem, &status, &enabled, &depsRaw, &fmRaw, &filePath); err != nil {
continue
}
info := buildSkillInfo(id.String(), name, slug, desc, version, s.baseDir, filePath)
info.Visibility = visibility
scanJSONStringArray(tagsJSON, &info.Tags)
info.IsSystem = isSystem
info.Status = status
info.Enabled = enabled
info.MissingDeps = parseDepsColumn(depsRaw)
info.Author = parseFrontmatterAuthor(fmRaw)
result = append(result, info)
}
if err := rows.Err(); err != nil {
slog.Warn("ListSkills: rows iteration error", "error", err)
return nil
}
s.mu.Lock()
s.listCache[tid] = &skillListCacheEntry{skills: result, ver: currentVer, time: time.Now()}
s.mu.Unlock()
return result
}
func (s *SQLiteSkillStore) ListAllSkills(ctx context.Context) []store.SkillInfo {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
tid = store.MasterTenantID
}
rows, err := s.db.QueryContext(ctx,
`SELECT id, name, slug, description, visibility, tags, version, is_system, status, enabled, deps, file_path
FROM skills WHERE enabled = 1 AND status != 'deleted' AND (is_system = 1 OR tenant_id = ?)
ORDER BY name`, tid)
if err != nil {
return nil
}
defer rows.Close()
return s.scanSkillInfoList(rows)
}
func (s *SQLiteSkillStore) ListAllSystemSkills(ctx context.Context) []store.SkillInfo {
rows, err := s.db.QueryContext(ctx,
`SELECT id, name, slug, description, visibility, tags, version, is_system, status, enabled, deps, file_path
FROM skills WHERE is_system = 1 AND enabled = 1 AND status != 'deleted'
ORDER BY name`)
if err != nil {
return nil
}
defer rows.Close()
return s.scanSkillInfoList(rows)
}
func (s *SQLiteSkillStore) scanSkillInfoList(rows *sql.Rows) []store.SkillInfo {
var result []store.SkillInfo
for rows.Next() {
var id uuid.UUID
var name, slug, visibility, status string
var desc *string
var tagsJSON []byte
var version int
var isSystem, enabled bool
var depsRaw []byte
var filePath *string
if err := rows.Scan(&id, &name, &slug, &desc, &visibility, &tagsJSON, &version,
&isSystem, &status, &enabled, &depsRaw, &filePath); err != nil {
continue
}
info := buildSkillInfo(id.String(), name, slug, desc, version, s.baseDir, filePath)
info.Visibility = visibility
scanJSONStringArray(tagsJSON, &info.Tags)
info.IsSystem = isSystem
info.Status = status
info.Enabled = enabled
info.MissingDeps = parseDepsColumn(depsRaw)
result = append(result, info)
}
if err := rows.Err(); err != nil {
slog.Warn("scanSkillInfoList: rows iteration error", "error", err)
}
return result
}
func (s *SQLiteSkillStore) StoreMissingDeps(ctx context.Context, id uuid.UUID, missing []string) error {
if missing == nil {
missing = []string{}
}
encoded, err := json.Marshal(map[string]any{"missing": missing})
if err != nil {
return err
}
_, err = s.db.ExecContext(ctx,
`UPDATE skills SET deps = ?, updated_at = ? WHERE id = ? AND is_system = 1`,
encoded, time.Now().UTC(), id,
)
if err == nil {
s.BumpVersion()
}
return err
}
// --- helpers shared with other skill files ---
func buildSkillInfo(id, name, slug string, desc *string, version int, baseDir string, filePath *string) store.SkillInfo {
d := ""
if desc != nil {
d = *desc
}
skillDir := fmt.Sprintf("%s/%s/%d", baseDir, slug, version)
if filePath != nil && *filePath != "" {
skillDir = *filePath
}
return store.SkillInfo{
ID: id,
Name: name,
Slug: slug,
Path: skillDir + "/SKILL.md",
BaseDir: skillDir,
Source: "managed",
Description: d,
Version: version,
}
}
func readSkillFile(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
content := strings.ReplaceAll(string(data), "\r\n", "\n")
content = strings.ReplaceAll(content, "\r", "\n")
content = skillFrontmatterRe.ReplaceAllString(content, "")
return content, nil
}
func parseDepsColumn(raw []byte) []string {
if len(raw) == 0 {
return nil
}
var d struct {
Missing []string `json:"missing"`
}
if err := json.Unmarshal(raw, &d); err != nil {
return nil
}
if len(d.Missing) == 0 {
return nil
}
return d.Missing
}
func parseFrontmatterAuthor(raw []byte) string {
if len(raw) == 0 {
return ""
}
var fm map[string]string
if err := json.Unmarshal(raw, &fm); err != nil {
return ""
}
return fm["author"]
}
func marshalFrontmatter(fm map[string]string) []byte {
if len(fm) == 0 {
return []byte("{}")
}
b, err := json.Marshal(fm)
if err != nil {
return []byte("{}")
}
return b
}
@@ -0,0 +1,291 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func (s *SQLiteSkillStore) LoadSkill(ctx context.Context, name string) (string, bool) {
var slug string
var version int
var filePath *string
q := "SELECT slug, version, file_path FROM skills WHERE slug = ? AND status = 'active'"
args := []any{name}
if !store.IsCrossTenant(ctx) {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
tid = store.MasterTenantID
}
q += " AND (is_system = 1 OR tenant_id = ?)"
args = append(args, tid)
}
if err := s.db.QueryRowContext(ctx, q, args...).Scan(&slug, &version, &filePath); err != nil {
return "", false
}
info := buildSkillInfo("", "", slug, nil, version, s.baseDir, filePath)
content, err := readSkillFile(info.Path)
if err != nil {
return "", false
}
return content, true
}
func (s *SQLiteSkillStore) LoadForContext(ctx context.Context, allowList []string) string {
skills := s.FilterSkills(ctx, allowList)
if len(skills) == 0 {
return ""
}
var parts []string
for _, sk := range skills {
content, ok := s.LoadSkill(ctx, sk.Name)
if !ok {
continue
}
parts = append(parts, fmt.Sprintf("### Skill: %s\n\n%s", sk.Name, content))
}
if len(parts) == 0 {
return ""
}
var result strings.Builder
result.WriteString("## Available Skills\n\n")
for i, p := range parts {
if i > 0 {
result.WriteString("\n\n---\n\n")
}
result.WriteString(p)
}
return result.String()
}
func (s *SQLiteSkillStore) BuildSummary(ctx context.Context, allowList []string) string {
skills := s.FilterSkills(ctx, allowList)
if len(skills) == 0 {
return ""
}
var result strings.Builder
result.WriteString("<available_skills>\n")
for _, sk := range skills {
result.WriteString(" <skill>\n")
result.WriteString(fmt.Sprintf(" <name>%s</name>\n", sk.Name))
result.WriteString(fmt.Sprintf(" <description>%s</description>\n", sk.Description))
result.WriteString(fmt.Sprintf(" <location>%s</location>\n", sk.Path))
result.WriteString(" </skill>\n")
}
result.WriteString("</available_skills>")
return result.String()
}
func (s *SQLiteSkillStore) GetSkill(ctx context.Context, name string) (*store.SkillInfo, bool) {
var id uuid.UUID
var skillName, slug, visibility string
var desc *string
var tagsJSON []byte
var version int
var isSystem bool
var filePath *string
q := "SELECT id, name, slug, description, visibility, tags, version, is_system, file_path FROM skills WHERE slug = ? AND status = 'active'"
args := []any{name}
if !store.IsCrossTenant(ctx) {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
tid = store.MasterTenantID
}
q += " AND (is_system = 1 OR tenant_id = ?)"
args = append(args, tid)
}
if err := s.db.QueryRowContext(ctx, q, args...).Scan(&id, &skillName, &slug, &desc, &visibility, &tagsJSON, &version, &isSystem, &filePath); err != nil {
return nil, false
}
info := buildSkillInfo(id.String(), skillName, slug, desc, version, s.baseDir, filePath)
info.Visibility = visibility
scanJSONStringArray(tagsJSON, &info.Tags)
info.IsSystem = isSystem
return &info, true
}
func (s *SQLiteSkillStore) FilterSkills(ctx context.Context, allowList []string) []store.SkillInfo {
all := s.ListSkills(ctx)
var filtered []store.SkillInfo
if allowList == nil {
for _, sk := range all {
if sk.Enabled {
filtered = append(filtered, sk)
}
}
return filtered
}
if len(allowList) == 0 {
return nil
}
allowed := make(map[string]bool, len(allowList))
for _, name := range allowList {
allowed[name] = true
}
for _, sk := range all {
if sk.Enabled && allowed[sk.Slug] {
filtered = append(filtered, sk)
}
}
return filtered
}
// GetSkillByID returns a SkillInfo for any skill by UUID regardless of status.
func (s *SQLiteSkillStore) GetSkillByID(ctx context.Context, id uuid.UUID) (store.SkillInfo, bool) {
var name, slug, visibility, status string
var desc *string
var tagsJSON, depsRaw []byte
var version int
var isSystem, enabled bool
var filePath *string
q := `SELECT name, slug, description, visibility, tags, version, is_system, status, enabled, deps, file_path
FROM skills WHERE id = ?`
args := []any{id}
if !store.IsCrossTenant(ctx) {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
tid = store.MasterTenantID
}
q += " AND (is_system = 1 OR tenant_id = ?)"
args = append(args, tid)
}
if err := s.db.QueryRowContext(ctx, q, args...).Scan(&name, &slug, &desc, &visibility, &tagsJSON,
&version, &isSystem, &status, &enabled, &depsRaw, &filePath); err != nil {
return store.SkillInfo{}, false
}
info := buildSkillInfo(id.String(), name, slug, desc, version, s.baseDir, filePath)
info.Visibility = visibility
scanJSONStringArray(tagsJSON, &info.Tags)
info.IsSystem = isSystem
info.Status = status
info.Enabled = enabled
info.MissingDeps = parseDepsColumn(depsRaw)
return info, true
}
func (s *SQLiteSkillStore) GetSkillOwnerID(ctx context.Context, id uuid.UUID) (string, bool) {
q := "SELECT owner_id FROM skills WHERE id = ?"
args := []any{id}
if !store.IsCrossTenant(ctx) {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
tid = store.MasterTenantID
}
q += " AND (is_system = 1 OR tenant_id = ?)"
args = append(args, tid)
}
var ownerID string
if err := s.db.QueryRowContext(ctx, q, args...).Scan(&ownerID); err != nil {
return "", false
}
return ownerID, true
}
func (s *SQLiteSkillStore) GetSkillOwnerIDBySlug(ctx context.Context, slug string) (string, bool) {
q := "SELECT owner_id FROM skills WHERE slug = ? AND status = 'active'"
args := []any{slug}
if !store.IsCrossTenant(ctx) {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
tid = store.MasterTenantID
}
q += " AND (is_system = 1 OR tenant_id = ?)"
args = append(args, tid)
}
var ownerID string
if err := s.db.QueryRowContext(ctx, q, args...).Scan(&ownerID); err != nil {
return "", false
}
return ownerID, true
}
// UpsertSystemSkill creates or updates a system skill.
// Returns (id, changed, actualFilePath, error).
func (s *SQLiteSkillStore) UpsertSystemSkill(ctx context.Context, p store.SkillCreateParams) (uuid.UUID, bool, string, error) {
var existingID uuid.UUID
var existingHash *string
var existingFilePath string
err := s.db.QueryRowContext(ctx,
"SELECT id, file_hash, file_path FROM skills WHERE slug = ?", p.Slug,
).Scan(&existingID, &existingHash, &existingFilePath)
if err == nil {
if existingHash != nil && p.FileHash != nil && *existingHash == *p.FileHash {
return existingID, false, existingFilePath, nil
}
if existingHash == nil && p.FileHash != nil {
_, _ = s.db.ExecContext(ctx,
`UPDATE skills SET file_hash = ?, updated_at = ? WHERE id = ?`,
p.FileHash, time.Now().UTC(), existingID,
)
return existingID, false, existingFilePath, nil
}
fmJSON := marshalFrontmatter(p.Frontmatter)
_, err = s.db.ExecContext(ctx,
`UPDATE skills SET name = ?, description = ?, version = ?, frontmatter = ?,
file_path = ?, file_size = ?, file_hash = ?, is_system = 1,
visibility = 'public', status = ?, updated_at = ?
WHERE id = ?`,
p.Name, p.Description, p.Version, fmJSON,
p.FilePath, p.FileSize, p.FileHash, p.Status, time.Now().UTC(), existingID,
)
if err != nil {
return uuid.Nil, false, "", fmt.Errorf("update system skill: %w", err)
}
s.BumpVersion()
return existingID, true, p.FilePath, nil
}
id := store.GenNewID()
fmJSON := marshalFrontmatter(p.Frontmatter)
now := time.Now().UTC()
_, err = s.db.ExecContext(ctx,
`INSERT INTO skills (id, name, slug, description, owner_id, visibility, version, status,
is_system, frontmatter, file_path, file_size, file_hash, tenant_id, created_at, updated_at)
VALUES (?, ?, ?, ?, 'system', 'public', ?, ?, 1, ?, ?, ?, ?, ?, ?, ?)`,
id, p.Name, p.Slug, p.Description, p.Version, p.Status,
fmJSON, p.FilePath, p.FileSize, p.FileHash, store.MasterTenantID, now, now,
)
if err != nil {
return uuid.Nil, false, "", fmt.Errorf("insert system skill: %w", err)
}
s.BumpVersion()
return id, true, p.FilePath, nil
}
// ListSystemSkillDirs returns slug->file_path map for all enabled system skills.
func (s *SQLiteSkillStore) ListSystemSkillDirs(ctx context.Context) map[string]string {
rows, err := s.db.QueryContext(ctx,
`SELECT slug, file_path FROM skills WHERE is_system = 1 AND enabled = 1`)
if err != nil {
return nil
}
defer rows.Close()
dirs := make(map[string]string)
for rows.Next() {
var slug, path string
if err := rows.Scan(&slug, &path); err != nil {
continue
}
dirs[slug] = path
}
if err := rows.Err(); err != nil {
return nil
}
return dirs
}
// IsSystemSkill checks if a skill slug belongs to a system skill.
func (s *SQLiteSkillStore) IsSystemSkill(slug string) bool {
var isSystem bool
err := s.db.QueryRow("SELECT is_system FROM skills WHERE slug = ?", slug).Scan(&isSystem)
return err == nil && isSystem
}
+212
View File
@@ -0,0 +1,212 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func (s *SQLiteSkillStore) CreateSkill(name, slug string, description *string, ownerID, visibility string, version int, filePath string, fileSize int64, fileHash *string) error {
id := store.GenNewID()
now := time.Now().UTC()
_, err := s.db.Exec(
`INSERT INTO skills (id, name, slug, description, owner_id, visibility, version, status, file_path, file_size, file_hash, tenant_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?)`,
id, name, slug, description, ownerID, visibility, version, filePath, fileSize, fileHash, store.MasterTenantID, now, now,
)
if err == nil {
s.BumpVersion()
}
return err
}
func (s *SQLiteSkillStore) UpdateSkill(ctx context.Context, id uuid.UUID, updates map[string]any) error {
var err error
if store.IsCrossTenant(ctx) {
err = execMapUpdate(ctx, s.db, "skills", id, updates)
} else {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
return fmt.Errorf("tenant_id required for update")
}
err = execMapUpdateWhereTenant(ctx, s.db, "skills", updates, id, tid)
}
if err != nil {
return err
}
s.BumpVersion()
return nil
}
func (s *SQLiteSkillStore) DeleteSkill(ctx context.Context, id uuid.UUID) error {
var isSystem bool
if err := s.db.QueryRowContext(ctx, "SELECT is_system FROM skills WHERE id = ?", id).Scan(&isSystem); err != nil {
return fmt.Errorf("check skill: %w", err)
}
if isSystem {
return fmt.Errorf("cannot delete system skill")
}
if !store.IsCrossTenant(ctx) {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
return fmt.Errorf("tenant_id required")
}
var skillTenantID uuid.UUID
if err := s.db.QueryRowContext(ctx, "SELECT tenant_id FROM skills WHERE id = ?", id).Scan(&skillTenantID); err != nil {
return fmt.Errorf("skill not found")
}
if skillTenantID != tid {
return fmt.Errorf("skill not found")
}
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, "DELETE FROM skill_agent_grants WHERE skill_id = ?", id); err != nil {
return fmt.Errorf("delete skill grants: %w", err)
}
if _, err := tx.ExecContext(ctx, "DELETE FROM skill_user_grants WHERE skill_id = ?", id); err != nil {
return fmt.Errorf("delete skill user grants: %w", err)
}
if _, err := tx.ExecContext(ctx, "UPDATE skills SET status = 'deleted' WHERE id = ?", id); err != nil {
return fmt.Errorf("delete skill: %w", err)
}
if err := tx.Commit(); err != nil {
return err
}
s.BumpVersion()
return nil
}
// CreateSkillManaged creates or updates a skill from upload parameters.
// SQLite uses a serializable transaction to avoid the race on version calculation.
func (s *SQLiteSkillStore) CreateSkillManaged(ctx context.Context, p store.SkillCreateParams) (uuid.UUID, error) {
if err := store.ValidateUserID(p.OwnerID); err != nil {
return uuid.Nil, err
}
fmJSON := marshalFrontmatter(p.Frontmatter)
tenantID := store.TenantIDFromContext(ctx)
if tenantID == uuid.Nil {
tenantID = store.MasterTenantID
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return uuid.Nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
// Compute next version atomically under the transaction lock.
var version int
if err := tx.QueryRowContext(ctx,
"SELECT COALESCE(MAX(version), 0) + 1 FROM skills WHERE slug = ?", p.Slug,
).Scan(&version); err != nil {
return uuid.Nil, fmt.Errorf("get next version: %w", err)
}
now := time.Now().UTC()
// Check for existing skill with same tenant+slug (for upsert).
var existingID uuid.UUID
err = tx.QueryRowContext(ctx,
"SELECT id FROM skills WHERE tenant_id = ? AND slug = ?", tenantID, p.Slug,
).Scan(&existingID)
var returnedID uuid.UUID
if err == nil {
// Update existing.
_, err = tx.ExecContext(ctx,
`UPDATE skills SET name = ?, description = ?, version = ?, frontmatter = ?,
file_path = ?, file_size = ?, file_hash = ?,
visibility = CASE WHEN status IN ('archived', 'deleted') THEN 'private' ELSE visibility END,
status = 'active', updated_at = ? WHERE id = ?`,
p.Name, p.Description, version, fmJSON,
p.FilePath, p.FileSize, p.FileHash, now, existingID,
)
if err != nil {
return uuid.Nil, fmt.Errorf("update skill: %w", err)
}
returnedID = existingID
} else {
// Insert new.
newID := store.GenNewID()
_, err = tx.ExecContext(ctx,
`INSERT INTO skills (id, name, slug, description, owner_id, tenant_id, visibility, version, status, frontmatter, file_path, file_size, file_hash, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?)`,
newID, p.Name, p.Slug, p.Description, p.OwnerID, tenantID, p.Visibility, version,
fmJSON, p.FilePath, p.FileSize, p.FileHash, now, now,
)
if err != nil {
return uuid.Nil, fmt.Errorf("insert skill: %w", err)
}
returnedID = newID
}
if err := tx.Commit(); err != nil {
return uuid.Nil, fmt.Errorf("commit: %w", err)
}
s.BumpVersion()
return returnedID, nil
}
func (s *SQLiteSkillStore) GetSkillFilePath(ctx context.Context, id uuid.UUID) (filePath string, slug string, version int, isSystem bool, ok bool) {
q := "SELECT file_path, slug, version, is_system FROM skills WHERE id = ? AND status = 'active'"
args := []any{id}
if !store.IsCrossTenant(ctx) {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
tid = store.MasterTenantID
}
q += " AND (is_system = 1 OR tenant_id = ?)"
args = append(args, tid)
}
err := s.db.QueryRowContext(ctx, q, args...).Scan(&filePath, &slug, &version, &isSystem)
return filePath, slug, version, isSystem, err == nil
}
func (s *SQLiteSkillStore) GetNextVersion(ctx context.Context, slug string) int {
var maxVersion int
s.db.QueryRowContext(ctx, "SELECT COALESCE(MAX(version), 0) FROM skills WHERE slug = ?", slug).Scan(&maxVersion)
return maxVersion + 1
}
// GetNextVersionLocked returns the next version for a slug.
// SQLite has no advisory locks; returns a no-op closer.
func (s *SQLiteSkillStore) GetNextVersionLocked(ctx context.Context, slug string) (int, func() error, error) {
return s.GetNextVersion(ctx, slug), func() error { return nil }, nil
}
func (s *SQLiteSkillStore) ToggleSkill(ctx context.Context, id uuid.UUID, enabled bool) error {
now := time.Now().UTC()
q := `UPDATE skills SET enabled = ?, updated_at = ? WHERE id = ?`
args := []any{enabled, now, id}
if !store.IsCrossTenant(ctx) {
tid := store.TenantIDFromContext(ctx)
if tid != uuid.Nil {
q += " AND tenant_id = ?"
args = append(args, tid)
}
}
_, err := s.db.ExecContext(ctx, q, args...)
if err == nil {
s.BumpVersion()
}
return err
}
+234
View File
@@ -0,0 +1,234 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"log/slog"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// SkillGrantInfo is a simplified grant record for API responses.
type SkillGrantInfo struct {
SkillID uuid.UUID `json:"skill_id"`
PinnedVersion int `json:"pinned_version"`
GrantedBy string `json:"granted_by"`
}
// GrantToAgent grants a skill to an agent with version pinning.
func (s *SQLiteSkillStore) GrantToAgent(ctx context.Context, skillID, agentID uuid.UUID, version int, grantedBy string) error {
if err := store.ValidateUserID(grantedBy); err != nil {
return err
}
// Upsert grant.
id := store.GenNewID()
_, err := s.db.ExecContext(ctx,
`INSERT INTO skill_agent_grants (id, skill_id, agent_id, pinned_version, granted_by, created_at, tenant_id)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (skill_id, agent_id) DO UPDATE SET pinned_version = excluded.pinned_version`,
id, skillID, agentID, version, grantedBy, time.Now().UTC(), tenantIDForInsert(ctx),
)
if err != nil {
return err
}
// Auto-promote: private → internal.
_, err = s.db.ExecContext(ctx,
`UPDATE skills SET visibility = 'internal', updated_at = ? WHERE id = ? AND visibility = 'private'`,
time.Now().UTC(), skillID)
if err != nil {
slog.Warn("skill_grants: failed to auto-promote visibility", "skill_id", skillID, "error", err)
}
s.BumpVersion()
return nil
}
// RevokeFromAgent revokes a skill grant from an agent.
func (s *SQLiteSkillStore) RevokeFromAgent(ctx context.Context, skillID, agentID uuid.UUID) error {
tClause, tArgs, err := scopeClause(ctx)
if err != nil {
return err
}
_, err = s.db.ExecContext(ctx,
"DELETE FROM skill_agent_grants WHERE skill_id = ? AND agent_id = ?"+tClause,
append([]any{skillID, agentID}, tArgs...)...)
if err != nil {
return err
}
// Auto-demote: internal → private when no grants remain.
_, err = s.db.ExecContext(ctx,
`UPDATE skills SET visibility = 'private', updated_at = ?
WHERE id = ? AND visibility = 'internal'
AND NOT EXISTS (SELECT 1 FROM skill_agent_grants WHERE skill_id = ?)`,
time.Now().UTC(), skillID, skillID)
if err != nil {
slog.Warn("skill_grants: failed to auto-demote visibility", "skill_id", skillID, "error", err)
}
s.BumpVersion()
return nil
}
// ListAgentGrants returns all skill grants for an agent.
func (s *SQLiteSkillStore) ListAgentGrants(ctx context.Context, agentID uuid.UUID) ([]SkillGrantInfo, error) {
tClause, tArgs, err := scopeClause(ctx)
if err != nil {
return nil, err
}
rows, err := s.db.QueryContext(ctx,
"SELECT skill_id, pinned_version, granted_by FROM skill_agent_grants WHERE agent_id = ?"+tClause,
append([]any{agentID}, tArgs...)...)
if err != nil {
return nil, err
}
defer rows.Close()
var result []SkillGrantInfo
for rows.Next() {
var g SkillGrantInfo
if err := rows.Scan(&g.SkillID, &g.PinnedVersion, &g.GrantedBy); err != nil {
slog.Warn("skill_grants: scan error in ListAgentGrants", "error", err)
continue
}
result = append(result, g)
}
return result, rows.Err()
}
// GrantToUser grants a skill to a user.
func (s *SQLiteSkillStore) GrantToUser(ctx context.Context, skillID uuid.UUID, userID, grantedBy string) error {
if err := store.ValidateUserID(userID); err != nil {
return err
}
if err := store.ValidateUserID(grantedBy); err != nil {
return err
}
id := store.GenNewID()
_, err := s.db.ExecContext(ctx,
`INSERT INTO skill_user_grants (id, skill_id, user_id, granted_by, created_at, tenant_id)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (skill_id, user_id) DO NOTHING`,
id, skillID, userID, grantedBy, time.Now().UTC(), tenantIDForInsert(ctx),
)
return err
}
// RevokeFromUser revokes a skill grant from a user.
func (s *SQLiteSkillStore) RevokeFromUser(ctx context.Context, skillID uuid.UUID, userID string) error {
tClause, tArgs, err := scopeClause(ctx)
if err != nil {
return err
}
_, err = s.db.ExecContext(ctx,
"DELETE FROM skill_user_grants WHERE skill_id = ? AND user_id = ?"+tClause,
append([]any{skillID, userID}, tArgs...)...)
return err
}
// ListAccessible returns skills accessible to a given agent+user combination.
func (s *SQLiteSkillStore) ListAccessible(ctx context.Context, agentID uuid.UUID, userID string) ([]store.SkillInfo, error) {
tClause, tArgs, err := scopeClauseAlias(ctx, "s")
if err != nil {
return nil, err
}
tenantCond := ""
stcJoin := ""
stcFilter := ""
if len(tArgs) > 0 {
tenantCond = " AND (s.is_system = 1 OR s.tenant_id = ?)"
stcJoin = " LEFT JOIN skill_tenant_configs stc ON s.id = stc.skill_id AND stc.tenant_id = ?"
stcFilter = " AND (stc.enabled IS NULL OR stc.enabled = 1)"
}
// Build args: agentID, userID, [tenantID for tenant cond, tenantID for stc join]
queryArgs := []any{agentID, userID}
if len(tArgs) > 0 {
queryArgs = append(queryArgs, tArgs...) // tenant cond
queryArgs = append(queryArgs, tArgs...) // stc join
}
// Remove tClause (aliased scope) — we handle it manually above.
_ = tClause
rows, err := s.db.QueryContext(ctx,
`SELECT DISTINCT s.name, s.slug, s.description, s.version, s.file_path FROM skills s
LEFT JOIN skill_agent_grants sag ON s.id = sag.skill_id AND sag.agent_id = ?
LEFT JOIN skill_user_grants sug ON s.id = sug.skill_id AND sug.user_id = ?`+stcJoin+`
WHERE s.status = 'active'`+tenantCond+stcFilter+` AND (
s.is_system = 1
OR s.visibility = 'public'
OR (s.visibility = 'private' AND s.owner_id = ?)
OR (s.visibility = 'internal' AND (sag.id IS NOT NULL OR sug.id IS NOT NULL))
)
ORDER BY s.name`,
append(queryArgs, userID)...,
)
if err != nil {
return nil, err
}
defer rows.Close()
var result []store.SkillInfo
for rows.Next() {
var name, slug string
var desc *string
var version int
var filePath *string
if err := rows.Scan(&name, &slug, &desc, &version, &filePath); err != nil {
slog.Warn("skill_grants: scan error in ListAccessible", "error", err)
continue
}
result = append(result, buildSkillInfo("", name, slug, desc, version, s.baseDir, filePath))
}
return result, rows.Err()
}
// ListWithGrantStatus returns all active skills with grant status for a specific agent.
func (s *SQLiteSkillStore) ListWithGrantStatus(ctx context.Context, agentID uuid.UUID) ([]store.SkillWithGrantStatus, error) {
tClause, tArgs, err := scopeClauseAlias(ctx, "s")
if err != nil {
return nil, err
}
tenantCond := ""
if len(tArgs) > 0 {
tenantCond = " AND (s.is_system = 1 OR s.tenant_id = ?)"
}
_ = tClause
queryArgs := []any{agentID}
if len(tArgs) > 0 {
queryArgs = append(queryArgs, tArgs...)
}
rows, err := s.db.QueryContext(ctx,
`SELECT s.id, s.name, s.slug, COALESCE(s.description, ''), s.visibility, s.version,
(sag.id IS NOT NULL) AS granted,
sag.pinned_version,
s.is_system
FROM skills s
LEFT JOIN skill_agent_grants sag ON s.id = sag.skill_id AND sag.agent_id = ?
WHERE s.status = 'active'`+tenantCond+`
ORDER BY s.name`, queryArgs...)
if err != nil {
return nil, err
}
defer rows.Close()
var result []store.SkillWithGrantStatus
for rows.Next() {
var r store.SkillWithGrantStatus
if err := rows.Scan(&r.ID, &r.Name, &r.Slug, &r.Description, &r.Visibility,
&r.Version, &r.Granted, &r.PinnedVer, &r.IsSystem); err != nil {
slog.Warn("skill_grants: scan error in ListWithGrantStatus", "error", err)
continue
}
result = append(result, r)
}
return result, rows.Err()
}
+304
View File
@@ -0,0 +1,304 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// SQLiteSnapshotStore implements store.SnapshotStore backed by SQLite.
type SQLiteSnapshotStore struct {
db *sql.DB
}
func NewSQLiteSnapshotStore(db *sql.DB) *SQLiteSnapshotStore {
return &SQLiteSnapshotStore{db: db}
}
const snapshotFieldCount = 22
// sqliteSnapshotBatchSize limits each INSERT to stay under SQLite's 999-variable limit (999 / 22 ≈ 45 → use 40).
const sqliteSnapshotBatchSize = 40
func (s *SQLiteSnapshotStore) UpsertSnapshots(ctx context.Context, snapshots []store.UsageSnapshot) error {
if len(snapshots) == 0 {
return nil
}
for start := 0; start < len(snapshots); start += sqliteSnapshotBatchSize {
end := start + sqliteSnapshotBatchSize
if end > len(snapshots) {
end = len(snapshots)
}
if err := s.upsertBatch(ctx, snapshots[start:end]); err != nil {
return err
}
}
return nil
}
func (s *SQLiteSnapshotStore) upsertBatch(ctx context.Context, snapshots []store.UsageSnapshot) error {
tenantID := store.TenantIDFromContext(ctx)
if tenantID == uuid.Nil {
tenantID = store.MasterTenantID
}
placeholderRow := "(" + strings.Repeat("?, ", snapshotFieldCount-1) + "?)"
vals := make([]string, len(snapshots))
var args []any
for i, snap := range snapshots {
vals[i] = placeholderRow
args = append(args,
snap.BucketHour, nilUUID(snap.AgentID), snap.Provider, snap.Model, snap.Channel,
snap.InputTokens, snap.OutputTokens, snap.CacheReadTokens, snap.CacheCreateTokens, snap.ThinkingTokens,
snap.TotalCost, snap.RequestCount, snap.LLMCallCount, snap.ToolCallCount,
snap.ErrorCount, snap.UniqueUsers, snap.AvgDurationMS,
snap.MemoryDocs, snap.MemoryChunks, snap.KGEntities, snap.KGRelations,
tenantID,
)
}
query := `INSERT INTO usage_snapshots (
bucket_hour, agent_id, provider, model, channel,
input_tokens, output_tokens, cache_read_tokens, cache_create_tokens, thinking_tokens,
total_cost, request_count, llm_call_count, tool_call_count,
error_count, unique_users, avg_duration_ms,
memory_docs, memory_chunks, kg_entities, kg_relations,
tenant_id
) VALUES ` + strings.Join(vals, ", ") + `
ON CONFLICT (bucket_hour, COALESCE(agent_id, '00000000-0000-0000-0000-000000000000'), COALESCE(provider, ''), COALESCE(model, ''), COALESCE(channel, ''), tenant_id)
DO UPDATE SET
input_tokens = excluded.input_tokens,
output_tokens = excluded.output_tokens,
cache_read_tokens = excluded.cache_read_tokens,
cache_create_tokens = excluded.cache_create_tokens,
thinking_tokens = excluded.thinking_tokens,
total_cost = excluded.total_cost,
request_count = excluded.request_count,
llm_call_count = excluded.llm_call_count,
tool_call_count = excluded.tool_call_count,
error_count = excluded.error_count,
unique_users = excluded.unique_users,
avg_duration_ms = excluded.avg_duration_ms,
memory_docs = excluded.memory_docs,
memory_chunks = excluded.memory_chunks,
kg_entities = excluded.kg_entities,
kg_relations = excluded.kg_relations`
_, err := s.db.ExecContext(ctx, query, args...)
return err
}
func (s *SQLiteSnapshotStore) GetTimeSeries(ctx context.Context, q store.SnapshotQuery) ([]store.SnapshotTimeSeries, error) {
bucketExpr := "bucket_hour"
if q.GroupBy == "day" {
bucketExpr = "strftime('%Y-%m-%d 00:00:00', bucket_hour)"
}
where, args := buildSnapshotWhere(ctx, q)
query := fmt.Sprintf(`SELECT
bucket_time,
SUM(input_tokens), SUM(output_tokens),
SUM(cache_read_tokens), SUM(cache_create_tokens), SUM(thinking_tokens),
SUM(total_cost),
SUM(request_count), SUM(llm_call_count), SUM(tool_call_count),
SUM(error_count), SUM(unique_users),
CASE WHEN SUM(request_count) > 0
THEN SUM(avg_duration_ms * request_count) / SUM(request_count)
ELSE 0 END,
SUM(memory_docs), SUM(memory_chunks),
SUM(kg_entities), SUM(kg_relations)
FROM (
SELECT
%s as bucket_time,
CASE WHEN provider != '' THEN input_tokens ELSE 0 END as input_tokens,
CASE WHEN provider != '' THEN output_tokens ELSE 0 END as output_tokens,
CASE WHEN provider != '' THEN cache_read_tokens ELSE 0 END as cache_read_tokens,
CASE WHEN provider != '' THEN cache_create_tokens ELSE 0 END as cache_create_tokens,
CASE WHEN provider != '' THEN thinking_tokens ELSE 0 END as thinking_tokens,
CASE WHEN provider != '' THEN total_cost ELSE 0 END as total_cost,
CASE WHEN provider != '' THEN llm_call_count ELSE 0 END as llm_call_count,
CASE WHEN provider = '' AND model = '' THEN request_count ELSE 0 END as request_count,
CASE WHEN provider = '' AND model = '' THEN tool_call_count ELSE 0 END as tool_call_count,
CASE WHEN provider = '' AND model = '' THEN error_count ELSE 0 END as error_count,
CASE WHEN provider = '' AND model = '' THEN unique_users ELSE 0 END as unique_users,
CASE WHEN provider = '' AND model = '' THEN avg_duration_ms ELSE 0 END as avg_duration_ms,
CASE WHEN provider = '' AND model = '' THEN memory_docs ELSE 0 END as memory_docs,
CASE WHEN provider = '' AND model = '' THEN memory_chunks ELSE 0 END as memory_chunks,
CASE WHEN provider = '' AND model = '' THEN kg_entities ELSE 0 END as kg_entities,
CASE WHEN provider = '' AND model = '' THEN kg_relations ELSE 0 END as kg_relations
FROM usage_snapshots
%s
) sub
GROUP BY bucket_time
ORDER BY bucket_time`, bucketExpr, where)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("get timeseries: %w", err)
}
defer rows.Close()
var result []store.SnapshotTimeSeries
for rows.Next() {
var ts store.SnapshotTimeSeries
if err := rows.Scan(
&ts.BucketTime,
&ts.InputTokens, &ts.OutputTokens,
&ts.CacheReadTokens, &ts.CacheCreateTokens, &ts.ThinkingTokens,
&ts.TotalCost,
&ts.RequestCount, &ts.LLMCallCount, &ts.ToolCallCount,
&ts.ErrorCount, &ts.UniqueUsers, &ts.AvgDurationMS,
&ts.MemoryDocs, &ts.MemoryChunks,
&ts.KGEntities, &ts.KGRelations,
); err != nil {
return nil, fmt.Errorf("scan timeseries: %w", err)
}
result = append(result, ts)
}
return result, rows.Err()
}
func (s *SQLiteSnapshotStore) GetBreakdown(ctx context.Context, q store.SnapshotQuery) ([]store.SnapshotBreakdown, error) {
groupBy := q.GroupBy
if groupBy == "" {
groupBy = "provider"
}
var groupCol, orderExpr, extraFilter string
switch groupBy {
case "provider":
groupCol = "provider"
orderExpr = "SUM(CASE WHEN provider != '' THEN input_tokens ELSE 0 END) DESC"
extraFilter = " AND provider != '' AND model != ''"
case "model":
groupCol = "model"
orderExpr = "SUM(CASE WHEN provider != '' THEN input_tokens ELSE 0 END) DESC"
extraFilter = " AND provider != '' AND model != ''"
case "channel":
groupCol = "channel"
orderExpr = "SUM(CASE WHEN provider = '' AND model = '' THEN request_count ELSE 0 END) DESC"
extraFilter = " AND channel != ''"
case "agent":
groupCol = "agent_id" // already TEXT in SQLite — no ::TEXT cast needed
orderExpr = "SUM(CASE WHEN provider != '' THEN input_tokens ELSE 0 END) DESC"
extraFilter = ""
default:
groupCol = "provider"
orderExpr = "SUM(input_tokens) DESC"
extraFilter = " AND provider != '' AND model != ''"
}
where, args := buildSnapshotWhere(ctx, q)
if where == "" {
where = " WHERE 1=1"
}
where += extraFilter
query := fmt.Sprintf(`SELECT
%s as key,
SUM(CASE WHEN provider != '' THEN input_tokens ELSE 0 END),
SUM(CASE WHEN provider != '' THEN output_tokens ELSE 0 END),
SUM(CASE WHEN provider != '' THEN cache_read_tokens ELSE 0 END),
SUM(CASE WHEN provider != '' THEN cache_create_tokens ELSE 0 END),
SUM(CASE WHEN provider != '' THEN total_cost ELSE 0 END),
SUM(CASE WHEN provider = '' AND model = '' THEN request_count ELSE 0 END),
SUM(CASE WHEN provider != '' THEN llm_call_count ELSE 0 END),
SUM(CASE WHEN provider = '' AND model = '' THEN tool_call_count ELSE 0 END),
SUM(CASE WHEN provider = '' AND model = '' THEN error_count ELSE 0 END),
CASE WHEN SUM(CASE WHEN provider = '' AND model = '' THEN request_count ELSE 0 END) > 0
THEN SUM(CASE WHEN provider = '' AND model = '' THEN avg_duration_ms * request_count ELSE 0 END) /
SUM(CASE WHEN provider = '' AND model = '' THEN request_count ELSE 0 END)
ELSE 0 END
FROM usage_snapshots
%s
GROUP BY %s
ORDER BY %s`, groupCol, where, groupCol, orderExpr)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("get breakdown: %w", err)
}
defer rows.Close()
var result []store.SnapshotBreakdown
for rows.Next() {
var b store.SnapshotBreakdown
if err := rows.Scan(
&b.Key,
&b.InputTokens, &b.OutputTokens,
&b.CacheReadTokens, &b.CacheCreateTokens,
&b.TotalCost,
&b.RequestCount, &b.LLMCallCount, &b.ToolCallCount,
&b.ErrorCount, &b.AvgDurationMS,
); err != nil {
return nil, fmt.Errorf("scan breakdown: %w", err)
}
result = append(result, b)
}
return result, rows.Err()
}
func (s *SQLiteSnapshotStore) GetLatestBucket(ctx context.Context) (*time.Time, error) {
var nt nullSqliteTime
err := s.db.QueryRowContext(ctx, `SELECT MAX(bucket_hour) FROM usage_snapshots`).Scan(&nt)
if err != nil {
return nil, fmt.Errorf("get latest bucket: %w", err)
}
if !nt.Valid {
return nil, nil
}
return &nt.Time, nil
}
// buildSnapshotWhere builds a WHERE clause with ? placeholders for SQLite.
func buildSnapshotWhere(ctx context.Context, q store.SnapshotQuery) (string, []any) {
var conds []string
var args []any
if !store.IsCrossTenant(ctx) {
tenantID := store.TenantIDFromContext(ctx)
if tenantID != uuid.Nil {
conds = append(conds, "tenant_id = ?")
args = append(args, tenantID)
}
}
if !q.From.IsZero() {
conds = append(conds, "bucket_hour >= ?")
args = append(args, q.From)
}
if !q.To.IsZero() {
conds = append(conds, "bucket_hour < ?")
args = append(args, q.To)
}
if q.AgentID != nil {
conds = append(conds, "agent_id = ?")
args = append(args, *q.AgentID)
}
if q.Provider != "" {
conds = append(conds, "provider = ?")
args = append(args, q.Provider)
}
if q.Model != "" {
conds = append(conds, "model = ?")
args = append(args, q.Model)
}
if q.Channel != "" {
conds = append(conds, "channel = ?")
args = append(args, q.Channel)
}
if len(conds) == 0 {
return "", nil
}
return " WHERE " + strings.Join(conds, " AND "), args
}
@@ -0,0 +1,100 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"time"
)
// SQLiteSystemConfigStore implements store.SystemConfigStore backed by SQLite.
// Strict tenant isolation — all operations require tenant_id in context (no fallback).
type SQLiteSystemConfigStore struct {
db *sql.DB
}
func NewSQLiteSystemConfigStore(db *sql.DB) *SQLiteSystemConfigStore {
return &SQLiteSystemConfigStore{db: db}
}
func (s *SQLiteSystemConfigStore) Get(ctx context.Context, key string) (string, error) {
tid, err := requireTenantID(ctx)
if err != nil {
return "", fmt.Errorf("system config get: %w", err)
}
var val string
err = s.db.QueryRowContext(ctx,
"SELECT value FROM system_configs WHERE key = ? AND tenant_id = ?",
key, tid,
).Scan(&val)
if err == nil {
return val, nil
}
if errors.Is(err, sql.ErrNoRows) {
return "", fmt.Errorf("system config not found: %s", key)
}
return "", fmt.Errorf("system config get: %w", err)
}
func (s *SQLiteSystemConfigStore) Set(ctx context.Context, key, value string) error {
tid, err := requireTenantID(ctx)
if err != nil {
slog.Warn("system_config.set: no tenant in context", "key", key)
return fmt.Errorf("system config set: %w", err)
}
_, err = s.db.ExecContext(ctx,
`INSERT INTO system_configs (key, value, tenant_id, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT (key, tenant_id) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
key, value, tid, time.Now(),
)
return err
}
func (s *SQLiteSystemConfigStore) Delete(ctx context.Context, key string) error {
tid, err := requireTenantID(ctx)
if err != nil {
return fmt.Errorf("system config delete: %w", err)
}
_, err = s.db.ExecContext(ctx,
"DELETE FROM system_configs WHERE key = ? AND tenant_id = ?",
key, tid,
)
return err
}
func (s *SQLiteSystemConfigStore) List(ctx context.Context) (map[string]string, error) {
tid, err := requireTenantID(ctx)
if err != nil {
return nil, fmt.Errorf("system config list: %w", err)
}
rows, err := s.db.QueryContext(ctx,
"SELECT key, value FROM system_configs WHERE tenant_id = ? ORDER BY key",
tid,
)
if err != nil {
return nil, err
}
defer rows.Close()
result := make(map[string]string)
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err != nil {
return nil, fmt.Errorf("system config scan: %w", err)
}
result[k] = v
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("system config list: %w", err)
}
return result, nil
}
+505
View File
@@ -0,0 +1,505 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// SQLiteTeamStore implements store.TeamStore backed by SQLite.
type SQLiteTeamStore struct {
db *sql.DB
}
// SetEmbeddingProvider is a no-op for SQLite — vector search not supported.
func (s *SQLiteTeamStore) SetEmbeddingProvider(_ store.EmbeddingProvider) {}
func NewSQLiteTeamStore(db *sql.DB) *SQLiteTeamStore {
return &SQLiteTeamStore{db: db}
}
const teamSelectCols = `id, name, lead_agent_id, description, status, settings, created_by, created_at, updated_at`
// ============================================================
// Team CRUD
// ============================================================
func (s *SQLiteTeamStore) CreateTeam(ctx context.Context, team *store.TeamData) error {
if team.ID == uuid.Nil {
team.ID = store.GenNewID()
}
now := time.Now()
team.CreatedAt = now
team.UpdatedAt = now
settings := team.Settings
if len(settings) == 0 {
settings = json.RawMessage(`{}`)
}
tenantID := store.TenantIDFromContext(ctx)
if tenantID == uuid.Nil {
tenantID = store.MasterTenantID
}
_, err := s.db.ExecContext(ctx,
`INSERT INTO agent_teams (id, name, lead_agent_id, description, status, settings, created_by, created_at, updated_at, tenant_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
team.ID, team.Name, team.LeadAgentID, team.Description,
team.Status, settings, team.CreatedBy, now, now, tenantID,
)
return err
}
func (s *SQLiteTeamStore) GetTeam(ctx context.Context, teamID uuid.UUID) (*store.TeamData, error) {
if store.IsCrossTenant(ctx) {
row := s.db.QueryRowContext(ctx,
`SELECT `+teamSelectCols+` FROM agent_teams WHERE id = ?`, teamID)
return scanTeamRow(row)
}
tenantID := store.TenantIDFromContext(ctx)
if tenantID == uuid.Nil {
return nil, nil
}
row := s.db.QueryRowContext(ctx,
`SELECT `+teamSelectCols+` FROM agent_teams WHERE id = ? AND tenant_id = ?`, teamID, tenantID)
d, err := scanTeamRow(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return d, err
}
func (s *SQLiteTeamStore) UpdateTeam(ctx context.Context, teamID uuid.UUID, updates map[string]any) error {
if store.IsCrossTenant(ctx) {
return execMapUpdate(ctx, s.db, "agent_teams", teamID, updates)
}
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
return fmt.Errorf("tenant_id required for update")
}
return execMapUpdateWhereTenant(ctx, s.db, "agent_teams", updates, teamID, tid)
}
func (s *SQLiteTeamStore) DeleteTeam(ctx context.Context, teamID uuid.UUID) error {
if store.IsCrossTenant(ctx) {
_, err := s.db.ExecContext(ctx, `DELETE FROM agent_teams WHERE id = ?`, teamID)
return err
}
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
return fmt.Errorf("tenant_id required for delete")
}
_, err := s.db.ExecContext(ctx, `DELETE FROM agent_teams WHERE id = ? AND tenant_id = ?`, teamID, tid)
return err
}
func (s *SQLiteTeamStore) ListTeams(ctx context.Context) ([]store.TeamData, error) {
var tenantFilter string
var queryArgs []any
if !store.IsCrossTenant(ctx) {
tenantID := store.TenantIDFromContext(ctx)
if tenantID == uuid.Nil {
return nil, nil
}
tenantFilter = " WHERE t.tenant_id = ?"
queryArgs = append(queryArgs, tenantID)
}
rows, err := s.db.QueryContext(ctx,
`SELECT t.id, t.name, t.lead_agent_id, t.description, t.status, t.settings, t.created_by, t.created_at, t.updated_at,
COALESCE(a.agent_key, '') AS lead_agent_key,
COALESCE(a.display_name, '') AS lead_display_name
FROM agent_teams t
LEFT JOIN agents a ON a.id = t.lead_agent_id`+tenantFilter+`
ORDER BY t.created_at`, queryArgs...)
if err != nil {
return nil, err
}
defer rows.Close()
var teams []store.TeamData
teamIndex := map[uuid.UUID]int{}
for rows.Next() {
var d store.TeamData
var desc sql.NullString
createdAt, updatedAt := scanTimePair()
if err := rows.Scan(
&d.ID, &d.Name, &d.LeadAgentID, &desc, &d.Status,
&d.Settings, &d.CreatedBy, createdAt, updatedAt,
&d.LeadAgentKey, &d.LeadDisplayName,
); err != nil {
return nil, err
}
d.CreatedAt = createdAt.Time
d.UpdatedAt = updatedAt.Time
if desc.Valid {
d.Description = desc.String
}
teamIndex[d.ID] = len(teams)
teams = append(teams, d)
}
if err := rows.Err(); err != nil {
return nil, err
}
if len(teams) > 0 {
mRows, err := s.db.QueryContext(ctx,
`SELECT m.team_id, m.agent_id, m.role, m.joined_at,
COALESCE(a.agent_key, '') AS agent_key,
COALESCE(a.display_name, '') AS display_name,
COALESCE(a.frontmatter, '') AS frontmatter,
COALESCE(json_extract(a.other_config, '$.emoji'), '') AS emoji
FROM agent_team_members m
JOIN agents a ON a.id = m.agent_id
WHERE a.status = 'active'
ORDER BY m.joined_at`)
if err != nil {
return nil, err
}
defer mRows.Close()
for mRows.Next() {
var m store.TeamMemberData
var joinedAt sqliteTime
if err := mRows.Scan(&m.TeamID, &m.AgentID, &m.Role, &joinedAt, &m.AgentKey, &m.DisplayName, &m.Frontmatter, &m.Emoji); err != nil {
return nil, err
}
m.JoinedAt = joinedAt.Time
if idx, ok := teamIndex[m.TeamID]; ok {
teams[idx].Members = append(teams[idx].Members, m)
teams[idx].MemberCount++
}
}
if err := mRows.Err(); err != nil {
return nil, err
}
}
return teams, nil
}
// ============================================================
// Members
// ============================================================
func (s *SQLiteTeamStore) AddMember(ctx context.Context, teamID, agentID uuid.UUID, role string) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO agent_team_members (team_id, agent_id, role, joined_at, tenant_id)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT (team_id, agent_id) DO UPDATE SET role = excluded.role`,
teamID, agentID, role, time.Now(), tenantIDForInsert(ctx),
)
return err
}
func (s *SQLiteTeamStore) RemoveMember(ctx context.Context, teamID, agentID uuid.UUID) error {
_, err := s.db.ExecContext(ctx,
`DELETE FROM agent_team_members WHERE team_id = ? AND agent_id = ?`,
teamID, agentID,
)
return err
}
func (s *SQLiteTeamStore) ListMembers(ctx context.Context, teamID uuid.UUID) ([]store.TeamMemberData, error) {
q := `SELECT m.team_id, m.agent_id, m.role, m.joined_at,
COALESCE(a.agent_key, '') AS agent_key,
COALESCE(a.display_name, '') AS display_name,
COALESCE(a.frontmatter, '') AS frontmatter,
COALESCE(json_extract(a.other_config, '$.emoji'), '') AS emoji
FROM agent_team_members m
JOIN agents a ON a.id = m.agent_id
JOIN agent_teams at2 ON at2.id = m.team_id
WHERE m.team_id = ? AND a.status = 'active'`
args := []any{teamID}
if !store.IsCrossTenant(ctx) {
tid := store.TenantIDFromContext(ctx)
if tid != uuid.Nil {
q += " AND at2.tenant_id = ?"
args = append(args, tid)
}
}
q += ` ORDER BY m.joined_at`
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var members []store.TeamMemberData
for rows.Next() {
var d store.TeamMemberData
var joinedAt sqliteTime
if err := rows.Scan(
&d.TeamID, &d.AgentID, &d.Role, &joinedAt,
&d.AgentKey, &d.DisplayName, &d.Frontmatter, &d.Emoji,
); err != nil {
return nil, err
}
d.JoinedAt = joinedAt.Time
members = append(members, d)
}
return members, rows.Err()
}
func (s *SQLiteTeamStore) ListIdleMembers(ctx context.Context, teamID uuid.UUID) ([]store.TeamMemberData, error) {
q := `SELECT m.team_id, m.agent_id, m.role, m.joined_at,
COALESCE(a.agent_key, '') AS agent_key,
COALESCE(a.display_name, '') AS display_name,
COALESCE(a.frontmatter, '') AS frontmatter,
COALESCE(json_extract(a.other_config, '$.emoji'), '') AS emoji
FROM agent_team_members m
JOIN agents a ON a.id = m.agent_id
JOIN agent_teams at2 ON at2.id = m.team_id
WHERE m.team_id = ? AND a.status = 'active' AND m.role != ?
AND NOT EXISTS (
SELECT 1 FROM team_tasks tt
WHERE tt.owner_agent_id = m.agent_id AND tt.team_id = ? AND tt.status = ?
)`
args := []any{teamID, store.TeamRoleLead, teamID, store.TeamTaskStatusInProgress}
if !store.IsCrossTenant(ctx) {
tid := store.TenantIDFromContext(ctx)
if tid != uuid.Nil {
q += " AND at2.tenant_id = ?"
args = append(args, tid)
}
}
q += ` ORDER BY m.joined_at`
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var members []store.TeamMemberData
for rows.Next() {
var d store.TeamMemberData
var joinedAt sqliteTime
if err := rows.Scan(
&d.TeamID, &d.AgentID, &d.Role, &joinedAt,
&d.AgentKey, &d.DisplayName, &d.Frontmatter, &d.Emoji,
); err != nil {
return nil, err
}
d.JoinedAt = joinedAt.Time
members = append(members, d)
}
return members, rows.Err()
}
func (s *SQLiteTeamStore) GetTeamForAgent(ctx context.Context, agentID uuid.UUID) (*store.TeamData, error) {
q := `SELECT t.id, t.name, t.lead_agent_id, t.description, t.status, t.settings, t.created_by, t.created_at, t.updated_at
FROM agent_teams t
WHERE (
t.lead_agent_id = ?
OR EXISTS (SELECT 1 FROM agent_team_members m WHERE m.team_id = t.id AND m.agent_id = ?)
) AND t.status = ?`
args := []any{agentID, agentID, store.TeamStatusActive}
if !store.IsCrossTenant(ctx) {
tid := store.TenantIDFromContext(ctx)
if tid != uuid.Nil {
q += " AND t.tenant_id = ?"
args = append(args, tid)
}
}
q += ` ORDER BY (t.lead_agent_id = ?) DESC LIMIT 1`
args = append(args, agentID)
row := s.db.QueryRowContext(ctx, q, args...)
d, err := scanTeamRow(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return d, err
}
func (s *SQLiteTeamStore) KnownUserIDs(ctx context.Context, teamID uuid.UUID, limit int) ([]string, error) {
if limit <= 0 {
limit = 100
}
q := `SELECT DISTINCT s.user_id
FROM sessions s
JOIN agent_team_members m ON m.agent_id = s.agent_id
JOIN agent_teams at2 ON at2.id = m.team_id
WHERE m.team_id = ? AND s.user_id != ''`
args := []any{teamID}
if !store.IsCrossTenant(ctx) {
tid := store.TenantIDFromContext(ctx)
if tid != uuid.Nil {
q += " AND at2.tenant_id = ?"
args = append(args, tid)
}
}
q += ` ORDER BY s.user_id LIMIT ?`
args = append(args, limit)
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var users []string
for rows.Next() {
var uid string
if err := rows.Scan(&uid); err != nil {
return nil, err
}
users = append(users, uid)
}
return users, rows.Err()
}
// ============================================================
// Team user grants
// ============================================================
func (s *SQLiteTeamStore) GrantTeamAccess(ctx context.Context, teamID uuid.UUID, userID, role, grantedBy string) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO team_user_grants (id, team_id, user_id, role, granted_by, created_at, tenant_id)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (team_id, user_id) DO UPDATE SET role = excluded.role, granted_by = excluded.granted_by`,
store.GenNewID(), teamID, userID, role, grantedBy, time.Now(), tenantIDForInsert(ctx),
)
return err
}
func (s *SQLiteTeamStore) RevokeTeamAccess(ctx context.Context, teamID uuid.UUID, userID string) error {
tClause, tArgs, err := scopeClause(ctx)
if err != nil {
return err
}
_, err = s.db.ExecContext(ctx,
`DELETE FROM team_user_grants WHERE team_id = ? AND user_id = ?`+tClause,
append([]any{teamID, userID}, tArgs...)...)
return err
}
func (s *SQLiteTeamStore) ListTeamGrants(ctx context.Context, teamID uuid.UUID) ([]store.TeamUserGrant, error) {
tClause, tArgs, err := scopeClause(ctx)
if err != nil {
return nil, err
}
rows, err := s.db.QueryContext(ctx,
`SELECT id, team_id, user_id, role, COALESCE(granted_by, ''), created_at
FROM team_user_grants WHERE team_id = ?`+tClause+` ORDER BY created_at DESC`,
append([]any{teamID}, tArgs...)...)
if err != nil {
return nil, err
}
defer rows.Close()
var result []store.TeamUserGrant
for rows.Next() {
var g store.TeamUserGrant
var createdAt sqliteTime
if err := rows.Scan(&g.ID, &g.TeamID, &g.UserID, &g.Role, &g.GrantedBy, &createdAt); err != nil {
return nil, err
}
g.CreatedAt = createdAt.Time
result = append(result, g)
}
return result, rows.Err()
}
func (s *SQLiteTeamStore) ListUserTeams(ctx context.Context, userID string) ([]store.TeamData, error) {
baseQuery := `SELECT ` + teamSelectCols + `
FROM agent_teams t
WHERE t.status = ?
AND EXISTS (SELECT 1 FROM team_user_grants g WHERE g.team_id = t.id AND g.user_id = ?)`
args := []any{store.TeamStatusActive, userID}
if !store.IsCrossTenant(ctx) {
tenantID := store.TenantIDFromContext(ctx)
if tenantID == uuid.Nil {
return nil, nil
}
baseQuery += ` AND t.tenant_id = ?`
args = append(args, tenantID)
}
baseQuery += ` ORDER BY t.created_at DESC`
rows, err := s.db.QueryContext(ctx, baseQuery, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var teams []store.TeamData
for rows.Next() {
var d store.TeamData
var desc sql.NullString
createdAt, updatedAt := scanTimePair()
if err := rows.Scan(
&d.ID, &d.Name, &d.LeadAgentID, &desc, &d.Status,
&d.Settings, &d.CreatedBy, createdAt, updatedAt,
); err != nil {
return nil, err
}
d.CreatedAt = createdAt.Time
d.UpdatedAt = updatedAt.Time
if desc.Valid {
d.Description = desc.String
}
teams = append(teams, d)
}
return teams, rows.Err()
}
func (s *SQLiteTeamStore) HasTeamAccess(ctx context.Context, teamID uuid.UUID, userID string) (bool, error) {
tClause, tArgs, err := scopeClause(ctx)
if err != nil {
return false, err
}
var exists bool
err = s.db.QueryRowContext(ctx,
`SELECT EXISTS(SELECT 1 FROM team_user_grants WHERE team_id = ? AND user_id = ?`+tClause+`)`,
append([]any{teamID, userID}, tArgs...)...,
).Scan(&exists)
return exists, err
}
// GetTeamUnscoped returns a team by ID without tenant filtering. Server-internal only.
func (s *SQLiteTeamStore) GetTeamUnscoped(ctx context.Context, id uuid.UUID) (*store.TeamData, error) {
row := s.db.QueryRowContext(ctx,
`SELECT `+teamSelectCols+` FROM agent_teams WHERE id = ?`, id)
return scanTeamRow(row)
}
// ============================================================
// Scan helpers
// ============================================================
func scanTeamRow(row *sql.Row) (*store.TeamData, error) {
var d store.TeamData
var desc sql.NullString
createdAt, updatedAt := scanTimePair()
err := row.Scan(
&d.ID, &d.Name, &d.LeadAgentID, &desc, &d.Status,
&d.Settings, &d.CreatedBy, createdAt, updatedAt,
)
if err != nil {
return nil, err
}
d.CreatedAt = createdAt.Time
d.UpdatedAt = updatedAt.Time
if desc.Valid {
d.Description = desc.String
}
return &d, nil
}
+558
View File
@@ -0,0 +1,558 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strings"
"time"
"unicode"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// taskLockDuration is how long a claimed task stays locked before stale recovery resets it.
const taskLockDuration = 30 * time.Minute
// maxListTasksRows caps ListTasks results to prevent unbounded queries.
const maxListTasksRows = 30
// taskSelectCols is the shared SELECT column list for task queries.
const taskSelectCols = `t.id, t.team_id, t.subject, t.description, t.status, t.owner_agent_id, t.blocked_by, t.priority, t.result, t.user_id, t.channel,
t.task_type, t.task_number, COALESCE(t.identifier,''), t.created_by_agent_id, COALESCE(t.assignee_user_id,''), t.parent_id,
COALESCE(t.chat_id,''), t.metadata, t.locked_at, t.lock_expires_at, COALESCE(t.progress_percent,0), COALESCE(t.progress_step,''),
t.followup_at, COALESCE(t.followup_count,0), COALESCE(t.followup_max,0), COALESCE(t.followup_message,''), COALESCE(t.followup_channel,''), COALESCE(t.followup_chat_id,''),
COALESCE(t.comment_count,0), COALESCE(t.attachment_count,0),
t.created_at, t.updated_at,
COALESCE(a.agent_key, '') AS owner_agent_key,
COALESCE(ca.agent_key, '') AS created_by_agent_key`
// taskJoinClause is the shared JOIN clause for task queries.
const taskJoinClause = `FROM team_tasks t
LEFT JOIN agents a ON a.id = t.owner_agent_id
LEFT JOIN agents ca ON ca.id = t.created_by_agent_id`
// ============================================================
// Scopes
// ============================================================
func (s *SQLiteTeamStore) ListTaskScopes(ctx context.Context, teamID uuid.UUID) ([]store.ScopeEntry, error) {
args := []any{teamID}
tenantWhere := ""
if !store.IsCrossTenant(ctx) {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
return nil, fmt.Errorf("tenant_id required")
}
tenantWhere = " AND tenant_id = ?"
args = append(args, tid)
}
rows, err := s.db.QueryContext(ctx,
`SELECT DISTINCT channel, chat_id FROM team_tasks
WHERE team_id = ? AND channel IS NOT NULL AND channel != ''`+tenantWhere+`
ORDER BY channel, chat_id`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var scopes []store.ScopeEntry
for rows.Next() {
var sc store.ScopeEntry
if err := rows.Scan(&sc.Channel, &sc.ChatID); err != nil {
return nil, err
}
scopes = append(scopes, sc)
}
return scopes, rows.Err()
}
// ============================================================
// Tasks CRUD
// ============================================================
func (s *SQLiteTeamStore) CreateTask(ctx context.Context, task *store.TeamTaskData) error {
if task.ID == uuid.Nil {
task.ID = store.GenNewID()
}
now := time.Now()
task.CreatedAt = now
task.UpdatedAt = now
if task.TaskType == "" {
task.TaskType = "general"
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// Scope task_number per (team_id, chat_id).
var taskNumber int
err = tx.QueryRowContext(ctx,
`SELECT COALESCE(MAX(task_number), 0) + 1 FROM team_tasks WHERE team_id = ? AND COALESCE(chat_id, '') = ?`,
task.TeamID, task.ChatID,
).Scan(&taskNumber)
if err != nil {
return fmt.Errorf("compute task_number: %w", err)
}
task.TaskNumber = taskNumber
hex := strings.ReplaceAll(task.ID.String(), "-", "")
task.Identifier = fmt.Sprintf("T-%03d-%s", taskNumber, hex[len(hex)-4:])
var metaJSON []byte
if len(task.Metadata) > 0 {
metaJSON, _ = json.Marshal(task.Metadata)
}
blockedByJSON := jsonStringArray(uuidSliceToStrings(task.BlockedBy))
_, err = tx.ExecContext(ctx,
`INSERT INTO team_tasks (id, team_id, subject, description, status, owner_agent_id, blocked_by, priority, result, user_id, channel,
task_type, task_number, identifier, created_by_agent_id, parent_id, chat_id, metadata, locked_at, lock_expires_at, created_at, updated_at, tenant_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
task.ID, task.TeamID, task.Subject, task.Description,
task.Status, task.OwnerAgentID, blockedByJSON,
task.Priority, task.Result,
nilStr(task.UserID), nilStr(task.Channel),
task.TaskType, taskNumber, task.Identifier,
task.CreatedByAgentID, task.ParentID,
nilStr(task.ChatID),
metaJSON,
task.LockedAt, task.LockExpiresAt,
now, now, tenantIDForInsert(ctx),
)
if err != nil {
return err
}
return tx.Commit()
}
// allowedTaskUpdateCols is the whitelist of columns that UpdateTask accepts.
var allowedTaskUpdateCols = map[string]bool{
"subject": true,
"description": true,
"priority": true,
"assignee_user_id": true,
"metadata": true,
"blocked_by": true,
"updated_at": true,
}
func (s *SQLiteTeamStore) UpdateTask(ctx context.Context, taskID uuid.UUID, updates map[string]any) error {
if len(updates) == 0 {
return nil
}
for col := range updates {
if !allowedTaskUpdateCols[col] {
return fmt.Errorf("column %q is not allowed in task updates", col)
}
}
// Convert blocked_by []uuid.UUID to JSON string array for SQLite.
if v, ok := updates["blocked_by"]; ok {
switch tv := v.(type) {
case []uuid.UUID:
updates["blocked_by"] = jsonStringArray(uuidSliceToStrings(tv))
case []string:
updates["blocked_by"] = jsonStringArray(tv)
}
}
updates["updated_at"] = time.Now()
if store.IsCrossTenant(ctx) {
return execMapUpdate(ctx, s.db, "team_tasks", taskID, updates)
}
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
return fmt.Errorf("tenant_id required for update")
}
return execMapUpdateWhereTenant(ctx, s.db, "team_tasks", updates, taskID, tid)
}
func (s *SQLiteTeamStore) ListTasks(ctx context.Context, teamID uuid.UUID, orderBy string, statusFilter string, userID string, channel string, chatID string, limit int, offset int) ([]store.TeamTaskData, error) {
orderClause := "t.priority DESC, t.created_at"
if orderBy == "newest" {
orderClause = "t.created_at DESC"
}
statusWhere := ""
switch statusFilter {
case store.TeamTaskFilterActive:
statusWhere = "AND t.status NOT IN ('completed','cancelled')"
case store.TeamTaskFilterInReview:
statusWhere = "AND t.status = 'in_review'"
case store.TeamTaskFilterCompleted:
statusWhere = "AND t.status IN ('completed','cancelled')"
}
if limit <= 0 {
limit = maxListTasksRows
}
// Scope filter using COALESCE for optional channel/chatID.
scopeWhere := "AND (? = '' OR COALESCE(t.channel,'') = ?) AND (? = '' OR COALESCE(t.chat_id,'') = ?)"
args := []any{teamID, userID, userID, channel, channel, chatID, chatID}
tenantWhere := ""
if !store.IsCrossTenant(ctx) {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
return nil, fmt.Errorf("tenant_id required")
}
tenantWhere = " AND t.tenant_id = ?"
args = append(args, tid)
}
args = append(args, limit+1, offset)
rows, err := s.db.QueryContext(ctx,
`SELECT `+taskSelectCols+`
`+taskJoinClause+`
WHERE t.team_id = ? AND (? = '' OR t.user_id = ?) `+statusWhere+` `+scopeWhere+tenantWhere+`
ORDER BY `+orderClause+`
LIMIT ? OFFSET ?`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
return scanTaskRowsJoined(rows)
}
func (s *SQLiteTeamStore) GetTask(ctx context.Context, taskID uuid.UUID) (*store.TeamTaskData, error) {
args := []any{taskID}
tenantWhere := ""
if !store.IsCrossTenant(ctx) {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
return nil, fmt.Errorf("tenant_id required")
}
tenantWhere = " AND t.tenant_id = ?"
args = append(args, tid)
}
rows, err := s.db.QueryContext(ctx,
`SELECT `+taskSelectCols+`
`+taskJoinClause+`
WHERE t.id = ?`+tenantWhere, args...)
if err != nil {
return nil, err
}
defer rows.Close()
tasks, err := scanTaskRowsJoined(rows)
if err != nil {
return nil, err
}
if len(tasks) == 0 {
return nil, store.ErrTaskNotFound
}
return &tasks[0], nil
}
func (s *SQLiteTeamStore) GetTasksByIDs(ctx context.Context, ids []uuid.UUID) ([]store.TeamTaskData, error) {
if len(ids) == 0 {
return nil, nil
}
placeholders := strings.Repeat("?,", len(ids))
placeholders = placeholders[:len(placeholders)-1]
args := make([]any, len(ids))
for i, id := range ids {
args[i] = id
}
tenantWhere := ""
if !store.IsCrossTenant(ctx) {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
return nil, fmt.Errorf("tenant_id required")
}
tenantWhere = " AND t.tenant_id = ?"
args = append(args, tid)
}
rows, err := s.db.QueryContext(ctx,
`SELECT `+taskSelectCols+`
`+taskJoinClause+`
WHERE t.id IN (`+placeholders+`)`+tenantWhere, args...)
if err != nil {
return nil, err
}
defer rows.Close()
return scanTaskRowsJoined(rows)
}
// SearchTasks uses LIKE-based search (SQLite has no tsvector/FTS without extension).
func (s *SQLiteTeamStore) SearchTasks(ctx context.Context, teamID uuid.UUID, query string, limit int, userID string) ([]store.TeamTaskData, error) {
if limit <= 0 {
limit = 20
}
words := strings.Fields(query)
if len(words) == 0 {
return nil, nil
}
// Sanitize words.
var sanitized []string
for _, w := range words {
w = strings.Map(func(r rune) rune {
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' || r == '_' {
return r
}
return -1
}, w)
w = strings.TrimSpace(w)
if w != "" {
sanitized = append(sanitized, "%"+w+"%")
}
}
if len(sanitized) == 0 {
return nil, nil
}
// Build AND-chained LIKE conditions.
likeClauses := make([]string, len(sanitized))
args := []any{teamID}
for i, pat := range sanitized {
likeClauses[i] = "(t.subject LIKE ? OR t.description LIKE ?)"
args = append(args, pat, pat)
}
args = append(args, userID, userID)
tenantWhere := ""
if !store.IsCrossTenant(ctx) {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
return nil, fmt.Errorf("tenant_id required")
}
tenantWhere = " AND t.tenant_id = ?"
args = append(args, tid)
}
args = append(args, limit)
rows, err := s.db.QueryContext(ctx,
`SELECT `+taskSelectCols+`
`+taskJoinClause+`
WHERE t.team_id = ? AND `+strings.Join(likeClauses, " AND ")+`
AND (? = '' OR t.user_id = ?)`+tenantWhere+`
ORDER BY t.created_at DESC
LIMIT ?`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
return scanTaskRowsJoined(rows)
}
func (s *SQLiteTeamStore) DeleteTask(ctx context.Context, taskID, teamID uuid.UUID) error {
args := []any{taskID, teamID}
tenantWhere := ""
if !store.IsCrossTenant(ctx) {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
return fmt.Errorf("tenant_id required")
}
tenantWhere = " AND tenant_id = ?"
args = append(args, tid)
}
res, err := s.db.ExecContext(ctx,
`DELETE FROM team_tasks WHERE id = ? AND team_id = ? AND status IN ('completed','failed','cancelled')`+tenantWhere,
args...)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return store.ErrTaskNotFound
}
return nil
}
func (s *SQLiteTeamStore) DeleteTasks(ctx context.Context, taskIDs []uuid.UUID, teamID uuid.UUID) ([]uuid.UUID, error) {
if len(taskIDs) == 0 {
return nil, nil
}
// SQLite doesn't support RETURNING in older versions — do select then delete.
placeholders := strings.Repeat("?,", len(taskIDs))
placeholders = placeholders[:len(placeholders)-1]
args := make([]any, len(taskIDs))
for i, id := range taskIDs {
args[i] = id
}
args = append(args, teamID)
tenantWhere := ""
if !store.IsCrossTenant(ctx) {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
return nil, fmt.Errorf("tenant_id required")
}
tenantWhere = " AND tenant_id = ?"
args = append(args, tid)
}
cond := `id IN (` + placeholders + `) AND team_id = ? AND status IN ('completed','failed','cancelled')` + tenantWhere
// Fetch IDs to delete first.
selectRows, err := s.db.QueryContext(ctx, `SELECT id FROM team_tasks WHERE `+cond, args...)
if err != nil {
return nil, err
}
var deleted []uuid.UUID
for selectRows.Next() {
var id uuid.UUID
if err := selectRows.Scan(&id); err != nil {
selectRows.Close()
return deleted, err
}
deleted = append(deleted, id)
}
selectRows.Close()
if err := selectRows.Err(); err != nil {
return nil, err
}
if len(deleted) > 0 {
_, err = s.db.ExecContext(ctx, `DELETE FROM team_tasks WHERE `+cond, args...)
if err != nil {
return nil, err
}
}
return deleted, nil
}
func (s *SQLiteTeamStore) ListActiveTasksByChatID(ctx context.Context, chatID string) ([]store.TeamTaskData, error) {
if chatID == "" {
return nil, nil
}
args := []any{chatID}
tenantWhere := ""
if !store.IsCrossTenant(ctx) {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
return nil, fmt.Errorf("tenant_id required")
}
tenantWhere = " AND t.tenant_id = ?"
args = append(args, tid)
}
rows, err := s.db.QueryContext(ctx,
`SELECT `+taskSelectCols+`
`+taskJoinClause+`
WHERE COALESCE(t.chat_id,'') = ?
AND t.status IN ('pending','in_progress','blocked','in_review')`+tenantWhere+`
ORDER BY t.task_number ASC
LIMIT 50`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
return scanTaskRowsJoined(rows)
}
// ============================================================
// Scan helpers
// ============================================================
func scanTaskRowsJoined(rows *sql.Rows) ([]store.TeamTaskData, error) {
var tasks []store.TeamTaskData
for rows.Next() {
var d store.TeamTaskData
var desc, result, userID, channel sql.NullString
var ownerID, createdByAgentID, parentID *uuid.UUID
var blockedByJSON []byte
var assigneeUserID, chatID, progressStep, identifier string
var metadataJSON []byte
var lockedAt, lockExpiresAt, followupAt nullSqliteTime
var followupCount, followupMax int
var followupMessage, followupChannel, followupChatID string
createdAt, updatedAt := scanTimePair()
if err := rows.Scan(
&d.ID, &d.TeamID, &d.Subject, &desc, &d.Status,
&ownerID, &blockedByJSON, &d.Priority, &result,
&userID, &channel,
&d.TaskType, &d.TaskNumber, &identifier, &createdByAgentID, &assigneeUserID, &parentID,
&chatID, &metadataJSON, &lockedAt, &lockExpiresAt, &d.ProgressPercent, &progressStep,
&followupAt, &followupCount, &followupMax, &followupMessage, &followupChannel, &followupChatID,
&d.CommentCount, &d.AttachmentCount,
createdAt, updatedAt,
&d.OwnerAgentKey,
&d.CreatedByAgentKey,
); err != nil {
return nil, err
}
d.CreatedAt = createdAt.Time
d.UpdatedAt = updatedAt.Time
if desc.Valid {
d.Description = desc.String
}
if result.Valid {
d.Result = &result.String
}
if userID.Valid {
d.UserID = userID.String
}
if channel.Valid {
d.Channel = channel.String
}
d.OwnerAgentID = ownerID
d.Identifier = identifier
d.CreatedByAgentID = createdByAgentID
d.AssigneeUserID = assigneeUserID
d.ParentID = parentID
d.ChatID = chatID
// Decode blocked_by JSON string array → []uuid.UUID.
var blockedByStrs []string
scanJSONStringArray(blockedByJSON, &blockedByStrs)
d.BlockedBy = stringsToUUIDSlice(blockedByStrs)
if len(metadataJSON) > 0 {
_ = json.Unmarshal(metadataJSON, &d.Metadata)
}
if lockedAt.Valid {
d.LockedAt = &lockedAt.Time
}
if lockExpiresAt.Valid {
d.LockExpiresAt = &lockExpiresAt.Time
}
d.ProgressStep = progressStep
if followupAt.Valid {
d.FollowupAt = &followupAt.Time
}
d.FollowupCount = followupCount
d.FollowupMax = followupMax
d.FollowupMessage = followupMessage
d.FollowupChannel = followupChannel
d.FollowupChatID = followupChatID
tasks = append(tasks, d)
}
return tasks, rows.Err()
}
// uuidSliceToStrings converts []uuid.UUID to []string for JSON storage.
func uuidSliceToStrings(ids []uuid.UUID) []string {
if ids == nil {
return nil
}
out := make([]string, len(ids))
for i, id := range ids {
out[i] = id.String()
}
return out
}
// stringsToUUIDSlice converts []string back to []uuid.UUID.
func stringsToUUIDSlice(strs []string) []uuid.UUID {
if strs == nil {
return nil
}
out := make([]uuid.UUID, 0, len(strs))
for _, s := range strs {
if id, err := uuid.Parse(s); err == nil {
out = append(out, id)
}
}
return out
}
@@ -0,0 +1,679 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// ============================================================
// Task comments
// ============================================================
func (s *SQLiteTeamStore) AddTaskComment(ctx context.Context, comment *store.TeamTaskCommentData) error {
if comment.ID == uuid.Nil {
comment.ID = store.GenNewID()
}
comment.CreatedAt = time.Now()
commentType := comment.CommentType
if commentType == "" {
commentType = "note"
}
tid := tenantIDForInsert(ctx)
_, err := s.db.ExecContext(ctx,
`INSERT INTO team_task_comments (id, task_id, agent_id, user_id, content, comment_type, created_at, tenant_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
comment.ID, comment.TaskID, comment.AgentID,
nilStr(comment.UserID),
comment.Content, commentType, comment.CreatedAt, tid,
)
if err != nil {
return err
}
_, _ = s.db.ExecContext(ctx,
`UPDATE team_tasks SET comment_count = comment_count + 1 WHERE id = ? AND tenant_id = ?`, comment.TaskID, tid)
return nil
}
func (s *SQLiteTeamStore) ListTaskComments(ctx context.Context, taskID uuid.UUID) ([]store.TeamTaskCommentData, error) {
tid := tenantIDForInsert(ctx)
rows, err := s.db.QueryContext(ctx,
`SELECT c.id, c.task_id, c.agent_id, c.user_id, c.content, c.comment_type, c.created_at,
COALESCE(a.agent_key, '') AS agent_key
FROM team_task_comments c
LEFT JOIN agents a ON a.id = c.agent_id
WHERE c.task_id = ? AND c.tenant_id = ?
ORDER BY c.created_at ASC`, taskID, tid)
if err != nil {
return nil, err
}
defer rows.Close()
return scanTaskCommentRows(rows)
}
func (s *SQLiteTeamStore) ListRecentTaskComments(ctx context.Context, taskID uuid.UUID, limit int) ([]store.TeamTaskCommentData, error) {
tid := tenantIDForInsert(ctx)
rows, err := s.db.QueryContext(ctx,
`SELECT c.id, c.task_id, c.agent_id, c.user_id, c.content, c.comment_type, c.created_at,
COALESCE(a.agent_key, '') AS agent_key
FROM team_task_comments c
LEFT JOIN agents a ON a.id = c.agent_id
WHERE c.task_id = ? AND c.tenant_id = ?
ORDER BY c.created_at DESC
LIMIT ?`, taskID, tid, limit)
if err != nil {
return nil, err
}
defer rows.Close()
comments, err := scanTaskCommentRows(rows)
if err != nil {
return nil, err
}
// Reverse to chronological order (ASC) for display.
for i, j := 0, len(comments)-1; i < j; i, j = i+1, j-1 {
comments[i], comments[j] = comments[j], comments[i]
}
return comments, nil
}
func scanTaskCommentRows(rows *sql.Rows) ([]store.TeamTaskCommentData, error) {
var comments []store.TeamTaskCommentData
for rows.Next() {
var c store.TeamTaskCommentData
var agentID *uuid.UUID
var userID sql.NullString
var createdAt sqliteTime
if err := rows.Scan(&c.ID, &c.TaskID, &agentID, &userID, &c.Content, &c.CommentType, &createdAt, &c.AgentKey); err != nil {
return nil, err
}
c.CreatedAt = createdAt.Time
c.AgentID = agentID
if userID.Valid {
c.UserID = userID.String
}
comments = append(comments, c)
}
return comments, rows.Err()
}
// ============================================================
// Audit events
// ============================================================
func (s *SQLiteTeamStore) RecordTaskEvent(ctx context.Context, event *store.TeamTaskEventData) error {
if event.ID == uuid.Nil {
event.ID = store.GenNewID()
}
event.CreatedAt = time.Now()
_, err := s.db.ExecContext(ctx,
`INSERT INTO team_task_events (id, task_id, event_type, actor_type, actor_id, data, created_at, tenant_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
event.ID, event.TaskID, event.EventType, event.ActorType, event.ActorID, event.Data, event.CreatedAt, tenantIDForInsert(ctx),
)
return err
}
func (s *SQLiteTeamStore) ListTaskEvents(ctx context.Context, taskID uuid.UUID) ([]store.TeamTaskEventData, error) {
tid := tenantIDForInsert(ctx)
rows, err := s.db.QueryContext(ctx,
`SELECT id, task_id, event_type, actor_type, actor_id, data, created_at
FROM team_task_events
WHERE task_id = ? AND tenant_id = ?
ORDER BY created_at ASC`, taskID, tid)
if err != nil {
return nil, err
}
defer rows.Close()
return scanTaskEventRows(rows)
}
func (s *SQLiteTeamStore) ListTeamEvents(ctx context.Context, teamID uuid.UUID, limit, offset int) ([]store.TeamTaskEventData, error) {
if limit <= 0 || limit > 100 {
limit = 50
}
tid := tenantIDForInsert(ctx)
rows, err := s.db.QueryContext(ctx,
`SELECT e.id, e.task_id, e.event_type, e.actor_type, e.actor_id, e.data, e.created_at
FROM team_task_events e
JOIN team_tasks t ON t.id = e.task_id
WHERE t.team_id = ? AND t.tenant_id = ?
ORDER BY e.created_at DESC
LIMIT ? OFFSET ?`,
teamID, tid, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
return scanTaskEventRows(rows)
}
func scanTaskEventRows(rows *sql.Rows) ([]store.TeamTaskEventData, error) {
var events []store.TeamTaskEventData
for rows.Next() {
var e store.TeamTaskEventData
var data json.RawMessage
var createdAt sqliteTime
if err := rows.Scan(&e.ID, &e.TaskID, &e.EventType, &e.ActorType, &e.ActorID, &data, &createdAt); err != nil {
return nil, err
}
e.CreatedAt = createdAt.Time
e.Data = data
events = append(events, e)
}
return events, rows.Err()
}
// ============================================================
// Attachments
// ============================================================
func (s *SQLiteTeamStore) AttachFileToTask(ctx context.Context, att *store.TeamTaskAttachmentData) error {
if att.ID == uuid.Nil {
att.ID = store.GenNewID()
}
att.CreatedAt = time.Now()
if len(att.Metadata) == 0 {
att.Metadata = json.RawMessage(`{}`)
}
tid := tenantIDForInsert(ctx)
res, err := s.db.ExecContext(ctx,
`INSERT INTO team_task_attachments (id, task_id, team_id, chat_id, path, file_size, mime_type, created_by_agent_id, created_by_sender_id, metadata, created_at, tenant_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (task_id, path) DO NOTHING`,
att.ID, att.TaskID, att.TeamID, att.ChatID, att.Path,
att.FileSize, att.MimeType, att.CreatedByAgentID,
nilStr(att.CreatedBySenderID),
att.Metadata, att.CreatedAt, tid,
)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n > 0 {
_, _ = s.db.ExecContext(ctx,
`UPDATE team_tasks SET attachment_count = attachment_count + 1 WHERE id = ? AND tenant_id = ?`, att.TaskID, tid)
}
return nil
}
func (s *SQLiteTeamStore) GetAttachment(ctx context.Context, attachmentID uuid.UUID) (*store.TeamTaskAttachmentData, error) {
var a store.TeamTaskAttachmentData
var agentID *uuid.UUID
var senderID sql.NullString
var metadata json.RawMessage
var createdAt sqliteTime
tid := tenantIDForInsert(ctx)
err := s.db.QueryRowContext(ctx,
`SELECT id, task_id, team_id, chat_id, path, file_size, mime_type,
created_by_agent_id, created_by_sender_id, metadata, created_at
FROM team_task_attachments WHERE id = ? AND tenant_id = ?`, attachmentID, tid,
).Scan(&a.ID, &a.TaskID, &a.TeamID, &a.ChatID, &a.Path, &a.FileSize, &a.MimeType,
&agentID, &senderID, &metadata, &createdAt)
if err != nil {
return nil, err
}
a.CreatedAt = createdAt.Time
a.CreatedByAgentID = agentID
if senderID.Valid {
a.CreatedBySenderID = senderID.String
}
a.Metadata = metadata
return &a, nil
}
func (s *SQLiteTeamStore) ListTaskAttachments(ctx context.Context, taskID uuid.UUID) ([]store.TeamTaskAttachmentData, error) {
tid := tenantIDForInsert(ctx)
rows, err := s.db.QueryContext(ctx,
`SELECT id, task_id, team_id, chat_id, path, file_size, mime_type,
created_by_agent_id, created_by_sender_id, metadata, created_at
FROM team_task_attachments
WHERE task_id = ? AND tenant_id = ?
ORDER BY created_at ASC`, taskID, tid)
if err != nil {
return nil, err
}
defer rows.Close()
var atts []store.TeamTaskAttachmentData
for rows.Next() {
var a store.TeamTaskAttachmentData
var agentID *uuid.UUID
var senderID sql.NullString
var metadata json.RawMessage
var createdAt sqliteTime
if err := rows.Scan(&a.ID, &a.TaskID, &a.TeamID, &a.ChatID, &a.Path, &a.FileSize, &a.MimeType,
&agentID, &senderID, &metadata, &createdAt); err != nil {
return nil, err
}
a.CreatedAt = createdAt.Time
a.CreatedByAgentID = agentID
if senderID.Valid {
a.CreatedBySenderID = senderID.String
}
a.Metadata = metadata
atts = append(atts, a)
}
return atts, rows.Err()
}
func (s *SQLiteTeamStore) DetachFileFromTask(ctx context.Context, taskID uuid.UUID, path string) error {
tid := tenantIDForInsert(ctx)
res, err := s.db.ExecContext(ctx,
`DELETE FROM team_task_attachments WHERE task_id = ? AND path = ? AND tenant_id = ?`,
taskID, path, tid,
)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n > 0 {
_, _ = s.db.ExecContext(ctx,
`UPDATE team_tasks SET attachment_count = MAX(attachment_count - 1, 0) WHERE id = ? AND tenant_id = ?`, taskID, tid)
}
return nil
}
// ============================================================
// Progress
// ============================================================
func (s *SQLiteTeamStore) UpdateTaskProgress(ctx context.Context, taskID, teamID uuid.UUID, percent int, step string) error {
if percent < 0 || percent > 100 {
return fmt.Errorf("progress percent must be 0-100, got %d", percent)
}
now := time.Now()
lockExpires := now.Add(taskLockDuration)
tid := tenantIDForInsert(ctx)
res, err := s.db.ExecContext(ctx,
`UPDATE team_tasks SET progress_percent = ?, progress_step = ?, lock_expires_at = ?, updated_at = ?
WHERE id = ? AND status = ? AND team_id = ? AND tenant_id = ?`,
percent, step, lockExpires, now,
taskID, store.TeamTaskStatusInProgress, teamID, tid,
)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("task not in progress or not found")
}
return nil
}
func (s *SQLiteTeamStore) RenewTaskLock(ctx context.Context, taskID, teamID uuid.UUID) error {
now := time.Now()
lockExpires := now.Add(taskLockDuration)
tid := tenantIDForInsert(ctx)
res, err := s.db.ExecContext(ctx,
`UPDATE team_tasks SET lock_expires_at = ?, updated_at = ?
WHERE id = ? AND team_id = ? AND status = ? AND tenant_id = ?`,
lockExpires, now,
taskID, teamID, store.TeamTaskStatusInProgress, tid,
)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("task not in progress or not found")
}
return nil
}
// ============================================================
// Stale recovery
// ============================================================
// v2ActiveTeamJoin is the JOIN that filters to v2 active teams.
// SQLite uses json_extract instead of PG's ->> operator.
const v2ActiveTeamJoin = `JOIN agent_teams tm ON tm.id = t.team_id
AND tm.status = 'active'
AND COALESCE(CAST(json_extract(tm.settings, '$.version') AS INTEGER), 0) >= 2`
func (s *SQLiteTeamStore) RecoverAllStaleTasks(ctx context.Context) ([]store.RecoveredTaskInfo, error) {
now := time.Now()
// SQLite: UPDATE ... FROM not supported in older versions; use subquery in WHERE.
_, err := s.db.ExecContext(ctx,
`UPDATE team_tasks SET status = ?, locked_at = NULL, lock_expires_at = NULL,
followup_at = NULL, followup_count = 0, followup_message = NULL,
followup_channel = NULL, followup_chat_id = NULL, updated_at = ?
WHERE status = ? AND lock_expires_at IS NOT NULL AND lock_expires_at < ?
AND team_id IN (
SELECT id FROM agent_teams WHERE status = 'active'
AND COALESCE(CAST(json_extract(settings, '$.version') AS INTEGER), 0) >= 2
)`,
store.TeamTaskStatusPending, now, store.TeamTaskStatusInProgress, now,
)
if err != nil {
return nil, err
}
return s.queryRecoveredTasks(ctx, store.TeamTaskStatusPending)
}
func (s *SQLiteTeamStore) ForceRecoverAllTasks(ctx context.Context) ([]store.RecoveredTaskInfo, error) {
now := time.Now()
_, err := s.db.ExecContext(ctx,
`UPDATE team_tasks SET status = ?, locked_at = NULL, lock_expires_at = NULL,
followup_at = NULL, followup_count = 0, followup_message = NULL,
followup_channel = NULL, followup_chat_id = NULL, updated_at = ?
WHERE status = ?
AND team_id IN (
SELECT id FROM agent_teams WHERE status = 'active'
AND COALESCE(CAST(json_extract(settings, '$.version') AS INTEGER), 0) >= 2
)`,
store.TeamTaskStatusPending, now, store.TeamTaskStatusInProgress,
)
if err != nil {
return nil, err
}
return s.queryRecoveredTasks(ctx, store.TeamTaskStatusPending)
}
func (s *SQLiteTeamStore) ListRecoverableTasks(ctx context.Context, teamID uuid.UUID) ([]store.TeamTaskData, error) {
now := time.Now()
tid := tenantIDForInsert(ctx)
rows, err := s.db.QueryContext(ctx,
`SELECT `+taskSelectCols+`
`+taskJoinClause+`
WHERE t.team_id = ? AND t.tenant_id = ?
AND (
t.status = ?
OR (t.status = ? AND t.lock_expires_at IS NOT NULL AND t.lock_expires_at < ?)
)
ORDER BY t.priority DESC, t.created_at
LIMIT ?`,
teamID, tid,
store.TeamTaskStatusPending, store.TeamTaskStatusInProgress, now, maxListTasksRows)
if err != nil {
return nil, err
}
defer rows.Close()
return scanTaskRowsJoined(rows)
}
func (s *SQLiteTeamStore) MarkAllStaleTasks(ctx context.Context, olderThan time.Time) ([]store.RecoveredTaskInfo, error) {
now := time.Now()
_, err := s.db.ExecContext(ctx,
`UPDATE team_tasks SET status = ?, updated_at = ?
WHERE status = ? AND updated_at < ?
AND team_id IN (
SELECT id FROM agent_teams WHERE status = 'active'
AND COALESCE(CAST(json_extract(settings, '$.version') AS INTEGER), 0) >= 2
)`,
store.TeamTaskStatusStale, now, store.TeamTaskStatusPending, olderThan,
)
if err != nil {
return nil, err
}
return s.queryRecoveredTasks(ctx, store.TeamTaskStatusStale)
}
func (s *SQLiteTeamStore) MarkInReviewStaleTasks(ctx context.Context, olderThan time.Time) ([]store.RecoveredTaskInfo, error) {
now := time.Now()
_, err := s.db.ExecContext(ctx,
`UPDATE team_tasks SET status = ?, updated_at = ?
WHERE status = ? AND updated_at < ?
AND team_id IN (
SELECT id FROM agent_teams WHERE status = 'active'
AND COALESCE(CAST(json_extract(settings, '$.version') AS INTEGER), 0) >= 2
)`,
store.TeamTaskStatusStale, now, store.TeamTaskStatusInReview, olderThan,
)
if err != nil {
return nil, err
}
return s.queryRecoveredTasks(ctx, store.TeamTaskStatusStale)
}
// FixOrphanedBlockedTasks unblocks blocked tasks where all blockers have reached terminal status.
func (s *SQLiteTeamStore) FixOrphanedBlockedTasks(ctx context.Context) ([]store.RecoveredTaskInfo, error) {
now := time.Now()
// Find blocked tasks where every entry in blocked_by is in terminal status.
rows, err := s.db.QueryContext(ctx,
`SELECT t.id, t.team_id, t.tenant_id, t.task_number, t.subject,
COALESCE(t.channel, ''), COALESCE(t.chat_id, '')
FROM team_tasks t
JOIN agent_teams tm ON tm.id = t.team_id AND tm.status = 'active'
AND COALESCE(CAST(json_extract(tm.settings, '$.version') AS INTEGER), 0) >= 2
WHERE t.status = 'blocked'
AND t.blocked_by IS NOT NULL AND t.blocked_by != '[]'
AND NOT EXISTS (
SELECT 1 FROM json_each(t.blocked_by) AS je
JOIN team_tasks bt ON bt.id = je.value AND bt.tenant_id = t.tenant_id
WHERE bt.status NOT IN (?, ?, ?)
)`,
store.TeamTaskStatusCompleted, store.TeamTaskStatusFailed, store.TeamTaskStatusCancelled,
)
if err != nil {
return nil, err
}
infos, err := scanRecoveredTaskInfoRows(rows)
rows.Close()
if err != nil {
return nil, err
}
// Unblock each — clear blocked_by and set pending.
for _, info := range infos {
_, _ = s.db.ExecContext(ctx,
`UPDATE team_tasks SET blocked_by = '[]', status = ?, updated_at = ? WHERE id = ? AND tenant_id = ?`,
store.TeamTaskStatusPending, now, info.ID, info.TenantID,
)
}
return infos, nil
}
// queryRecoveredTasks fetches recently-updated tasks matching the given status for recovery reporting.
func (s *SQLiteTeamStore) queryRecoveredTasks(ctx context.Context, status string) ([]store.RecoveredTaskInfo, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT t.id, t.team_id, t.tenant_id, t.task_number, t.subject,
COALESCE(t.channel, ''), COALESCE(t.chat_id, '')
FROM team_tasks t
WHERE t.status = ?
AND t.updated_at >= ?
AND t.team_id IN (
SELECT id FROM agent_teams WHERE status = 'active'
AND COALESCE(CAST(json_extract(settings, '$.version') AS INTEGER), 0) >= 2
)
ORDER BY t.updated_at DESC
LIMIT 200`,
status, time.Now().Add(-5*time.Second),
)
if err != nil {
return nil, err
}
defer rows.Close()
return scanRecoveredTaskInfoRows(rows)
}
func scanRecoveredTaskInfoRows(rows interface {
Next() bool
Scan(...any) error
Err() error
}) ([]store.RecoveredTaskInfo, error) {
var out []store.RecoveredTaskInfo
for rows.Next() {
var info store.RecoveredTaskInfo
if err := rows.Scan(&info.ID, &info.TeamID, &info.TenantID, &info.TaskNumber, &info.Subject, &info.Channel, &info.ChatID); err != nil {
return nil, err
}
out = append(out, info)
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
func (s *SQLiteTeamStore) ResetTaskStatus(ctx context.Context, taskID, teamID uuid.UUID) error {
now := time.Now()
tid := tenantIDForInsert(ctx)
res, err := s.db.ExecContext(ctx,
`UPDATE team_tasks SET status = ?, locked_at = NULL, lock_expires_at = NULL, result = NULL,
progress_percent = NULL, progress_step = NULL, updated_at = ?
WHERE id = ? AND team_id = ? AND status IN (?, ?, ?, ?) AND tenant_id = ?`,
store.TeamTaskStatusPending, now,
taskID, teamID,
store.TeamTaskStatusStale, store.TeamTaskStatusFailed,
store.TeamTaskStatusCancelled, store.TeamTaskStatusInReview, tid,
)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("task not available for reset (not stale/failed/cancelled/in_review or wrong team)")
}
return nil
}
// ============================================================
// Follow-up reminders
// ============================================================
func (s *SQLiteTeamStore) SetTaskFollowup(ctx context.Context, taskID, teamID uuid.UUID, followupAt time.Time, max int, message, channel, chatID string) error {
now := time.Now()
tid := tenantIDForInsert(ctx)
res, err := s.db.ExecContext(ctx,
`UPDATE team_tasks SET followup_at = ?, followup_max = ?, followup_message = ?, followup_channel = ?, followup_chat_id = ?, updated_at = ?
WHERE id = ? AND team_id = ? AND status = ? AND tenant_id = ?`,
followupAt, max, message, channel, chatID, now,
taskID, teamID, store.TeamTaskStatusInProgress, tid,
)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("task not in progress or not found")
}
return nil
}
func (s *SQLiteTeamStore) ClearTaskFollowup(ctx context.Context, taskID uuid.UUID) error {
tid := tenantIDForInsert(ctx)
_, err := s.db.ExecContext(ctx,
`UPDATE team_tasks SET followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL, updated_at = ?
WHERE id = ? AND tenant_id = ?`,
time.Now(), taskID, tid,
)
return err
}
func (s *SQLiteTeamStore) ListAllFollowupDueTasks(ctx context.Context) ([]store.TeamTaskData, error) {
now := time.Now()
rows, err := s.db.QueryContext(ctx,
`SELECT `+taskSelectCols+`
`+taskJoinClause+`
`+v2ActiveTeamJoin+`
WHERE t.followup_at IS NOT NULL
AND t.followup_at <= ?
AND t.status = ?
AND (t.followup_max = 0 OR t.followup_count < t.followup_max)
ORDER BY t.followup_at
LIMIT 100`,
now, store.TeamTaskStatusInProgress,
)
if err != nil {
return nil, err
}
defer rows.Close()
return scanTaskRowsJoined(rows)
}
func (s *SQLiteTeamStore) IncrementFollowupCount(ctx context.Context, taskID uuid.UUID, nextAt *time.Time) error {
tid := tenantIDForInsert(ctx)
_, err := s.db.ExecContext(ctx,
`UPDATE team_tasks SET followup_count = followup_count + 1, followup_at = ?, updated_at = ?
WHERE id = ? AND tenant_id = ?`,
nextAt, time.Now(), taskID, tid,
)
return err
}
func (s *SQLiteTeamStore) ClearFollowupByScope(ctx context.Context, channel, chatID string) (int, error) {
tid := tenantIDForInsert(ctx)
res, err := s.db.ExecContext(ctx,
`UPDATE team_tasks
SET followup_at = NULL, followup_count = 0, followup_message = NULL,
followup_channel = NULL, followup_chat_id = NULL, updated_at = ?
WHERE followup_channel = ? AND followup_chat_id = ?
AND followup_at IS NOT NULL AND status = ? AND tenant_id = ?`,
time.Now(), channel, chatID, store.TeamTaskStatusInProgress, tid,
)
if err != nil {
return 0, err
}
n, err := res.RowsAffected()
return int(n), err
}
func (s *SQLiteTeamStore) SetFollowupForActiveTasks(ctx context.Context, teamID uuid.UUID, channel, chatID string, followupAt time.Time, max int, message string) (int, error) {
tid := tenantIDForInsert(ctx)
res, err := s.db.ExecContext(ctx,
`UPDATE team_tasks
SET followup_at = ?, followup_max = ?, followup_message = ?,
followup_channel = ?, followup_chat_id = ?, updated_at = ?
WHERE team_id = ?
AND status = ?
AND followup_at IS NULL
AND tenant_id = ?
AND (
(COALESCE(channel,'') = ? AND COALESCE(chat_id,'') = ?)
OR followup_channel = ?
OR (COALESCE(channel,'') IN ('', 'system', 'delegate') AND COALESCE(chat_id,'') = '')
)`,
followupAt, max, message, channel, chatID, time.Now(),
teamID, store.TeamTaskStatusInProgress, tid,
channel, chatID, channel,
)
if err != nil {
return 0, err
}
n, err := res.RowsAffected()
return int(n), err
}
func (s *SQLiteTeamStore) HasActiveMemberTasks(ctx context.Context, teamID uuid.UUID, excludeAgentID uuid.UUID) (bool, error) {
tid := tenantIDForInsert(ctx)
var exists bool
err := s.db.QueryRowContext(ctx,
`SELECT EXISTS(
SELECT 1 FROM team_tasks
WHERE team_id = ?
AND owner_agent_id IS NOT NULL
AND owner_agent_id != ?
AND status IN (?, ?, ?)
AND tenant_id = ?
)`,
teamID, excludeAgentID,
store.TeamTaskStatusPending, store.TeamTaskStatusInProgress, store.TeamTaskStatusBlocked, tid,
).Scan(&exists)
return exists, err
}
// BackfillTaskEmbeddings is a no-op for SQLite — vector search not supported.
func (s *SQLiteTeamStore) BackfillTaskEmbeddings(_ context.Context) (int, error) {
return 0, nil
}
// SearchTasksByEmbedding is a no-op for SQLite — falls back to LIKE search.
func (s *SQLiteTeamStore) SearchTasksByEmbedding(_ context.Context, _ uuid.UUID, _ []float32, _ int, _ string) ([]store.TeamTaskData, error) {
return nil, nil
}
@@ -0,0 +1,349 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func (s *SQLiteTeamStore) ClaimTask(ctx context.Context, taskID, agentID, teamID uuid.UUID) error {
now := time.Now()
lockExpires := now.Add(taskLockDuration)
tid := tenantIDForInsert(ctx)
res, err := s.db.ExecContext(ctx,
`UPDATE team_tasks SET status = ?, owner_agent_id = ?, locked_at = ?, lock_expires_at = ?, updated_at = ?
WHERE id = ? AND status = ? AND owner_agent_id IS NULL AND team_id = ? AND tenant_id = ?`,
store.TeamTaskStatusInProgress, agentID, now, lockExpires, now,
taskID, store.TeamTaskStatusPending, teamID, tid,
)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("task not available for claiming (already claimed or not pending)")
}
return nil
}
func (s *SQLiteTeamStore) AssignTask(ctx context.Context, taskID, agentID, teamID uuid.UUID) error {
now := time.Now()
lockExpires := now.Add(taskLockDuration)
tid := tenantIDForInsert(ctx)
res, err := s.db.ExecContext(ctx,
`UPDATE team_tasks SET status = ?, owner_agent_id = ?, locked_at = ?, lock_expires_at = ?, updated_at = ?
WHERE id = ? AND team_id = ? AND status = ? AND tenant_id = ?`,
store.TeamTaskStatusInProgress, agentID, now, lockExpires, now,
taskID, teamID, store.TeamTaskStatusPending, tid,
)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("task not available for assignment (not pending or wrong team)")
}
return nil
}
func (s *SQLiteTeamStore) CompleteTask(ctx context.Context, taskID, teamID uuid.UUID, result string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
tid := tenantIDForInsert(ctx)
res, err := tx.ExecContext(ctx,
`UPDATE team_tasks SET status = ?, result = ?, locked_at = NULL, lock_expires_at = NULL,
followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL,
progress_percent = NULL, updated_at = ?
WHERE id = ? AND status = ? AND team_id = ? AND tenant_id = ?`,
store.TeamTaskStatusCompleted, result, time.Now(),
taskID, store.TeamTaskStatusInProgress, teamID, tid,
)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("task not in progress or not found")
}
if err := unblockDependentTasksSQLite(ctx, tx, taskID); err != nil {
return err
}
return tx.Commit()
}
func (s *SQLiteTeamStore) CancelTask(ctx context.Context, taskID, teamID uuid.UUID, reason string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
now := time.Now()
tid := tenantIDForInsert(ctx)
res, err := tx.ExecContext(ctx,
`UPDATE team_tasks SET status = ?, result = ?, locked_at = NULL, lock_expires_at = NULL,
followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL,
progress_percent = NULL, updated_at = ?
WHERE id = ? AND status NOT IN (?, ?) AND team_id = ? AND tenant_id = ?`,
store.TeamTaskStatusCancelled, reason, now,
taskID, store.TeamTaskStatusCompleted, store.TeamTaskStatusCancelled, teamID, tid,
)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("task not found, already completed/cancelled, or wrong team")
}
if err := unblockDependentTasksSQLite(ctx, tx, taskID); err != nil {
return err
}
return tx.Commit()
}
func (s *SQLiteTeamStore) FailTask(ctx context.Context, taskID, teamID uuid.UUID, errMsg string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
now := time.Now()
tid := tenantIDForInsert(ctx)
res, err := tx.ExecContext(ctx,
`UPDATE team_tasks SET status = ?, result = ?, locked_at = NULL, lock_expires_at = NULL,
followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL,
progress_percent = NULL, updated_at = ?
WHERE id = ? AND status = ? AND team_id = ? AND tenant_id = ?`,
store.TeamTaskStatusFailed, "FAILED: "+errMsg, now,
taskID, store.TeamTaskStatusInProgress, teamID, tid,
)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("task not in progress or not found")
}
if err := unblockDependentTasksSQLite(ctx, tx, taskID); err != nil {
return err
}
return tx.Commit()
}
func (s *SQLiteTeamStore) FailPendingTask(ctx context.Context, taskID, teamID uuid.UUID, errMsg string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
now := time.Now()
tid := tenantIDForInsert(ctx)
res, err := tx.ExecContext(ctx,
`UPDATE team_tasks SET status = ?, result = ?, locked_at = NULL, lock_expires_at = NULL,
progress_percent = NULL, updated_at = ?
WHERE id = ? AND status IN (?, ?) AND team_id = ? AND tenant_id = ?`,
store.TeamTaskStatusFailed, "FAILED: "+errMsg, now,
taskID, store.TeamTaskStatusPending, store.TeamTaskStatusBlocked, teamID, tid,
)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("task not pending/blocked or not found")
}
if err := unblockDependentTasksSQLite(ctx, tx, taskID); err != nil {
return err
}
return tx.Commit()
}
func (s *SQLiteTeamStore) ReviewTask(ctx context.Context, taskID, teamID uuid.UUID) error {
tid := tenantIDForInsert(ctx)
res, err := s.db.ExecContext(ctx,
`UPDATE team_tasks SET status = ?, updated_at = ?
WHERE id = ? AND status = ? AND team_id = ? AND tenant_id = ?`,
store.TeamTaskStatusInReview, time.Now(),
taskID, store.TeamTaskStatusInProgress, teamID, tid,
)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("task not in progress or not found")
}
return nil
}
func (s *SQLiteTeamStore) ApproveTask(ctx context.Context, taskID, teamID uuid.UUID, comment string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
now := time.Now()
tid := tenantIDForInsert(ctx)
res, err := tx.ExecContext(ctx,
`UPDATE team_tasks SET status = ?, locked_at = NULL, lock_expires_at = NULL,
followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL,
progress_percent = NULL, updated_at = ?
WHERE id = ? AND status = ? AND team_id = ? AND tenant_id = ?`,
store.TeamTaskStatusCompleted, now,
taskID, store.TeamTaskStatusInReview, teamID, tid,
)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("task not in review or not found")
}
if err := unblockDependentTasksSQLite(ctx, tx, taskID); err != nil {
return err
}
return tx.Commit()
}
func (s *SQLiteTeamStore) RejectTask(ctx context.Context, taskID, teamID uuid.UUID, reason string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
now := time.Now()
tid := tenantIDForInsert(ctx)
res, err := tx.ExecContext(ctx,
`UPDATE team_tasks SET status = ?, result = ?, locked_at = NULL, lock_expires_at = NULL,
followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL,
progress_percent = NULL, updated_at = ?
WHERE id = ? AND status = ? AND team_id = ? AND tenant_id = ?`,
store.TeamTaskStatusCancelled, reason, now,
taskID, store.TeamTaskStatusInReview, teamID, tid,
)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("task not in review or not found")
}
if err := unblockDependentTasksSQLite(ctx, tx, taskID); err != nil {
return err
}
return tx.Commit()
}
// unblockDependentTasksSQLite removes taskID from blocked_by JSON arrays and
// transitions blocked→pending when all blockers are resolved.
// SQLite has no array_remove — we do it in Go: fetch affected rows, update in-process.
func unblockDependentTasksSQLite(ctx context.Context, tx *sql.Tx, taskID uuid.UUID) error {
tid := tenantIDForInsert(ctx)
taskIDStr := taskID.String()
// Find all blocked tasks that reference this taskID in their blocked_by JSON array.
rows, err := tx.QueryContext(ctx,
`SELECT id, blocked_by FROM team_tasks
WHERE status IN ('blocked', 'pending') AND tenant_id = ?
AND EXISTS (SELECT 1 FROM json_each(blocked_by) WHERE json_each.value = ?)`,
tid, taskIDStr,
)
if err != nil {
return err
}
type toUpdate struct {
id uuid.UUID
blockedBy []string
}
var pending []toUpdate
for rows.Next() {
var id uuid.UUID
var rawJSON []byte
if err := rows.Scan(&id, &rawJSON); err != nil {
rows.Close()
return err
}
var blockedBy []string
if len(rawJSON) > 0 {
_ = json.Unmarshal(rawJSON, &blockedBy)
}
// Remove taskID from the slice.
filtered := blockedBy[:0]
for _, s := range blockedBy {
if s != taskIDStr {
filtered = append(filtered, s)
}
}
pending = append(pending, toUpdate{id: id, blockedBy: filtered})
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
now := time.Now()
for _, u := range pending {
newJSON := jsonStringArray(u.blockedBy)
newStatus := "pending"
if len(u.blockedBy) > 0 {
newStatus = "blocked"
}
if _, err := tx.ExecContext(ctx,
`UPDATE team_tasks SET blocked_by = ?, status = ?, updated_at = ? WHERE id = ? AND tenant_id = ?`,
newJSON, newStatus, now, u.id, tid,
); err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,151 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"database/sql"
"time"
"github.com/google/uuid"
)
// SQLiteBuiltinToolTenantConfigStore implements store.BuiltinToolTenantConfigStore.
type SQLiteBuiltinToolTenantConfigStore struct {
db *sql.DB
}
func NewSQLiteBuiltinToolTenantConfigStore(db *sql.DB) *SQLiteBuiltinToolTenantConfigStore {
return &SQLiteBuiltinToolTenantConfigStore{db: db}
}
func (s *SQLiteBuiltinToolTenantConfigStore) ListDisabled(ctx context.Context, tenantID uuid.UUID) ([]string, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT tool_name FROM builtin_tool_tenant_configs WHERE tenant_id = ? AND enabled = 0`,
tenantID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var names []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
names = append(names, name)
}
return names, rows.Err()
}
func (s *SQLiteBuiltinToolTenantConfigStore) ListAll(ctx context.Context, tenantID uuid.UUID) (map[string]bool, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT tool_name, enabled FROM builtin_tool_tenant_configs WHERE tenant_id = ?`,
tenantID,
)
if err != nil {
return nil, err
}
defer rows.Close()
result := make(map[string]bool)
for rows.Next() {
var name string
var enabled bool
if err := rows.Scan(&name, &enabled); err != nil {
return nil, err
}
result[name] = enabled
}
return result, rows.Err()
}
func (s *SQLiteBuiltinToolTenantConfigStore) Set(ctx context.Context, tenantID uuid.UUID, toolName string, enabled bool) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO builtin_tool_tenant_configs (tool_name, tenant_id, enabled, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT (tool_name, tenant_id) DO UPDATE SET enabled = excluded.enabled, updated_at = excluded.updated_at`,
toolName, tenantID, enabled, time.Now().UTC(),
)
return err
}
func (s *SQLiteBuiltinToolTenantConfigStore) Delete(ctx context.Context, tenantID uuid.UUID, toolName string) error {
_, err := s.db.ExecContext(ctx,
`DELETE FROM builtin_tool_tenant_configs WHERE tool_name = ? AND tenant_id = ?`,
toolName, tenantID,
)
return err
}
// SQLiteSkillTenantConfigStore implements store.SkillTenantConfigStore.
type SQLiteSkillTenantConfigStore struct {
db *sql.DB
}
func NewSQLiteSkillTenantConfigStore(db *sql.DB) *SQLiteSkillTenantConfigStore {
return &SQLiteSkillTenantConfigStore{db: db}
}
func (s *SQLiteSkillTenantConfigStore) ListDisabledSkillIDs(ctx context.Context, tenantID uuid.UUID) ([]uuid.UUID, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT skill_id FROM skill_tenant_configs WHERE tenant_id = ? AND enabled = 0`,
tenantID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []uuid.UUID
for rows.Next() {
var id uuid.UUID
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
func (s *SQLiteSkillTenantConfigStore) ListAll(ctx context.Context, tenantID uuid.UUID) (map[uuid.UUID]bool, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT skill_id, enabled FROM skill_tenant_configs WHERE tenant_id = ?`,
tenantID,
)
if err != nil {
return nil, err
}
defer rows.Close()
result := make(map[uuid.UUID]bool)
for rows.Next() {
var id uuid.UUID
var enabled bool
if err := rows.Scan(&id, &enabled); err != nil {
return nil, err
}
result[id] = enabled
}
return result, rows.Err()
}
func (s *SQLiteSkillTenantConfigStore) Set(ctx context.Context, tenantID uuid.UUID, skillID uuid.UUID, enabled bool) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO skill_tenant_configs (skill_id, tenant_id, enabled, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT (skill_id, tenant_id) DO UPDATE SET enabled = excluded.enabled, updated_at = excluded.updated_at`,
skillID, tenantID, enabled, time.Now().UTC(),
)
return err
}
func (s *SQLiteSkillTenantConfigStore) Delete(ctx context.Context, tenantID uuid.UUID, skillID uuid.UUID) error {
_, err := s.db.ExecContext(ctx,
`DELETE FROM skill_tenant_configs WHERE skill_id = ? AND tenant_id = ?`,
skillID, tenantID,
)
return err
}
+240
View File
@@ -0,0 +1,240 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"database/sql"
"encoding/json"
"errors"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// SQLiteTenantStore implements store.TenantStore backed by SQLite.
type SQLiteTenantStore struct {
db *sql.DB
}
func NewSQLiteTenantStore(db *sql.DB) *SQLiteTenantStore {
return &SQLiteTenantStore{db: db}
}
// ============================================================
// Tenant CRUD
// ============================================================
func (s *SQLiteTenantStore) CreateTenant(ctx context.Context, tenant *store.TenantData) error {
if tenant.ID == uuid.Nil {
tenant.ID = store.GenNewID()
}
now := time.Now()
tenant.CreatedAt = now
tenant.UpdatedAt = now
settings := tenant.Settings
if len(settings) == 0 {
settings = json.RawMessage(`{}`)
}
_, err := s.db.ExecContext(ctx,
`INSERT INTO tenants (id, name, slug, status, settings, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
tenant.ID, tenant.Name, tenant.Slug, tenant.Status, settings, now, now,
)
return err
}
func (s *SQLiteTenantStore) GetTenant(ctx context.Context, id uuid.UUID) (*store.TenantData, error) {
row := s.db.QueryRowContext(ctx,
`SELECT id, name, slug, status, settings, created_at, updated_at
FROM tenants WHERE id = ?`, id)
return scanTenantRow(row)
}
func (s *SQLiteTenantStore) GetTenantBySlug(ctx context.Context, slug string) (*store.TenantData, error) {
row := s.db.QueryRowContext(ctx,
`SELECT id, name, slug, status, settings, created_at, updated_at
FROM tenants WHERE slug = ?`, slug)
return scanTenantRow(row)
}
func (s *SQLiteTenantStore) ListTenants(ctx context.Context) ([]store.TenantData, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, name, slug, status, settings, created_at, updated_at
FROM tenants ORDER BY created_at`)
if err != nil {
return nil, err
}
defer rows.Close()
var tenants []store.TenantData
for rows.Next() {
d, err := scanTenantRowScanner(rows)
if err != nil {
return nil, err
}
tenants = append(tenants, *d)
}
return tenants, rows.Err()
}
func (s *SQLiteTenantStore) UpdateTenant(ctx context.Context, id uuid.UUID, updates map[string]any) error {
return execMapUpdate(ctx, s.db, "tenants", id, updates)
}
// ============================================================
// Tenant-user membership
// ============================================================
func (s *SQLiteTenantStore) AddUser(ctx context.Context, tenantID uuid.UUID, userID, role string) error {
now := time.Now()
_, err := s.db.ExecContext(ctx,
`INSERT INTO tenant_users (id, tenant_id, user_id, role, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = excluded.role, updated_at = excluded.updated_at`,
store.GenNewID(), tenantID, userID, role, now, now,
)
return err
}
func (s *SQLiteTenantStore) GetTenantUser(ctx context.Context, id uuid.UUID) (*store.TenantUserData, error) {
row := s.db.QueryRowContext(ctx,
`SELECT id, tenant_id, user_id, display_name, role, metadata, created_at, updated_at
FROM tenant_users WHERE id = ?`, id)
var d store.TenantUserData
createdAt, updatedAt := scanTimePair()
if err := row.Scan(&d.ID, &d.TenantID, &d.UserID, &d.DisplayName, &d.Role, &d.Metadata, createdAt, updatedAt); err != nil {
return nil, err
}
d.CreatedAt = createdAt.Time
d.UpdatedAt = updatedAt.Time
return &d, nil
}
func (s *SQLiteTenantStore) CreateTenantUserReturning(ctx context.Context, tenantID uuid.UUID, userID, displayName, role string) (*store.TenantUserData, error) {
now := time.Now()
var dn *string
if displayName != "" {
dn = &displayName
}
// SQLite 3.35+ supports RETURNING.
row := s.db.QueryRowContext(ctx,
`INSERT INTO tenant_users (id, tenant_id, user_id, display_name, role, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (tenant_id, user_id) DO UPDATE SET
display_name = COALESCE(excluded.display_name, tenant_users.display_name),
updated_at = excluded.updated_at
RETURNING id, tenant_id, user_id, display_name, role, metadata, created_at, updated_at`,
store.GenNewID(), tenantID, userID, dn, role, now, now,
)
var d store.TenantUserData
createdAt, updatedAt := scanTimePair()
if err := row.Scan(&d.ID, &d.TenantID, &d.UserID, &d.DisplayName, &d.Role, &d.Metadata, createdAt, updatedAt); err != nil {
return nil, err
}
d.CreatedAt = createdAt.Time
d.UpdatedAt = updatedAt.Time
return &d, nil
}
func (s *SQLiteTenantStore) RemoveUser(ctx context.Context, tenantID uuid.UUID, userID string) error {
_, err := s.db.ExecContext(ctx,
`DELETE FROM tenant_users WHERE tenant_id = ? AND user_id = ?`,
tenantID, userID,
)
return err
}
func (s *SQLiteTenantStore) GetUserRole(ctx context.Context, tenantID uuid.UUID, userID string) (string, error) {
var role string
err := s.db.QueryRowContext(ctx,
`SELECT role FROM tenant_users WHERE tenant_id = ? AND user_id = ?`,
tenantID, userID,
).Scan(&role)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
return role, err
}
func (s *SQLiteTenantStore) ListUsers(ctx context.Context, tenantID uuid.UUID) ([]store.TenantUserData, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, tenant_id, user_id, display_name, role, metadata, created_at, updated_at
FROM tenant_users WHERE tenant_id = ? ORDER BY created_at`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanTenantUserRows(rows)
}
func (s *SQLiteTenantStore) ListUserTenants(ctx context.Context, userID string) ([]store.TenantUserData, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, tenant_id, user_id, display_name, role, metadata, created_at, updated_at
FROM tenant_users WHERE user_id = ? ORDER BY created_at`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanTenantUserRows(rows)
}
func (s *SQLiteTenantStore) ResolveUserTenant(ctx context.Context, userID string) (uuid.UUID, error) {
var tenantID uuid.UUID
err := s.db.QueryRowContext(ctx,
`SELECT tenant_id FROM tenant_users WHERE user_id = ? ORDER BY created_at LIMIT 1`,
userID,
).Scan(&tenantID)
if errors.Is(err, sql.ErrNoRows) {
return store.MasterTenantID, nil
}
if err != nil {
return uuid.Nil, err
}
return tenantID, nil
}
// ============================================================
// Scan helpers
// ============================================================
func scanTenantRow(row *sql.Row) (*store.TenantData, error) {
var d store.TenantData
createdAt, updatedAt := scanTimePair()
if err := row.Scan(&d.ID, &d.Name, &d.Slug, &d.Status, &d.Settings, createdAt, updatedAt); err != nil {
return nil, err
}
d.CreatedAt = createdAt.Time
d.UpdatedAt = updatedAt.Time
return &d, nil
}
func scanTenantRowScanner(row interface{ Scan(...any) error }) (*store.TenantData, error) {
var d store.TenantData
createdAt, updatedAt := scanTimePair()
if err := row.Scan(&d.ID, &d.Name, &d.Slug, &d.Status, &d.Settings, createdAt, updatedAt); err != nil {
return nil, err
}
d.CreatedAt = createdAt.Time
d.UpdatedAt = updatedAt.Time
return &d, nil
}
func scanTenantUserRows(rows *sql.Rows) ([]store.TenantUserData, error) {
var result []store.TenantUserData
for rows.Next() {
var d store.TenantUserData
createdAt, updatedAt := scanTimePair()
if err := rows.Scan(&d.ID, &d.TenantID, &d.UserID, &d.DisplayName, &d.Role, &d.Metadata, createdAt, updatedAt); err != nil {
return nil, err
}
d.CreatedAt = createdAt.Time
d.UpdatedAt = updatedAt.Time
result = append(result, d)
}
return result, rows.Err()
}
+252
View File
@@ -0,0 +1,252 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// SQLiteTracingStore implements store.TracingStore backed by SQLite.
type SQLiteTracingStore struct {
db *sql.DB
}
func NewSQLiteTracingStore(db *sql.DB) *SQLiteTracingStore {
return &SQLiteTracingStore{db: db}
}
func (s *SQLiteTracingStore) CreateTrace(ctx context.Context, trace *store.TraceData) error {
if trace.ID == uuid.Nil {
trace.ID = store.GenNewID()
}
tenantID := store.TenantIDFromContext(ctx)
if tenantID == uuid.Nil {
tenantID = store.MasterTenantID
}
_, err := s.db.ExecContext(ctx,
`INSERT INTO traces (id, parent_trace_id, agent_id, user_id, session_key, run_id, start_time, end_time,
duration_ms, name, channel, input_preview, output_preview,
total_input_tokens, total_output_tokens, total_cost, span_count, llm_call_count, tool_call_count,
status, error, metadata, tags, team_id, created_at, tenant_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
trace.ID, nilUUID(trace.ParentTraceID), nilUUID(trace.AgentID), nilStr(trace.UserID), nilStr(trace.SessionKey),
nilStr(trace.RunID), trace.StartTime, nilTime(trace.EndTime),
nilInt(trace.DurationMS), nilStr(trace.Name), nilStr(trace.Channel),
nilStr(trace.InputPreview), nilStr(trace.OutputPreview),
trace.TotalInputTokens, trace.TotalOutputTokens, trace.TotalCost, trace.SpanCount, trace.LLMCallCount, trace.ToolCallCount,
trace.Status, nilStr(trace.Error), jsonOrEmpty(trace.Metadata), jsonStringArray(trace.Tags), nilUUID(trace.TeamID), trace.CreatedAt, tenantID,
)
return err
}
func (s *SQLiteTracingStore) UpdateTrace(ctx context.Context, traceID uuid.UUID, updates map[string]any) error {
if store.IsCrossTenant(ctx) {
return execMapUpdate(ctx, s.db, "traces", traceID, updates)
}
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
return fmt.Errorf("tenant_id required for update")
}
return execMapUpdateWhereTenant(ctx, s.db, "traces", updates, traceID, tid)
}
func (s *SQLiteTracingStore) GetTrace(ctx context.Context, traceID uuid.UUID) (*store.TraceData, error) {
query := `SELECT id, parent_trace_id, agent_id, user_id, session_key, run_id, start_time, end_time,
duration_ms, name, channel, input_preview, output_preview,
total_input_tokens, total_output_tokens, COALESCE(total_cost, 0), span_count, llm_call_count, tool_call_count,
status, error, metadata, tags, team_id, created_at
FROM traces WHERE id = ?`
qArgs := []any{traceID}
if !store.IsCrossTenant(ctx) {
tenantID := store.TenantIDFromContext(ctx)
if tenantID == uuid.Nil {
return nil, sql.ErrNoRows
}
query += ` AND tenant_id = ?`
qArgs = append(qArgs, tenantID)
}
return scanTraceRow(s.db.QueryRowContext(ctx, query, qArgs...))
}
func buildTraceWhere(ctx context.Context, opts store.TraceListOpts) (string, []any) {
var conditions []string
var args []any
if !store.IsCrossTenant(ctx) {
tenantID := store.TenantIDFromContext(ctx)
if tenantID == uuid.Nil {
return " WHERE 1=0", nil
}
conditions = append(conditions, "tenant_id = ?")
args = append(args, tenantID)
}
if opts.AgentID != nil {
conditions = append(conditions, "agent_id = ?")
args = append(args, *opts.AgentID)
}
if opts.UserID != "" {
conditions = append(conditions, "user_id = ?")
args = append(args, opts.UserID)
}
if opts.SessionKey != "" {
conditions = append(conditions, "session_key = ?")
args = append(args, opts.SessionKey)
}
if opts.Status != "" {
conditions = append(conditions, "status = ?")
args = append(args, opts.Status)
}
if opts.Channel != "" {
conditions = append(conditions, "channel = ?")
args = append(args, opts.Channel)
}
if len(conditions) == 0 {
return "", nil
}
return " WHERE " + strings.Join(conditions, " AND "), args
}
func (s *SQLiteTracingStore) CountTraces(ctx context.Context, opts store.TraceListOpts) (int, error) {
where, args := buildTraceWhere(ctx, opts)
var count int
err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM traces"+where, args...).Scan(&count)
return count, err
}
func (s *SQLiteTracingStore) ListTraces(ctx context.Context, opts store.TraceListOpts) ([]store.TraceData, error) {
where, args := buildTraceWhere(ctx, opts)
limit := opts.Limit
if limit <= 0 {
limit = 50
}
q := `SELECT id, parent_trace_id, agent_id, user_id, session_key, run_id, start_time, end_time,
duration_ms, name, channel, input_preview, output_preview,
total_input_tokens, total_output_tokens, COALESCE(total_cost, 0), span_count, llm_call_count, tool_call_count,
status, error, metadata, tags, team_id, created_at
FROM traces` + where +
fmt.Sprintf(" ORDER BY created_at DESC LIMIT %d OFFSET %d", limit, opts.Offset)
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
return scanTraceRows(rows)
}
func (s *SQLiteTracingStore) ListChildTraces(ctx context.Context, parentTraceID uuid.UUID) ([]store.TraceData, error) {
q := `SELECT id, parent_trace_id, agent_id, user_id, session_key, run_id, start_time, end_time,
duration_ms, name, channel, input_preview, output_preview,
total_input_tokens, total_output_tokens, COALESCE(total_cost, 0), span_count, llm_call_count, tool_call_count,
status, error, metadata, tags, team_id, created_at
FROM traces WHERE parent_trace_id = ?`
qArgs := []any{parentTraceID}
if !store.IsCrossTenant(ctx) {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
return nil, fmt.Errorf("tenant_id required")
}
q += " AND tenant_id = ?"
qArgs = append(qArgs, tid)
}
q += " ORDER BY created_at"
rows, err := s.db.QueryContext(ctx, q, qArgs...)
if err != nil {
return nil, err
}
defer rows.Close()
return scanTraceRows(rows)
}
func (s *SQLiteTracingStore) GetMonthlyAgentCost(ctx context.Context, agentID uuid.UUID, year int, month time.Month) (float64, error) {
start := time.Date(year, month, 1, 0, 0, 0, 0, time.UTC)
end := start.AddDate(0, 1, 0)
q := `SELECT COALESCE(SUM(total_cost), 0) FROM traces
WHERE agent_id = ? AND created_at >= ? AND created_at < ? AND parent_trace_id IS NULL`
qArgs := []any{agentID, start, end}
if !store.IsCrossTenant(ctx) {
tid := store.TenantIDFromContext(ctx)
if tid != uuid.Nil {
q += " AND tenant_id = ?"
qArgs = append(qArgs, tid)
}
}
var cost float64
err := s.db.QueryRowContext(ctx, q, qArgs...).Scan(&cost)
return cost, err
}
func (s *SQLiteTracingStore) GetCostSummary(ctx context.Context, opts store.CostSummaryOpts) ([]store.CostSummaryRow, error) {
var conditions []string
var args []any
conditions = append(conditions, "parent_trace_id IS NULL")
if !store.IsCrossTenant(ctx) {
tenantID := store.TenantIDFromContext(ctx)
if tenantID != uuid.Nil {
conditions = append(conditions, "tenant_id = ?")
args = append(args, tenantID)
}
}
if opts.AgentID != nil {
conditions = append(conditions, "agent_id = ?")
args = append(args, *opts.AgentID)
}
if opts.From != nil {
conditions = append(conditions, "created_at >= ?")
args = append(args, *opts.From)
}
if opts.To != nil {
conditions = append(conditions, "created_at < ?")
args = append(args, *opts.To)
}
where := " WHERE " + strings.Join(conditions, " AND ")
q := `SELECT agent_id, COALESCE(SUM(total_cost), 0), COALESCE(SUM(total_input_tokens), 0),
COALESCE(SUM(total_output_tokens), 0), COUNT(*)
FROM traces` + where + ` GROUP BY agent_id ORDER BY SUM(total_cost) DESC`
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var result []store.CostSummaryRow
for rows.Next() {
var r store.CostSummaryRow
var agentID *uuid.UUID
if err := rows.Scan(&agentID, &r.TotalCost, &r.TotalInputTokens, &r.TotalOutputTokens, &r.TraceCount); err != nil {
continue
}
r.AgentID = agentID
result = append(result, r)
}
return result, rows.Err()
}
// DeleteTracesOlderThan deletes traces and their spans older than cutoff.
func (s *SQLiteTracingStore) DeleteTracesOlderThan(ctx context.Context, cutoff time.Time) (int64, error) {
// Delete spans belonging to old traces.
_, err := s.db.ExecContext(ctx,
`DELETE FROM spans WHERE trace_id IN (SELECT id FROM traces WHERE created_at < ?)`, cutoff)
if err != nil {
return 0, fmt.Errorf("delete old spans: %w", err)
}
res, err := s.db.ExecContext(ctx, `DELETE FROM traces WHERE created_at < ?`, cutoff)
if err != nil {
return 0, fmt.Errorf("delete old traces: %w", err)
}
return res.RowsAffected()
}
@@ -0,0 +1,97 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"database/sql"
"log/slog"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func scanTraceRow(row *sql.Row) (*store.TraceData, error) {
var d store.TraceData
var parentTraceID, agentID, teamID *uuid.UUID
var userID, sessionKey, runID, name, channel, inputPreview, outputPreview, errStr *string
var endTime nullSqliteTime
var durationMS *int
var metadata *[]byte
var tags []byte
var startTime, createdAt sqliteTime
err := row.Scan(&d.ID, &parentTraceID, &agentID, &userID, &sessionKey, &runID, &startTime, &endTime,
&durationMS, &name, &channel, &inputPreview, &outputPreview,
&d.TotalInputTokens, &d.TotalOutputTokens, &d.TotalCost, &d.SpanCount, &d.LLMCallCount, &d.ToolCallCount,
&d.Status, &errStr, &metadata, &tags, &teamID, &createdAt)
if err != nil {
return nil, err
}
d.StartTime = startTime.Time
d.CreatedAt = createdAt.Time
var endTimePtr *time.Time
if endTime.Valid {
endTimePtr = &endTime.Time
}
applyTraceNullables(&d, parentTraceID, agentID, teamID, userID, sessionKey, runID, name, channel, inputPreview, outputPreview, errStr, endTimePtr, durationMS, metadata, tags)
return &d, nil
}
func scanTraceRows(rows *sql.Rows) ([]store.TraceData, error) {
var result []store.TraceData
for rows.Next() {
var d store.TraceData
var parentTraceID, agentID, teamID *uuid.UUID
var userID, sessionKey, runID, name, channel, inputPreview, outputPreview, errStr *string
var endTime nullSqliteTime
var durationMS *int
var metadata *[]byte
var tags []byte
var startTime, createdAt sqliteTime
if err := rows.Scan(&d.ID, &parentTraceID, &agentID, &userID, &sessionKey, &runID, &startTime, &endTime,
&durationMS, &name, &channel, &inputPreview, &outputPreview,
&d.TotalInputTokens, &d.TotalOutputTokens, &d.TotalCost, &d.SpanCount, &d.LLMCallCount, &d.ToolCallCount,
&d.Status, &errStr, &metadata, &tags, &teamID, &createdAt); err != nil {
slog.Warn("tracing: trace scan failed", "error", err)
continue
}
d.StartTime = startTime.Time
d.CreatedAt = createdAt.Time
var endTimePtr *time.Time
if endTime.Valid {
endTimePtr = &endTime.Time
}
applyTraceNullables(&d, parentTraceID, agentID, teamID, userID, sessionKey, runID, name, channel, inputPreview, outputPreview, errStr, endTimePtr, durationMS, metadata, tags)
result = append(result, d)
}
return result, rows.Err()
}
func applyTraceNullables(d *store.TraceData,
parentTraceID, agentID, teamID *uuid.UUID,
userID, sessionKey, runID, name, channel, inputPreview, outputPreview, errStr *string,
endTime *time.Time, durationMS *int, metadata *[]byte, tags []byte,
) {
d.ParentTraceID = parentTraceID
d.AgentID = agentID
d.TeamID = teamID
d.UserID = derefStr(userID)
d.SessionKey = derefStr(sessionKey)
d.RunID = derefStr(runID)
d.EndTime = endTime
if durationMS != nil {
d.DurationMS = *durationMS
}
d.Name = derefStr(name)
d.Channel = derefStr(channel)
d.InputPreview = derefStr(inputPreview)
d.OutputPreview = derefStr(outputPreview)
d.Error = derefStr(errStr)
if metadata != nil {
d.Metadata = *metadata
}
scanJSONStringArray(tags, &d.Tags)
}
+208
View File
@@ -0,0 +1,208 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"log/slog"
"strings"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func (s *SQLiteTracingStore) CreateSpan(ctx context.Context, span *store.SpanData) error {
if span.ID == uuid.Nil {
span.ID = store.GenNewID()
}
tenantID := span.TenantID
if tenantID == uuid.Nil {
tenantID = store.MasterTenantID
}
_, err := s.db.ExecContext(ctx,
`INSERT INTO spans (id, trace_id, parent_span_id, agent_id, span_type, name,
start_time, end_time, duration_ms, status, error, level,
model, provider, input_tokens, output_tokens, finish_reason,
model_params, tool_name, tool_call_id, input_preview, output_preview,
metadata, team_id, created_at, tenant_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
span.ID, span.TraceID, span.ParentSpanID, span.AgentID, span.SpanType, nilStr(span.Name),
span.StartTime, nilTime(span.EndTime), nilInt(span.DurationMS), span.Status, nilStr(span.Error), span.Level,
nilStr(span.Model), nilStr(span.Provider), nilInt(span.InputTokens), nilInt(span.OutputTokens), nilStr(span.FinishReason),
jsonOrNull(span.ModelParams), nilStr(span.ToolName), nilStr(span.ToolCallID), nilStr(span.InputPreview), nilStr(span.OutputPreview),
jsonOrNull(span.Metadata), nilUUID(span.TeamID), span.CreatedAt, tenantID,
)
return err
}
func (s *SQLiteTracingStore) UpdateSpan(ctx context.Context, spanID uuid.UUID, updates map[string]any) error {
return execMapUpdate(ctx, s.db, "spans", spanID, updates)
}
func (s *SQLiteTracingStore) GetTraceSpans(ctx context.Context, traceID uuid.UUID) ([]store.SpanData, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, trace_id, parent_span_id, agent_id, span_type, name,
start_time, end_time, duration_ms, status, error, level,
model, provider, input_tokens, output_tokens, finish_reason,
model_params, tool_name, tool_call_id, input_preview, output_preview,
metadata, team_id, created_at
FROM spans WHERE trace_id = ? ORDER BY start_time`, traceID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []store.SpanData
for rows.Next() {
var d store.SpanData
var parentSpanID, agentID, teamID *uuid.UUID
var name, errStr, level, model, provider, finishReason, toolName, toolCallID, inputPreview, outputPreview *string
var status *string
var endTimeSt nullSqliteTime
var durationMS, inputTokens, outputTokens *int
var modelParams, metadata *[]byte
var startTime, createdAt sqliteTime
if err := rows.Scan(&d.ID, &d.TraceID, &parentSpanID, &agentID, &d.SpanType, &name,
&startTime, &endTimeSt, &durationMS, &status, &errStr, &level,
&model, &provider, &inputTokens, &outputTokens, &finishReason,
&modelParams, &toolName, &toolCallID, &inputPreview, &outputPreview,
&metadata, &teamID, &createdAt); err != nil {
slog.Warn("tracing: span scan failed", "trace_id", traceID, "error", err)
continue
}
d.StartTime = startTime.Time
d.CreatedAt = createdAt.Time
d.ParentSpanID = parentSpanID
d.AgentID = agentID
d.TeamID = teamID
d.Name = derefStr(name)
if endTimeSt.Valid {
d.EndTime = &endTimeSt.Time
}
d.Status = derefStr(status)
d.Level = derefStr(level)
if modelParams != nil {
d.ModelParams = *modelParams
}
if metadata != nil {
d.Metadata = *metadata
}
if durationMS != nil {
d.DurationMS = *durationMS
}
d.Error = derefStr(errStr)
d.Model = derefStr(model)
d.Provider = derefStr(provider)
if inputTokens != nil {
d.InputTokens = *inputTokens
}
if outputTokens != nil {
d.OutputTokens = *outputTokens
}
d.FinishReason = derefStr(finishReason)
d.ToolName = derefStr(toolName)
d.ToolCallID = derefStr(toolCallID)
d.InputPreview = derefStr(inputPreview)
d.OutputPreview = derefStr(outputPreview)
result = append(result, d)
}
return result, rows.Err()
}
// sqliteSpanCols is the number of columns in the spans INSERT.
const sqliteSpanCols = 26
// sqliteSpanBatchSize limits rows per batch: 999 / 26 ≈ 38.
const sqliteSpanBatchSize = 38
// BatchCreateSpans inserts spans in batches to stay under SQLite's 999-variable limit.
func (s *SQLiteTracingStore) BatchCreateSpans(ctx context.Context, spans []store.SpanData) error {
if len(spans) == 0 {
return nil
}
for start := 0; start < len(spans); start += sqliteSpanBatchSize {
end := start + sqliteSpanBatchSize
if end > len(spans) {
end = len(spans)
}
if err := s.batchInsertSpans(ctx, spans[start:end]); err != nil {
return err
}
}
return nil
}
func (s *SQLiteTracingStore) batchInsertSpans(ctx context.Context, spans []store.SpanData) error {
placeholderRow := "(" + strings.Repeat("?, ", sqliteSpanCols-1) + "?)"
valueGroups := make([]string, len(spans))
args := make([]any, 0, len(spans)*sqliteSpanCols)
for i, span := range spans {
if span.ID == uuid.Nil {
span.ID = store.GenNewID()
spans[i].ID = span.ID
}
tenantID := span.TenantID
if tenantID == uuid.Nil {
tenantID = store.MasterTenantID
}
valueGroups[i] = placeholderRow
args = append(args,
span.ID, span.TraceID, span.ParentSpanID, span.AgentID, span.SpanType, nilStr(span.Name),
span.StartTime, nilTime(span.EndTime), nilInt(span.DurationMS), span.Status, nilStr(span.Error), span.Level,
nilStr(span.Model), nilStr(span.Provider), nilInt(span.InputTokens), nilInt(span.OutputTokens), nilStr(span.FinishReason),
jsonOrNull(span.ModelParams), nilStr(span.ToolName), nilStr(span.ToolCallID), nilStr(span.InputPreview), nilStr(span.OutputPreview),
jsonOrNull(span.Metadata), nilUUID(span.TeamID), span.CreatedAt, tenantID,
)
}
q := `INSERT INTO spans (id, trace_id, parent_span_id, agent_id, span_type, name,
start_time, end_time, duration_ms, status, error, level,
model, provider, input_tokens, output_tokens, finish_reason,
model_params, tool_name, tool_call_id, input_preview, output_preview,
metadata, team_id, created_at, tenant_id)
VALUES ` + strings.Join(valueGroups, ", ")
_, err := s.db.ExecContext(ctx, q, args...)
if err == nil {
return nil
}
// Fallback to individual inserts on batch failure.
slog.Warn("tracing: batch span insert failed, falling back", "count", len(spans), "error", err)
var firstErr error
for i := range spans {
if e := s.CreateSpan(ctx, &spans[i]); e != nil {
slog.Warn("tracing: individual span insert failed", "span_id", spans[i].ID, "error", e)
if firstErr == nil {
firstErr = e
}
}
}
return firstErr
}
func (s *SQLiteTracingStore) BatchUpdateTraceAggregates(ctx context.Context, traceID uuid.UUID) error {
_, err := s.db.ExecContext(ctx, `
UPDATE traces SET
span_count = (SELECT COUNT(*) FROM spans WHERE trace_id = ?),
llm_call_count = (SELECT COUNT(*) FROM spans WHERE trace_id = ? AND span_type = 'llm_call'),
tool_call_count = (SELECT COUNT(*) FROM spans WHERE trace_id = ? AND span_type = 'tool_call'),
total_input_tokens = COALESCE((SELECT SUM(input_tokens) FROM spans WHERE trace_id = ? AND span_type = 'llm_call' AND input_tokens IS NOT NULL), 0),
total_output_tokens = COALESCE((SELECT SUM(output_tokens) FROM spans WHERE trace_id = ? AND span_type = 'llm_call' AND output_tokens IS NOT NULL), 0),
total_cost = COALESCE((SELECT SUM(total_cost) FROM spans WHERE trace_id = ? AND total_cost IS NOT NULL), 0),
metadata = (
SELECT json_object(
'total_cache_read_tokens', COALESCE(SUM(json_extract(metadata, '$.cache_read_tokens')), 0),
'total_cache_creation_tokens', COALESCE(SUM(json_extract(metadata, '$.cache_creation_tokens')), 0)
)
FROM spans WHERE trace_id = ? AND span_type = 'llm_call' AND metadata IS NOT NULL
)
WHERE id = ?`,
traceID, traceID, traceID, traceID, traceID, traceID, traceID, traceID)
return err
}
+3
View File
@@ -144,4 +144,7 @@ type TracingStore interface {
// Cost aggregation
GetMonthlyAgentCost(ctx context.Context, agentID uuid.UUID, year int, month time.Month) (float64, error)
GetCostSummary(ctx context.Context, opts CostSummaryOpts) ([]CostSummaryRow, error)
// Maintenance
DeleteTracesOlderThan(ctx context.Context, cutoff time.Time) (int64, error)
}
+7 -1
View File
@@ -20,9 +20,15 @@ func GenNewID() uuid.UUID {
// StoreConfig configures the store layer.
type StoreConfig struct {
// PostgresDSN is the Postgres connection string (required).
// PostgresDSN is the Postgres connection string (required for postgres backend).
PostgresDSN string
// SQLitePath is the path to the SQLite database file (required for sqlite backend).
SQLitePath string
// StorageBackend selects the database backend: "postgres" (default) or "sqlite".
StorageBackend string
// SkillsStorageDir is the directory for skill file content (default: dataDir/skills-store/).
SkillsStorageDir string
+1 -1
View File
@@ -171,7 +171,7 @@ func (t *CreateAudioTool) Execute(ctx context.Context, args map[string]any) *Res
slog.Info("create_audio: file saved", "path", audioPath, "size", fi.Size(), "data_len", len(audioBytes), "provider", providerName, "type", audioType)
}
result := &Result{ForLLM: fmt.Sprintf("MEDIA:%s", audioPath)}
result := &Result{ForLLM: fmt.Sprintf("MEDIA:%s\nUse the EXACT filename when referencing: %s", audioPath, filepath.Base(audioPath))}
result.Media = []bus.MediaFile{{Path: audioPath, MimeType: "audio/mpeg"}}
result.Deliverable = fmt.Sprintf("[Generated audio: %s]\nPrompt: %s", filepath.Base(audioPath), prompt)
result.Provider = providerName
+1 -1
View File
@@ -122,7 +122,7 @@ func (t *CreateImageTool) Execute(ctx context.Context, args map[string]any) *Res
slog.Info("create_image: file saved", "path", imagePath, "size", fi.Size(), "data_len", len(chainResult.Data))
}
result := &Result{ForLLM: fmt.Sprintf("MEDIA:%s", imagePath)}
result := &Result{ForLLM: fmt.Sprintf("MEDIA:%s\nUse the EXACT filename when referencing: %s", imagePath, filepath.Base(imagePath))}
result.Media = []bus.MediaFile{{Path: imagePath, MimeType: "image/png"}}
result.Deliverable = fmt.Sprintf("[Generated image: %s]\nPrompt: %s", filepath.Base(imagePath), prompt)
result.Provider = chainResult.Provider
+1 -1
View File
@@ -140,7 +140,7 @@ func (t *CreateVideoTool) Execute(ctx context.Context, args map[string]any) *Res
slog.Info("create_video: file saved", "path", videoPath, "size", fi.Size(), "data_len", len(chainResult.Data))
}
result := &Result{ForLLM: fmt.Sprintf("MEDIA:%s", videoPath)}
result := &Result{ForLLM: fmt.Sprintf("MEDIA:%s\nUse the EXACT filename when referencing: %s", videoPath, filepath.Base(videoPath))}
result.Media = []bus.MediaFile{{Path: videoPath, MimeType: "video/mp4"}}
result.Deliverable = fmt.Sprintf("[Generated video: %s]\nPrompt: %s", filepath.Base(videoPath), prompt)
result.Provider = chainResult.Provider
+36 -2
View File
@@ -17,6 +17,7 @@ import (
type Registry struct {
tools map[string]Tool
aliases map[string]string // alias name → canonical tool name
disabled map[string]bool // tools disabled via admin UI (kept in registry, excluded from List)
mu sync.RWMutex
rateLimiter *ToolRateLimiter // nil = no rate limiting
scrubbing bool // scrub credentials from output (default true)
@@ -30,6 +31,7 @@ func NewRegistry() *Registry {
return &Registry{
tools: make(map[string]Tool),
aliases: make(map[string]string),
disabled: make(map[string]bool),
scrubbing: true, // enabled by default
}
}
@@ -93,11 +95,18 @@ func (r *Registry) Aliases() map[string]string {
}
// resolve looks up a tool by name, checking real tools first, then aliases.
// Disabled tools are excluded.
func (r *Registry) resolve(name string) (Tool, bool) {
if t, ok := r.tools[name]; ok {
if r.disabled[name] {
return nil, false
}
return t, true
}
if canonical, ok := r.aliases[name]; ok {
if r.disabled[canonical] {
return nil, false
}
t, ok := r.tools[canonical]
return t, ok
}
@@ -218,10 +227,16 @@ func (r *Registry) ProviderDefs() []providers.ToolDefinition {
defer r.mu.RUnlock()
defs := make([]providers.ToolDefinition, 0, len(r.tools)+len(r.aliases))
for _, tool := range r.tools {
for name, tool := range r.tools {
if r.disabled[name] {
continue
}
defs = append(defs, ToProviderDef(tool))
}
for alias, canonical := range r.aliases {
if r.disabled[canonical] {
continue
}
tool, ok := r.tools[canonical]
if !ok {
continue
@@ -244,11 +259,28 @@ func (r *Registry) List() []string {
defer r.mu.RUnlock()
names := make([]string, 0, len(r.tools))
for name := range r.tools {
names = append(names, name)
if !r.disabled[name] {
names = append(names, name)
}
}
return names
}
// Disable marks a tool as disabled (excluded from List and policy evaluation)
// without removing it from the registry. Can be re-enabled later.
func (r *Registry) Disable(name string) {
r.mu.Lock()
defer r.mu.Unlock()
r.disabled[name] = true
}
// Enable re-enables a previously disabled tool.
func (r *Registry) Enable(name string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.disabled, name)
}
// Count returns the number of registered tools.
func (r *Registry) Count() int {
r.mu.RLock()
@@ -265,10 +297,12 @@ func (r *Registry) Clone() *Registry {
clone := &Registry{
tools: make(map[string]Tool, len(r.tools)),
aliases: make(map[string]string, len(r.aliases)),
disabled: make(map[string]bool, len(r.disabled)),
rateLimiter: r.rateLimiter,
scrubbing: r.scrubbing,
}
maps.Copy(clone.tools, r.tools)
maps.Copy(clone.aliases, r.aliases)
maps.Copy(clone.disabled, r.disabled)
return clone
}
+49
View File
@@ -0,0 +1,49 @@
package tools
// TeamActionPolicy controls which team_tasks actions are available.
// Injected into TeamTasksTool at construction — no scattered if/else.
type TeamActionPolicy interface {
// IsAllowed returns true if the action is permitted in this edition.
IsAllowed(action string) bool
// AllowedActions returns the list of permitted action names (for Schema enum).
AllowedActions() []string
// MemberGuidance returns system-prompt text for team members.
MemberGuidance() string
}
// FullTeamPolicy allows all 18 team task actions (standard/PG edition).
type FullTeamPolicy struct{}
var fullActions = []string{
"list", "get", "create", "claim", "complete", "cancel",
"approve", "reject", "search", "review", "comment",
"progress", "attach", "update", "ask_user", "clear_ask_user", "retry",
}
func (FullTeamPolicy) IsAllowed(string) bool { return true }
func (FullTeamPolicy) AllowedActions() []string { return fullActions }
func (FullTeamPolicy) MemberGuidance() string {
return "Use comment(type='blocker') to escalate blockers to the leader. " +
"Use review to submit work for approval. " +
"Use progress to report incremental status updates."
}
// LiteTeamPolicy allows core lifecycle actions only (desktop/lite edition).
// Blocked: comment, review, approve, reject, attach, ask_user, clear_ask_user.
type LiteTeamPolicy struct{}
var liteActions = []string{
"list", "get", "create", "claim", "complete", "cancel",
"progress", "search", "update", "retry",
}
var liteBlocked = map[string]bool{
"comment": true, "review": true, "approve": true, "reject": true,
"attach": true, "ask_user": true, "clear_ask_user": true,
}
func (LiteTeamPolicy) IsAllowed(action string) bool { return !liteBlocked[action] }
func (LiteTeamPolicy) AllowedActions() []string { return liteActions }
func (LiteTeamPolicy) MemberGuidance() string {
return "Use progress to update status. Use complete when finished."
}
+24 -7
View File
@@ -3,16 +3,18 @@ package tools
import (
"context"
"fmt"
"strings"
)
// TeamTasksTool exposes the shared team task list to agents.
// Actions: list, get, create, claim, complete, cancel, search, review, comment, progress, attach, update.
// Actions are filtered by TeamActionPolicy (full in standard, limited in lite).
type TeamTasksTool struct {
manager *TeamToolManager
policy TeamActionPolicy
}
func NewTeamTasksTool(manager *TeamToolManager) *TeamTasksTool {
return &TeamTasksTool{manager: manager}
func NewTeamTasksTool(manager *TeamToolManager, policy TeamActionPolicy) *TeamTasksTool {
return &TeamTasksTool{manager: manager, policy: policy}
}
func (t *TeamTasksTool) Name() string { return "team_tasks" }
@@ -27,10 +29,8 @@ func (t *TeamTasksTool) Parameters() map[string]any {
"properties": map[string]any{
"action": map[string]any{
"type": "string",
"description": "'list', 'get', 'create', 'claim', 'complete', 'cancel', 'approve', 'reject', 'search', 'review', 'comment', 'progress', 'attach', 'update', 'ask_user', or 'clear_ask_user'. " +
"ask_user: set a periodic reminder that is sent to the USER (not the team) when you need the user's input/decision to continue (e.g. 'Which design do you prefer?'). ONLY use when you have a question for the user. Do NOT use for status updates, waiting for teammates, or notifications — use 'progress' instead. " +
"clear_ask_user: cancel a previously set ask_user reminder. " +
"retry: re-dispatch a stale or failed task.",
"enum": t.policy.AllowedActions(),
"description": t.buildActionDescription(),
},
"task_id": map[string]any{
"type": "string",
@@ -98,9 +98,26 @@ func (t *TeamTasksTool) Parameters() map[string]any {
}
}
// buildActionDescription returns the action parameter description based on policy.
func (t *TeamTasksTool) buildActionDescription() string {
base := "Available actions: " + strings.Join(t.policy.AllowedActions(), ", ") + "."
if t.policy.IsAllowed("ask_user") {
base += " ask_user: set a periodic reminder sent to the USER when you need input. clear_ask_user: cancel a previously set reminder."
}
if t.policy.IsAllowed("retry") {
base += " retry: re-dispatch a stale or failed task."
}
return base
}
func (t *TeamTasksTool) Execute(ctx context.Context, args map[string]any) *Result {
action, _ := args["action"].(string)
// Edition policy guard — reject actions not allowed in this edition.
if !t.policy.IsAllowed(action) {
return ErrorResult(fmt.Sprintf("action %q is not available in this edition", action))
}
// Block mutations during notification runs — leader may only relay status.
if RunKindFromCtx(ctx) == RunKindNotification {
switch action {
+29 -14
View File
@@ -4,10 +4,8 @@ import (
"context"
"log/slog"
"os"
"strings"
"sync"
"time"
"unicode/utf8"
"github.com/google/uuid"
@@ -17,7 +15,9 @@ import (
const (
defaultFlushInterval = 5 * time.Second
defaultBufferSize = 1000
previewMaxLen = 500
previewMaxLen = 40_000
traceRetention = 7 * 24 * time.Hour // auto-delete traces older than 7 days
pruneInterval = 8 * time.Hour
)
// SpanExporter is implemented by backends that receive span data alongside
@@ -205,18 +205,41 @@ func (c *Collector) flushLoop() {
ticker := time.NewTicker(defaultFlushInterval)
defer ticker.Stop()
pruneTicker := time.NewTicker(pruneInterval)
defer pruneTicker.Stop()
// Run initial prune shortly after startup.
go c.pruneOldTraces()
for {
select {
case <-ticker.C:
c.flush()
case <-pruneTicker.C:
c.pruneOldTraces()
case <-c.stopCh:
// Drain remaining spans
c.flush()
return
}
}
}
// pruneOldTraces deletes traces and spans older than traceRetention.
func (c *Collector) pruneOldTraces() {
cutoff := time.Now().UTC().Add(-traceRetention)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
deleted, err := c.store.DeleteTracesOlderThan(ctx, cutoff)
if err != nil {
slog.Warn("tracing: prune old traces failed", "error", err)
return
}
if deleted > 0 {
slog.Info("tracing: pruned old traces", "deleted", deleted, "older_than", cutoff.Format(time.RFC3339))
}
}
func (c *Collector) flush() {
// Drain span channel
var spans []store.SpanData
@@ -296,15 +319,7 @@ doneUpdates:
}
}
// truncatePreviewStr sanitizes and truncates a string based on verbose mode.
// truncatePreviewStr sanitizes and truncates a string by removing the middle.
func (c *Collector) truncatePreviewStr(s string) string {
limit := c.PreviewMaxLen()
s = strings.ToValidUTF8(s, "")
if len(s) <= limit {
return s
}
for limit > 0 && !utf8.RuneStart(s[limit]) {
limit--
}
return s[:limit] + "..."
return TruncateMid(s, c.PreviewMaxLen())
}
+5 -5
View File
@@ -191,13 +191,13 @@ func queryTraceAggregates(ctx context.Context, db *sql.DB, from, to time.Time) (
agent_id,
COALESCE(channel, '') as channel,
COUNT(*) as request_count,
COUNT(*) FILTER (WHERE status = 'error') as error_count,
SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) as error_count,
COUNT(DISTINCT user_id) as unique_users,
COALESCE(SUM(total_input_tokens), 0) as input_tokens,
COALESCE(SUM(total_output_tokens), 0) as output_tokens,
COALESCE(SUM(total_cost), 0) as total_cost,
COALESCE(SUM(tool_call_count), 0) as tool_call_count,
COALESCE(AVG(duration_ms), 0)::INTEGER as avg_duration_ms
CAST(COALESCE(AVG(duration_ms), 0) AS INTEGER) as avg_duration_ms
FROM traces
WHERE start_time >= $1 AND start_time < $2
AND parent_trace_id IS NULL
@@ -249,9 +249,9 @@ func querySpanAggregates(ctx context.Context, db *sql.DB, from, to time.Time) ([
COALESCE(SUM(s.input_tokens), 0) as span_input_tokens,
COALESCE(SUM(s.output_tokens), 0) as span_output_tokens,
COALESCE(SUM(s.total_cost), 0) as span_cost,
COALESCE(SUM((s.metadata->>'cache_read_tokens')::BIGINT), 0) as cache_read_tokens,
COALESCE(SUM((s.metadata->>'cache_creation_tokens')::BIGINT), 0) as cache_create_tokens,
COALESCE(SUM((s.metadata->>'thinking_tokens')::BIGINT), 0) as thinking_tokens
COALESCE(SUM(CAST(s.metadata->>'cache_read_tokens' AS INTEGER)), 0) as cache_read_tokens,
COALESCE(SUM(CAST(s.metadata->>'cache_creation_tokens' AS INTEGER)), 0) as cache_create_tokens,
COALESCE(SUM(CAST(s.metadata->>'thinking_tokens' AS INTEGER)), 0) as thinking_tokens
FROM traces t
JOIN spans s ON s.trace_id = t.id AND s.span_type = 'llm_call'
WHERE t.start_time >= $1 AND t.start_time < $2
+158
View File
@@ -0,0 +1,158 @@
package tracing
import (
"encoding/json"
"fmt"
"strings"
"unicode/utf8"
)
const truncateMarker = "\n\n... [truncated %d chars] ...\n\n"
// TruncateMid truncates s by removing the middle portion, keeping head + tail.
// Returns s unchanged if it fits within maxLen.
func TruncateMid(s string, maxLen int) string {
s = strings.ToValidUTF8(s, "")
if len(s) <= maxLen {
return s
}
marker := fmt.Sprintf(truncateMarker, len(s)-maxLen)
usable := maxLen - len(marker)
if usable <= 0 {
return s[len(s)-maxLen:]
}
head := usable * 2 / 3 // 2/3 head, 1/3 tail
tail := usable - head
// Align to rune boundaries.
for head > 0 && !utf8.RuneStart(s[head]) {
head--
}
tailStart := len(s) - tail
for tailStart < len(s) && !utf8.RuneStart(s[tailStart]) {
tailStart++
}
return s[:head] + marker + s[tailStart:]
}
// TruncateJSON truncates a JSON string by removing middle array elements,
// preserving valid JSON structure. Falls back to TruncateMid if the input
// is not a JSON array.
func TruncateJSON(s string, maxLen int) string {
s = strings.ToValidUTF8(s, "")
if len(s) <= maxLen {
return s
}
// Only handle top-level JSON arrays (the common case: messages array).
var arr []json.RawMessage
if err := json.Unmarshal([]byte(s), &arr); err != nil || len(arr) < 3 {
return TruncateMid(s, maxLen)
}
// Pre-compute each element's serialized length to avoid repeated string builds.
elemLens := make([]int, len(arr))
for i, raw := range arr {
elemLens[i] = len(raw)
}
// Find max elements to keep via binary search on total size.
// We keep `keep` elements split as ceil(keep/2) head + floor(keep/2) tail.
maxKeep := len(arr) - 1 // must drop at least 1 to have a placeholder
bestKeep := 0
lo, hi := 2, maxKeep // minimum 2 (1 head + 1 tail)
if lo > hi {
lo = hi
}
for lo <= hi {
mid := (lo + hi) / 2
size := estimateArraySize(arr, elemLens, mid)
if size <= maxLen {
bestKeep = mid
lo = mid + 1
} else {
hi = mid - 1
}
}
// Even 2 elements too big — truncate individual elements.
if bestKeep < 2 {
return truncateArrayElements(arr, maxLen)
}
keepHead := (bestKeep + 1) / 2
keepTail := bestKeep - keepHead
return buildTruncatedArray(arr, keepHead, keepTail)
}
// estimateArraySize returns the byte length of a JSON array keeping `keep` elements
// (split head/tail) plus a placeholder, without building the actual string.
func estimateArraySize(arr []json.RawMessage, elemLens []int, keep int) int {
keepHead := (keep + 1) / 2
keepTail := keep - keepHead
dropped := len(arr) - keep
// placeholder: {"__truncated__":"N elements omitted"}
placeholderLen := 22 + digitCount(dropped) + 17 // key + number + " elements omitted"}
total := 2 // [ and ]
for i := 0; i < keepHead; i++ {
total += elemLens[i]
}
total += placeholderLen
for i := len(arr) - keepTail; i < len(arr); i++ {
total += elemLens[i]
}
total += keep // commas between elements (keep elements + 1 placeholder - 1)
return total
}
// digitCount returns the number of decimal digits in n.
func digitCount(n int) int {
if n <= 0 {
return 1
}
d := 0
for n > 0 {
d++
n /= 10
}
return d
}
// buildTruncatedArray constructs a JSON array string keeping head and tail elements,
// with a placeholder object in the middle showing how many elements were omitted.
func buildTruncatedArray(arr []json.RawMessage, keepHead, keepTail int) string {
dropped := len(arr) - keepHead - keepTail
placeholder := fmt.Sprintf(`{"__truncated__":"%d elements omitted"}`, dropped)
parts := make([]string, 0, keepHead+1+keepTail)
for i := 0; i < keepHead; i++ {
parts = append(parts, string(arr[i]))
}
parts = append(parts, placeholder)
for i := len(arr) - keepTail; i < len(arr); i++ {
parts = append(parts, string(arr[i]))
}
return "[" + strings.Join(parts, ",") + "]"
}
// truncateArrayElements keeps first + last element but truncates their content.
func truncateArrayElements(arr []json.RawMessage, maxLen int) string {
dropped := len(arr) - 2
placeholder := fmt.Sprintf(`{"__truncated__":"%d elements omitted"}`, dropped)
overhead := len("[,,]") + len(placeholder)
perElem := (maxLen - overhead) / 2
if perElem < 50 {
perElem = 50
}
first := TruncateMid(string(arr[0]), perElem)
last := TruncateMid(string(arr[len(arr)-1]), perElem)
return "[" + first + "," + placeholder + "," + last + "]"
}
+216
View File
@@ -0,0 +1,216 @@
//go:build sqliteonly
package main
import (
"context"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/nextlevelbuilder/goclaw/cmd"
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
// App holds Wails application state and embedded gateway lifecycle.
type App struct {
ctx context.Context
cancelGw context.CancelFunc
gatewayToken string
gatewayPort int
}
// NewApp creates a new App instance with default port.
func NewApp() *App {
return &App{
gatewayPort: 18790,
}
}
// startup is called by Wails when the application starts.
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
// Resolve port from env or use default.
if p := os.Getenv("GOCLAW_PORT"); p != "" {
fmt.Sscanf(p, "%d", &a.gatewayPort)
}
// Ensure secrets (encryption key + gateway token) via OS keyring or file fallback.
encKey, gwToken, err := EnsureSecrets()
if err != nil {
slog.Error("failed to setup secrets", "error", err)
return
}
a.gatewayToken = gwToken
// Set env vars consumed by the embedded gateway.
os.Setenv("GOCLAW_ENCRYPTION_KEY", encKey)
os.Setenv("GOCLAW_GATEWAY_TOKEN", gwToken)
os.Setenv("GOCLAW_STORAGE_BACKEND", "sqlite")
os.Setenv("GOCLAW_DESKTOP", "1")
// Bind to localhost only — desktop has no reason to expose on LAN.
if os.Getenv("GOCLAW_HOST") == "" {
os.Setenv("GOCLAW_HOST", "127.0.0.1")
}
slog.Info("desktop secrets configured", "token_len", len(gwToken), "token_prefix", gwToken[:min(8, len(gwToken))])
// Ensure data directory exists.
dataDir := os.Getenv("GOCLAW_DATA_DIR")
if dataDir == "" {
home, _ := os.UserHomeDir()
dataDir = home + "/.goclaw/data"
os.Setenv("GOCLAW_DATA_DIR", dataDir)
}
os.MkdirAll(dataDir, 0755)
// Start gateway in background with a cancel context for graceful shutdown.
gwCtx, cancel := context.WithCancel(context.Background())
a.cancelGw = cancel
go func() {
// When context is cancelled, interrupt the process to trigger gateway shutdown.
go func() {
<-gwCtx.Done()
p, _ := os.FindProcess(os.Getpid())
p.Signal(os.Interrupt)
}()
slog.Info("starting embedded gateway", "port", a.gatewayPort)
cmd.RunGateway()
}()
a.waitForGateway()
}
// waitForGateway polls the health endpoint until the gateway is ready or times out.
func (a *App) waitForGateway() {
url := fmt.Sprintf("http://localhost:%d/health", a.gatewayPort)
for i := 0; i < 30; i++ {
resp, err := http.Get(url)
if err == nil && resp.StatusCode == 200 {
resp.Body.Close()
slog.Info("gateway ready", "port", a.gatewayPort)
return
}
if resp != nil {
resp.Body.Close()
}
time.Sleep(500 * time.Millisecond)
}
slog.Warn("gateway not responding after 15s")
}
// shutdown is called by Wails when the application is closing.
func (a *App) shutdown(ctx context.Context) {
if a.cancelGw != nil {
a.cancelGw()
}
}
// GetGatewayURL returns the base URL of the embedded gateway.
func (a *App) GetGatewayURL() string {
return fmt.Sprintf("http://localhost:%d", a.gatewayPort)
}
// GetGatewayToken returns the token for WebSocket authentication.
func (a *App) GetGatewayToken() string {
return a.gatewayToken
}
// GetGatewayPort returns the gateway port number.
func (a *App) GetGatewayPort() int {
return a.gatewayPort
}
// IsGatewayReady checks if the gateway health endpoint is responding.
func (a *App) IsGatewayReady() bool {
url := fmt.Sprintf("http://localhost:%d/health", a.gatewayPort)
resp, err := http.Get(url)
if err != nil {
return false
}
resp.Body.Close()
return resp.StatusCode == 200
}
// GetVersion returns the embedded gateway version string.
func (a *App) GetVersion() string {
return cmd.Version
}
// OpenFile opens a local file with the system's default application.
func (a *App) OpenFile(path string) error {
switch runtime.GOOS {
case "darwin":
return exec.Command("open", path).Start()
case "linux":
return exec.Command("xdg-open", path).Start()
default: // windows
return exec.Command("cmd", "/c", "start", "", path).Start()
}
}
// SaveFile opens a Save As dialog and copies the source file to the chosen location.
func (a *App) SaveFile(srcPath string) error {
dest, err := wailsRuntime.SaveFileDialog(a.ctx, wailsRuntime.SaveDialogOptions{
DefaultFilename: filepath.Base(srcPath),
Title: "Save File",
})
if err != nil || dest == "" {
return err
}
src, err := os.Open(srcPath)
if err != nil {
return err
}
defer src.Close()
dst, err := os.Create(dest)
if err != nil {
return err
}
defer dst.Close()
_, err = io.Copy(dst, src)
return err
}
// DownloadURL fetches a URL with Bearer auth and opens a Save As dialog.
func (a *App) DownloadURL(url, defaultFilename string) error {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return err
}
// Use gateway token for local API calls
if strings.Contains(url, "localhost") || strings.Contains(url, "127.0.0.1") {
req.Header.Set("Authorization", "Bearer "+a.gatewayToken)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("HTTP %d", resp.StatusCode)
}
dest, err := wailsRuntime.SaveFileDialog(a.ctx, wailsRuntime.SaveDialogOptions{
DefaultFilename: defaultFilename,
Title: "Save File",
})
if err != nil || dest == "" {
return err
}
f, err := os.Create(dest)
if err != nil {
return err
}
defer f.Close()
_, err = io.Copy(f, resp.Body)
return err
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

+68
View File
@@ -0,0 +1,68 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleName</key>
<string>{{.Info.ProductName}}</string>
<key>CFBundleExecutable</key>
<string>{{.OutputFilename}}</string>
<key>CFBundleIdentifier</key>
<string>com.wails.{{.Name}}</string>
<key>CFBundleVersion</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleGetInfoString</key>
<string>{{.Info.Comments}}</string>
<key>CFBundleShortVersionString</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleIconFile</key>
<string>iconfile</string>
<key>LSMinimumSystemVersion</key>
<string>10.13.0</string>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSHumanReadableCopyright</key>
<string>{{.Info.Copyright}}</string>
{{if .Info.FileAssociations}}
<key>CFBundleDocumentTypes</key>
<array>
{{range .Info.FileAssociations}}
<dict>
<key>CFBundleTypeExtensions</key>
<array>
<string>{{.Ext}}</string>
</array>
<key>CFBundleTypeName</key>
<string>{{.Name}}</string>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
<key>CFBundleTypeIconFile</key>
<string>{{.IconName}}</string>
</dict>
{{end}}
</array>
{{end}}
{{if .Info.Protocols}}
<key>CFBundleURLTypes</key>
<array>
{{range .Info.Protocols}}
<dict>
<key>CFBundleURLName</key>
<string>com.wails.{{.Scheme}}</string>
<key>CFBundleURLSchemes</key>
<array>
<string>{{.Scheme}}</string>
</array>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
</dict>
{{end}}
</array>
{{end}}
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
</dict>
</plist>
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
{"onlyBuiltDependencies":["esbuild"]}
+74
View File
@@ -0,0 +1,74 @@
<svg width="718" height="480" viewBox="0 0 718 480" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2002_138)">
<path d="M475.975 111.14C479.22 108.995 482.125 107.284 485.505 105.343C494.22 140.39 496.545 174.152 483.055 208.656C477.735 222.262 471.185 233.521 465.68 246.319C473.555 260.847 480.845 275.458 490.435 289.009C492.05 291.291 500.855 302.539 501.4 304.235C500.21 304.54 499.38 304.82 498.14 304.85C492.925 305.455 487.71 306.015 482.49 306.535C489.165 317.605 495.08 326.745 502.305 337.49L503.29 338.78C502.99 338.91 502.705 339.105 502.38 339.145C498.995 342.1 475.905 352.86 469.955 356.015C446.68 368.37 430.11 378.295 409.91 394.9C409.08 395.73 403.185 401.9 402.89 402.05C402.735 400.975 402.965 400.88 403.325 399.83L402.92 399.505L400.525 400.49C398.615 403.055 396.885 407.465 395.53 410.515C393.89 404.415 394.78 392.36 391.3 387.51C387.425 393.09 383.415 398.565 379.26 403.94C377.88 405.9 376.56 407.9 375.295 409.935C373.905 408.695 372.65 407.735 371.17 406.595C365.925 406.235 358.415 405.48 353.435 406.435C350.165 406.575 345.404 406.69 343.047 409.185C341.637 407.735 341.384 407.225 340.31 405.47C335.67 399.565 331.109 393.6 326.628 387.57C323.986 392.97 323.355 404.335 322.883 410.61C321.285 407.76 319.351 403.11 317.784 399.91L312.706 394.09C311.019 395.015 311.11 395.2 310.295 396.915C290.334 379.59 272.747 368.9 249.053 356.57C239.038 351.36 224.767 345.095 215.441 339.68L214.873 339.345L214.593 338.51C222.06 327.945 229.275 317.2 236.235 306.29C230.543 306.06 225.064 305.675 219.438 304.77C217.635 304.42 217.579 304.75 216.815 303.65C231.937 284.12 238.287 273.55 249.888 251.212L252.646 246.608C247.281 234.241 240.568 223.097 235.317 209.993C221.694 175.992 223.402 140.277 232.644 105.53L237.439 108.304C262.959 122.947 292.157 143.265 311.9 165.499C317.746 172.083 322.162 180.227 327.393 187.017C349.08 186.699 369.595 187.143 391.025 187.081C395.85 179.083 404.47 166.614 411.075 159.903C425.385 145.359 458.04 119.627 475.975 111.14Z" fill="#F08223"/>
<path d="M249.888 251.211C251.835 250.214 253.172 249.412 255.36 250.387C270.46 257.119 284.97 265.137 300.152 271.747C304.538 273.656 310.84 276.006 314.378 279.05L314.531 279.461C318.081 280.559 325.666 283.327 329.094 284.895C330.719 286.329 333.366 290.049 335.057 291.98C343.514 301.633 351.07 313.295 359.44 322.825C366.445 313.69 373.515 304.37 380.815 295.483C382.3 293.674 386.195 288.894 387.81 287.395C387.35 292.673 381.975 307.9 383.33 310.125L383.875 309.675C386.765 309.525 390 309.14 392.935 308.895L393.66 308.825C397.965 307.925 405.28 304.495 409.11 306.775L410.7 306.315C417.56 304.28 421.82 304.295 428.905 304.01L429.51 303.01C430.935 302.198 433.095 302.38 434.83 302.353C438.73 303.125 442.8 303.945 446.555 305.29L447.405 305.83C452.245 304.96 478.275 305.085 482.49 306.535C489.165 317.605 495.08 326.745 502.305 337.49L503.29 338.78C502.99 338.91 502.705 339.105 502.38 339.145C498.995 342.1 475.905 352.86 469.955 356.015C446.68 368.37 430.11 378.295 409.91 394.9C409.08 395.73 403.185 401.9 402.89 402.05C402.735 400.975 402.965 400.88 403.325 399.83L402.92 399.505L400.525 400.49C398.615 403.055 396.885 407.465 395.53 410.515C393.89 404.415 394.78 392.36 391.3 387.51C387.425 393.09 383.415 398.565 379.26 403.94C377.88 405.9 376.56 407.9 375.295 409.935C373.905 408.695 372.65 407.735 371.17 406.595C365.925 406.235 358.415 405.48 353.435 406.435C350.165 406.575 345.404 406.69 343.047 409.185C341.637 407.735 341.384 407.225 340.31 405.47C335.67 399.565 331.109 393.6 326.628 387.57C323.986 392.97 323.355 404.335 322.883 410.61C321.285 407.76 319.351 403.11 317.784 399.91L312.706 394.09C311.019 395.015 311.11 395.2 310.295 396.915C290.334 379.59 272.747 368.9 249.053 356.57C239.038 351.36 224.767 345.095 215.441 339.68L214.873 339.345L214.593 338.51C222.06 327.945 229.275 317.2 236.235 306.29C230.543 306.06 225.064 305.675 219.438 304.77C217.635 304.42 217.579 304.75 216.815 303.65C231.937 284.119 238.287 273.549 249.888 251.211Z" fill="#DA4525"/>
<path d="M434.83 302.353C438.73 303.125 442.8 303.945 446.555 305.29L447.405 305.83C452.245 304.96 478.275 305.085 482.49 306.535C489.165 317.605 495.08 326.745 502.305 337.49L503.29 338.78C502.99 338.91 502.705 339.105 502.38 339.145C498.995 342.1 475.905 352.86 469.955 356.015C446.68 368.37 430.11 378.295 409.91 394.9C409.08 395.73 403.185 401.9 402.89 402.05C402.735 400.975 402.965 400.88 403.325 399.83L402.92 399.505L400.525 400.49C398.615 403.055 396.885 407.465 395.53 410.515C393.89 404.415 394.78 392.36 391.3 387.51L391.915 386.455C391.715 386.09 391.415 385.555 391.285 385.125C387.405 372.375 383.51 359.05 380.835 346.01C380.65 345.115 381.09 344.14 381.445 343.42L382.22 343.615L382.225 343.04C384.98 341.915 386.945 337.445 389.505 334.9C390.545 333.34 391.54 331.825 392.65 330.32C398.095 320.98 399.355 312.075 409.11 306.775L410.7 306.315C417.56 304.28 421.82 304.295 428.905 304.01L429.51 303.01C430.935 302.199 433.095 302.38 434.83 302.353Z" fill="#F4972E"/>
<path d="M446.265 336.37C462.965 336.705 479.35 338.475 496.13 338.66C497.595 338.86 498.825 339.04 500.315 339.105L502.38 339.145C498.995 342.1 475.905 352.86 469.955 356.015C446.68 368.37 430.11 378.295 409.91 394.9C409.08 395.73 403.185 401.9 402.89 402.05C402.735 400.975 402.965 400.88 403.325 399.83L402.92 399.505L400.525 400.49C398.615 403.055 396.885 407.465 395.53 410.515C393.89 404.415 394.78 392.36 391.3 387.51L391.915 386.455C394.31 381.31 396.755 375.905 399.435 370.925C400.44 367.285 440.07 340.3 446.265 336.37Z" fill="white"/>
<path d="M500.315 339.105L502.38 339.145C498.995 342.1 475.905 352.86 469.955 356.015C446.68 368.37 430.11 378.295 409.91 394.9C408.84 394.365 407.62 393.95 406.49 393.52C413.69 383.865 445.835 349.87 455.93 347.36C465.845 344.89 476.66 343.405 486.77 341.93C491.05 341.305 495.905 341.175 500.015 339.885L500.315 339.105Z" fill="#FACA71"/>
<path d="M391.915 386.455C394.31 381.31 396.755 375.905 399.435 370.925C399.98 375.735 400.255 380.575 400.26 385.42C400.27 387.73 399.935 398.565 400.32 400.06L400.525 400.49C398.615 403.055 396.885 407.465 395.53 410.515C393.89 404.415 394.78 392.36 391.3 387.51L391.915 386.455Z" fill="#FACA71"/>
<path d="M406.49 393.52C407.62 393.95 408.84 394.365 409.91 394.9C409.08 395.73 403.185 401.9 402.89 402.05C402.735 400.975 402.965 400.88 403.325 399.83L402.92 399.505L400.525 400.49L400.32 400.06L406.49 393.52Z" fill="#F08223"/>
<path d="M389.505 334.9C393 337.065 417.915 332.855 421.125 334.505L420.16 336.995L420.67 337.15L422.305 334.91C426.325 333.77 453.35 326.36 455.235 327.165C452.62 329.84 448.625 333.655 446.265 336.37C440.07 340.3 400.44 367.285 399.435 370.925C396.755 375.905 394.31 381.31 391.915 386.455C391.715 386.09 391.415 385.555 391.285 385.125C387.405 372.375 383.51 359.05 380.835 346.01C380.65 345.115 381.09 344.14 381.445 343.42L382.22 343.615L382.225 343.04C384.98 341.915 386.945 337.445 389.505 334.9Z" fill="#F08223"/>
<path d="M446.555 305.29L447.405 305.83C452.245 304.96 478.275 305.085 482.49 306.535C489.165 317.605 495.08 326.745 502.305 337.49L503.29 338.78C502.99 338.91 502.705 339.105 502.38 339.145L500.315 339.105C498.825 339.04 497.595 338.86 496.13 338.66C494.09 335.885 462.545 326.815 457.36 325.215C452.42 319.68 448.105 313.665 443.7 307.7L446.555 305.29Z" fill="#F69723"/>
<path d="M446.555 305.29L447.405 305.83C454.18 309.655 499.56 337.28 502.305 337.49L503.29 338.78C502.99 338.91 502.705 339.105 502.38 339.145L500.315 339.105C498.825 339.04 497.595 338.86 496.13 338.66C494.09 335.885 462.545 326.815 457.36 325.215C452.42 319.68 448.105 313.665 443.7 307.7L446.555 305.29Z" fill="#EC6D25"/>
<path d="M443.7 307.7C448.105 313.665 452.42 319.68 457.36 325.215C462.545 326.815 494.09 335.885 496.13 338.66C479.35 338.475 462.965 336.705 446.265 336.37C448.625 333.655 452.62 329.84 455.235 327.165L456.005 326.755L456.1 326.12C453.035 325.54 426.825 332.905 421.63 334.265C426.095 327.89 437.16 315.91 440.08 310.76C441.3 309.755 442.51 308.735 443.7 307.7Z" fill="#F69723"/>
<path d="M409.11 306.775L410.7 306.315C417.56 304.28 421.82 304.295 428.905 304.01C425.445 312.065 424.365 315.4 417.625 321.425C408.89 326.975 402.5 325.235 393.965 329.86C393.13 330.315 393.5 330.19 392.65 330.32C398.095 320.98 399.355 312.075 409.11 306.775Z" fill="white"/>
<path d="M434.83 302.353C438.73 303.125 442.8 303.945 446.555 305.29L443.7 307.7C442.51 308.735 441.3 309.755 440.08 310.76C434.505 313.14 421.24 322.815 417.625 321.425C424.365 315.4 425.445 312.065 428.905 304.01L429.51 303.01C430.935 302.199 433.095 302.38 434.83 302.353Z" fill="#DA4525"/>
<path d="M314.531 279.461C318.081 280.559 325.666 283.327 329.094 284.895C330.719 286.329 333.366 290.05 335.057 291.98C343.514 301.634 351.07 313.295 359.44 322.825C366.445 313.69 373.515 304.37 380.815 295.483C382.3 293.675 386.195 288.894 387.81 287.395C387.35 292.673 381.975 307.9 383.33 310.125L383.875 309.675C386.765 309.525 390 309.14 392.935 308.895L393.66 308.825C397.965 307.925 405.28 304.495 409.11 306.775C399.355 312.075 398.095 320.98 392.65 330.32C391.54 331.825 390.545 333.34 389.505 334.9C386.945 337.445 384.98 341.915 382.225 343.04L382.22 343.615L381.445 343.42C381.09 344.14 380.65 345.115 380.835 346.01C383.51 359.05 387.405 372.375 391.285 385.125C391.415 385.555 391.715 386.09 391.915 386.455L391.3 387.51C387.425 393.09 383.415 398.565 379.26 403.94C377.88 405.9 376.56 407.9 375.295 409.935C373.905 408.695 372.65 407.735 371.17 406.595C365.925 406.235 358.415 405.48 353.435 406.435C350.165 406.575 345.404 406.69 343.047 409.185C341.637 407.735 341.384 407.225 340.31 405.47C342.142 402.15 341.352 380.05 341.785 374.79C343.747 350.935 344.124 326.33 331.977 304.81C327.03 296.049 320.094 288.237 314.776 279.853L314.531 279.461Z" fill="#F4972E"/>
<path d="M393.66 308.825C397.965 307.925 405.28 304.495 409.11 306.775C399.355 312.075 398.095 320.98 392.65 330.32C391.54 331.825 390.545 333.34 389.505 334.9C386.945 337.445 384.98 341.915 382.225 343.04L382.22 343.615L381.445 343.42C381.09 344.14 380.65 345.115 380.835 346.01C383.51 359.05 387.405 372.375 391.285 385.125C391.415 385.555 391.715 386.09 391.915 386.455L391.3 387.51C387.425 393.09 383.415 398.565 379.26 403.94C377.84 402.61 377.385 402.05 377.28 399.92C376.4 382.1 375.52 363.255 375.31 345.51C375.25 340.65 380.975 313.44 383.33 310.125L383.875 309.675C386.765 309.525 390 309.14 392.935 308.895L393.66 308.825Z" fill="#F69723"/>
<path d="M393.66 308.825C397.965 307.925 405.28 304.495 409.11 306.775C399.355 312.075 398.095 320.98 392.65 330.32C391.54 331.825 390.545 333.34 389.505 334.9C386.945 337.445 384.98 341.915 382.225 343.04L382.22 343.615L381.445 343.42C382.455 337.06 390.25 315.39 392.935 308.895L393.66 308.825Z" fill="#DA4525"/>
<path d="M249.887 251.211C251.835 250.214 253.171 249.412 255.36 250.387C270.46 257.119 284.969 265.137 300.152 271.747C304.538 273.656 310.84 276.006 314.377 279.05L314.53 279.461L314.775 279.853C320.093 288.237 327.03 296.049 331.976 304.81C344.123 326.33 343.747 350.935 341.784 374.79C341.351 380.05 342.142 402.15 340.31 405.47C335.67 399.565 331.109 393.6 326.627 387.57L326.236 386.595C324.674 384.21 318.966 372.275 318.379 369.495C321.8 364.125 328.789 344.45 330.96 337.87C328.616 334.9 327.292 333.125 325.309 329.925C321.013 322.865 317.299 310.865 311.052 307.7C314.105 306.01 319.586 308.205 323.587 309.05C309.233 290.833 288.257 288.808 266.548 286.637C259.493 285.931 246.877 293.012 239.771 295.59C234.71 297.426 223.085 301.584 219.437 304.77C217.634 304.42 217.578 304.75 216.814 303.65C231.937 284.119 238.286 273.549 249.887 251.211Z" fill="#E75F21"/>
<path d="M330.96 337.87C332.604 339.94 333.83 342.88 335.655 344.665L336.784 344.075C337.078 345.18 337.36 346.695 337.08 347.785C335.164 355.26 329.211 381.145 326.237 386.595C324.674 384.21 318.966 372.275 318.379 369.495C321.8 364.125 328.789 344.45 330.96 337.87Z" fill="#DA4525"/>
<path d="M311.052 307.7C314.105 306.01 319.586 308.205 323.587 309.05C327.743 311.415 335.05 338.29 336.784 344.075L335.655 344.665C333.829 342.88 332.604 339.94 330.96 337.87C328.616 334.9 327.292 333.125 325.309 329.925C321.013 322.865 317.299 310.865 311.052 307.7Z" fill="#DA4525"/>
<path d="M268.114 336.32C268.887 336.33 270.77 336.415 271.335 336.78C284.292 345.12 307.051 359.89 318.097 369.395L318.379 369.495C318.966 372.275 324.674 384.21 326.237 386.595L326.628 387.57C323.986 392.97 323.355 404.335 322.883 410.61C321.285 407.76 319.351 403.11 317.784 399.91L312.706 394.09C311.019 395.015 311.11 395.2 310.295 396.915C290.334 379.59 272.747 368.9 249.053 356.57C239.038 351.36 224.767 345.095 215.441 339.68L214.873 339.345C216.817 338.915 219.08 339.04 221.099 339.045C236.778 338.265 252.45 337.355 268.114 336.32Z" fill="white"/>
<path d="M214.873 339.345C216.817 338.915 219.08 339.04 221.099 339.045L218.947 339.795C219.863 340.57 219.246 340.26 220.501 340.405C235.123 342.075 249.675 345.575 264.308 347.335C277.918 358.375 290.889 369.745 302.72 382.69C305.29 385.505 310.663 391.055 312.706 394.09C311.019 395.015 311.11 395.2 310.295 396.915C290.334 379.59 272.747 368.9 249.053 356.57C239.038 351.36 224.767 345.095 215.441 339.68L214.873 339.345Z" fill="#F08223"/>
<path d="M318.097 369.395L318.379 369.495C318.966 372.275 324.674 384.21 326.237 386.595L326.628 387.57C323.986 392.97 323.355 404.335 322.883 410.61C321.285 407.76 319.351 403.11 317.784 399.91C317.969 398.145 317.684 390.63 317.676 388.3C317.682 381.995 317.823 375.695 318.097 369.395Z" fill="#F08223"/>
<path d="M268.113 336.32L266.913 335.82L267.786 335.11C267.128 331.325 261.513 329.03 259.178 325.915C261.93 325.08 272.277 311.12 275.076 307.855C283.457 311.925 290.08 317.29 298.126 321.73C299.054 322.24 299.62 321.74 300.413 321.33C308.443 326.055 315.108 325.51 323.212 329.145C324.045 329.52 324.41 329.795 325.309 329.925C327.292 333.125 328.616 334.9 330.96 337.87C328.789 344.45 321.8 364.125 318.379 369.495L318.097 369.395C307.051 359.89 284.292 345.12 271.334 336.78C270.77 336.415 268.886 336.33 268.113 336.32Z" fill="#E85922"/>
<path d="M287.781 303.575C297.485 304.045 301.879 304.265 311.053 307.7C317.3 310.865 321.014 322.865 325.309 329.925C324.411 329.795 324.046 329.52 323.212 329.145C315.109 325.51 308.444 326.055 300.413 321.33C294.081 315.365 292.334 311.07 287.781 303.575Z" fill="white"/>
<path d="M475.975 111.14C479.22 108.995 482.125 107.284 485.505 105.343C494.22 140.39 496.545 174.152 483.055 208.656C477.735 222.262 471.185 233.521 465.68 246.319C473.555 260.847 480.845 275.458 490.435 289.009C492.05 291.291 500.855 302.539 501.4 304.235C500.21 304.54 499.38 304.82 498.14 304.85C492.925 305.455 487.71 306.015 482.49 306.535C478.275 305.085 452.245 304.96 447.405 305.83L446.555 305.29C442.8 303.945 438.73 303.125 434.83 302.354C433.095 302.38 430.935 302.199 429.51 303.01L428.905 304.01C421.82 304.295 417.56 304.28 410.7 306.315L409.11 306.775C405.28 304.495 397.965 307.925 393.66 308.825L392.935 308.895C390 309.14 386.765 309.525 383.875 309.675L383.33 310.125C381.975 307.9 387.35 292.674 387.81 287.395L388.675 285.191C390.86 276.813 392.71 265.406 394.29 256.695C396.565 243.899 399.02 231.136 401.65 218.408C402.865 212.441 404.09 205.534 405.76 199.734C408.57 201.681 411.785 204.312 414.53 206.447C419.4 193.235 427.11 181.252 434.905 169.619C429.295 171.723 414.485 204.218 413.525 204.599C411.235 202.926 408.93 201.274 406.61 199.641C410.015 189.8 416.955 178.363 422.98 169.762C425.32 166.43 429.985 162.012 431.42 158.457C421.49 167.084 410.295 187.99 405.56 200.062C402.89 197.768 392.865 189.414 391.025 187.081C395.85 179.083 404.47 166.614 411.075 159.903C425.385 145.359 458.04 119.627 475.975 111.14Z" fill="#F7A32A"/>
<path d="M434.905 169.618C437.66 167.012 443.08 159.28 446.455 155.702C457.205 144.317 468.31 132.77 480.515 122.928L481.275 123.109L482.24 123.35C483.5 126.867 484.245 135.769 484.56 139.633C486.62 165.019 481.185 184.667 470.58 207.316C465.555 218.054 459.865 226.927 454.66 237.048L454.88 237.673L454.345 237.792C441.635 228.163 427.165 215.247 414.53 206.446C419.4 193.235 427.11 181.252 434.905 169.618Z" fill="#E86123"/>
<path d="M434.905 169.618C437.66 167.012 443.08 159.28 446.455 155.702C457.205 144.317 468.31 132.77 480.515 122.928L481.275 123.109C467.74 141.984 449.24 167.332 438.465 187.451C445.905 185.56 453.31 183.772 460.595 181.335C453.93 188.829 445.545 198.546 439.515 206.538C444.51 207.501 451.155 209.118 455.92 209.873C450.755 213.598 445.955 217.047 441 221.061C445.38 226.021 450.615 231.842 454.66 237.048L454.88 237.673L454.345 237.792C441.635 228.163 427.165 215.247 414.53 206.446C419.4 193.235 427.11 181.252 434.905 169.618Z" fill="white"/>
<path d="M475.975 111.14C477.15 110.878 478.335 110.631 479.52 110.398C479.195 111.261 479.29 111.554 478.475 112.028C472.55 115.474 434.835 152.825 431.42 158.457C421.49 167.084 410.295 187.99 405.56 200.062C402.89 197.768 392.865 189.413 391.025 187.081C395.85 179.082 404.47 166.614 411.075 159.902C425.385 145.359 458.04 119.627 475.975 111.14Z" fill="#EC6D25"/>
<path d="M453.14 286.783C460.12 285.369 483.665 298.451 491.255 300.772C492.935 301.286 496.96 303.47 498.14 304.85C492.925 305.455 487.71 306.015 482.49 306.535C478.275 305.085 452.245 304.96 447.405 305.83L446.555 305.29C442.8 303.945 438.73 303.125 434.83 302.353C437.745 297.95 452.2 291.317 454.275 287.336L453.14 286.783Z" fill="#F08223"/>
<path d="M393.66 308.825C394.06 308.075 394.18 307.74 394.745 307.115C409.915 290.309 432.69 288.779 453.14 286.783L454.275 287.336C452.2 291.317 437.745 297.95 434.83 302.353C433.095 302.38 430.935 302.198 429.51 303.01L428.905 304.01C421.82 304.295 417.56 304.28 410.7 306.315L409.11 306.775C405.28 304.495 397.965 307.925 393.66 308.825Z" fill="#EC6D25"/>
<path d="M388.675 285.191L406.465 278.65C399.885 284.628 386.8 301.284 383.875 309.675L383.33 310.125C381.975 307.9 387.35 292.674 387.81 287.395L388.675 285.191Z" fill="#FACA71"/>
<path d="M232.644 105.53L237.438 108.304C240.228 113.372 250.138 121.211 254.514 125.613C278.743 149.989 299.494 166.835 312.917 199.525C311.366 200.7 304.995 205.971 303.838 206.04C300.248 209.239 264.434 237.583 262.412 238.139C259.443 240.534 255.073 244.83 252.646 246.608C247.28 234.241 240.567 223.097 235.317 209.993C221.693 175.992 223.401 140.277 232.644 105.53Z" fill="#DA4525"/>
<path d="M232.644 105.53L237.438 108.304C240.228 113.372 250.138 121.211 254.514 125.613C278.743 149.989 299.494 166.835 312.917 199.525C311.366 200.7 304.995 205.971 303.838 206.04C292.202 176.579 269.233 151.382 246.013 130.29C243.454 127.965 240.506 125.001 237.649 123.202L236.68 123.214C233.512 127.179 233.319 157.573 234.522 167.705C237.069 189.174 251.852 217.614 262.359 237.084C262.468 237.162 262.578 237.239 262.687 237.317L262.412 238.139C259.443 240.534 255.073 244.83 252.646 246.608C247.28 234.241 240.567 223.097 235.317 209.993C221.693 175.992 223.401 140.277 232.644 105.53Z" fill="#E75F21"/>
<path d="M237.649 123.202C240.506 125.001 243.454 127.965 246.013 130.29C269.233 151.382 292.202 176.579 303.838 206.04C300.248 209.239 264.434 237.583 262.412 238.139L262.687 237.317C267.458 232.339 272.612 226.212 277.221 220.982C272.507 217.272 267.82 213.514 262.958 210.002C268.747 208.621 273.289 207.607 279.187 206.655C272.583 198.399 265.111 189.932 258.254 181.779C264.544 183.94 273.329 186.081 280.072 188.117C265.558 163.876 253.103 146.274 237.649 123.202Z" fill="white"/>
<path d="M312.917 199.525C313.842 208.746 329.501 283.62 329.093 284.895C325.665 283.328 318.08 280.56 314.53 279.462L314.377 279.051C310.84 276.007 304.538 273.657 300.152 271.747C284.969 265.137 270.46 257.12 255.36 250.388C253.171 249.412 251.835 250.214 249.887 251.212L252.646 246.608C255.073 244.83 259.443 240.534 262.412 238.139C264.434 237.583 300.248 209.239 303.838 206.04C304.995 205.971 311.366 200.7 312.917 199.525Z" fill="#EC6D25"/>
<path d="M621.56 250.225C649.19 241.166 690.17 245.625 717.205 256.44C709.7 259.501 703.805 261.849 696.72 265.928C667.78 282.589 653.15 309.35 637.985 337.57C635.265 342.625 626.38 359.54 623.34 363.51C621.565 367.67 618.1 372.775 615.505 376.62C603.8 393.985 590.755 407.065 573.195 418.535C558.59 427.585 542.505 433.99 525.68 437.455C474.11 448.47 409.665 436.945 367.58 474.325L362.02 479.92C364.675 471.09 369.22 460.1 373.15 451.81C385.24 447.205 396.37 434.76 402.66 423.43C404.7 419.76 406.64 409.95 409.285 407C421.92 392.885 440.665 380.49 458.075 373.04C463.07 370.96 472.755 369.86 478.055 368.64C491.315 365.58 503.22 362.38 515.38 356.08C554.775 335.245 570.47 308.165 583.605 268.238C594.21 261.405 609.565 253.776 621.56 250.225Z" fill="#E75F21"/>
<path d="M621.56 250.225C649.19 241.166 690.17 245.625 717.205 256.44C709.7 259.501 703.805 261.849 696.72 265.928C667.78 282.589 653.15 309.35 637.985 337.57C635.265 342.625 626.38 359.54 623.34 363.51C621.565 367.67 618.1 372.775 615.505 376.62C603.8 393.985 590.755 407.065 573.195 418.535C571.44 414.48 545.975 385.205 542.01 381.33C545.73 379.58 548.56 376.67 551.55 373.895C553.47 369.34 558.85 361.395 561.64 356.86C565.865 349.91 569.865 342.82 573.625 335.605C579.675 324.1 585.435 312.17 590.735 300.303C593.075 295.052 595.375 289.951 598.375 285.025L598.865 284.239L598.58 283.248L596.69 282.524C595.035 283.259 592.67 284.079 590.925 284.746C598.69 274.913 607.395 265.636 616.01 256.539C617.96 254.482 619.98 252.581 621.56 250.225Z" fill="#F69723"/>
<path d="M621.56 250.225C649.19 241.166 690.17 245.625 717.205 256.44C709.7 259.501 703.805 261.849 696.72 265.928C667.78 282.589 653.15 309.35 637.985 337.57C635.265 342.625 626.38 359.54 623.34 363.51C623.565 360.315 625.67 352.15 626.47 348.71L634.525 314.865C623.65 320.42 611.72 325.83 600.605 331.08C607.7 312.03 616.01 292.932 623.54 273.98C621.255 274.578 619.18 275.355 616.955 276.14C611.51 277.698 601.165 281.561 596.69 282.524C595.035 283.259 592.67 284.079 590.925 284.746C598.69 274.913 607.395 265.636 616.01 256.539C617.96 254.482 619.98 252.581 621.56 250.225Z" fill="#FACA71"/>
<path d="M616.955 276.139C618.865 276.349 619.025 276.37 620.78 275.62L621.765 275.983C621.55 276.499 620.36 279.537 620.27 279.703C618.645 282.815 616.2 286.393 614.46 289.437C602.325 310.605 571.635 360.585 551.55 373.895C553.47 369.34 558.85 361.395 561.64 356.86C565.865 349.91 569.865 342.82 573.625 335.605C579.675 324.1 585.435 312.17 590.735 300.302C593.075 295.051 595.375 289.951 598.375 285.024L598.865 284.238L598.58 283.248L596.69 282.524C601.165 281.56 611.51 277.697 616.955 276.139Z" fill="#F08223"/>
<path d="M367.58 474.325C369.175 468.785 376.94 462.325 380.92 458.04C402.63 434.675 432.38 421.595 462.36 412.475C487.43 404.85 512.63 399.29 535.565 386.03C537.88 384.695 539.795 382.84 542.01 381.33C545.975 385.205 571.44 414.48 573.195 418.535C558.59 427.585 542.505 433.99 525.68 437.455C474.11 448.47 409.665 436.945 367.58 474.325Z" fill="#EC6D25"/>
<path d="M0.801025 256.56C25.0185 247.36 54.1575 243.004 79.764 246.599C84.7195 247.295 91.8315 248.471 96.5195 250.184C107.353 252.657 125.73 261.55 134.908 268.389C151.273 321.035 182.319 357.225 239.866 368.71C259.977 372.725 273.389 377.2 289.764 390.215C305.109 401.825 309.611 404.04 315.084 422.56C317.848 431.91 335.17 447.435 344.74 451.765C348.92 460.1 353.64 470.705 356.47 479.625C353.74 476.995 350.935 474.185 348 471.815C341.661 467.07 335.043 462.49 327.807 459.22C279.619 437.72 224.65 448.955 175.055 432.73C142.825 422.185 109.913 394.42 94.6255 363.56C93.1065 361.485 91.6015 358.645 90.351 356.335C72.476 323.335 56.065 285.647 22.0795 265.999C15.0615 261.942 8.32553 259.394 0.801025 256.56Z" fill="#E25424"/>
<path d="M96.5196 250.183C107.353 252.656 125.73 261.55 134.908 268.389C151.273 321.035 182.319 357.225 239.866 368.71C259.977 372.725 273.389 377.2 289.764 390.215C305.109 401.825 309.611 404.04 315.084 422.56C317.848 431.91 335.17 447.435 344.74 451.765C348.92 460.1 353.64 470.705 356.47 479.625C353.74 476.995 350.935 474.185 348 471.815C346.347 465.985 337.908 458.67 333.511 454.355C300.489 421.36 253.876 412.4 211.091 398.675C161.126 382.655 136.715 345.09 111.239 302.361C107.851 296.679 100.658 280.062 96.1736 276.477C95.9266 276.2 95.6466 275.431 95.4761 275.041C98.1241 274.429 122.162 282.64 126.602 284.128C121.639 277.875 116.417 271.832 110.95 266.014C107.991 262.893 98.3261 253.311 96.5196 250.183Z" fill="#DA4525"/>
<path d="M0.801025 256.56C25.0185 247.36 54.1575 243.004 79.764 246.599C84.7195 247.295 91.8315 248.471 96.5195 250.184C98.326 253.311 107.991 262.893 110.95 266.015C116.417 271.833 121.639 277.876 126.602 284.128C122.162 282.641 98.124 274.43 95.476 275.041C95.6465 275.431 95.9265 276.201 96.1735 276.477C98.524 284.568 102.091 292.191 105.352 299.903C109.836 310.48 114.246 321.085 118.583 331.725C112.441 327.625 90.966 318.075 83.428 315.2C85.304 322.65 95.0385 358.885 94.6255 363.56C93.1065 361.485 91.6015 358.645 90.351 356.335C72.476 323.335 56.065 285.647 22.0795 265.999C15.0615 261.942 8.32553 259.394 0.801025 256.56Z" fill="#EC6D25"/>
<path d="M36.5449 144.41C39.9939 142.362 43.5309 140.466 47.1459 138.728C71.5904 126.965 101.828 120.272 128.784 122.526C133.934 122.956 142.546 124.004 147.441 125.707C179.429 133.995 200.18 152.645 216.845 180.687C220.918 187.54 221.171 191.645 223.036 199.172C227.129 215.7 236.727 231.113 243.669 246.443C238.264 256.707 231.491 269.571 224.834 279.118C218.987 288.737 209.926 299.947 203.02 309.135C209.213 310.84 215.524 312.075 221.813 313.395C216.298 322.785 208.814 332.995 202.483 341.975C209.473 346.055 217.54 349.635 224.721 353.415C227.875 355.075 231.937 356.795 234.787 358.805C186.018 347.505 155.923 311.75 142.857 265.626C140.49 257.27 135.466 248.348 133.181 239.672C130.294 234.691 126.858 225.314 124.571 219.9C106.437 176.964 83.9889 152.799 36.5449 144.41Z" fill="#DA4525"/>
<path d="M147.441 125.708C179.429 133.996 200.18 152.646 216.845 180.688C220.918 187.54 221.171 191.646 223.036 199.172C227.129 215.701 236.727 231.114 243.669 246.444C238.264 256.708 231.491 269.571 224.834 279.119C219.383 273.408 210.999 265.446 206.282 259.418C189.485 237.952 176.496 211.879 163.029 188.069L149.288 164.041C148.577 162.77 142.818 152.392 142.458 152.068L141.922 153.256C140.917 151.391 139.8 148.753 138.889 146.775C152.344 148.295 166.196 150.78 179.569 153.061C173.076 147.624 166.488 142.3 159.809 137.094C156.534 134.519 149.974 129.652 147.218 126.9L147.441 125.708Z" fill="#E75F21"/>
<path d="M36.5449 144.41C39.9939 142.362 43.5309 140.466 47.1459 138.728C71.5904 126.965 101.828 120.272 128.784 122.526C133.934 122.956 142.546 124.004 147.441 125.707L147.218 126.899C149.974 129.651 156.534 134.519 159.809 137.093C166.488 142.3 173.076 147.623 179.569 153.061C166.196 150.779 152.344 148.294 138.889 146.775C139.8 148.752 140.917 151.39 141.922 153.255C148.71 169.117 156.304 188.254 163.529 203.623C158.986 200.799 151.773 197.847 146.733 195.59L119.912 183.552C121.851 190.84 134.038 235.869 133.181 239.672C130.294 234.691 126.858 225.314 124.571 219.9C106.437 176.964 83.9889 152.799 36.5449 144.41Z" fill="#ED8337"/>
<path d="M572.35 125.308C598.135 118.311 634.385 123.835 659.05 133.56C667.205 136.776 674.455 140.479 682.24 144.456C650.39 150.413 629.28 160.685 610.45 188.3C604.515 197.12 599.365 206.448 595.07 216.173C592.01 223.087 589.27 230.474 585.895 237.176C583.285 245.531 579.88 253.913 576.95 262.181C569.995 281.821 561.8 304.92 547.345 320.385C545.005 313.765 539.525 302.994 536.43 296.405C529.665 282.164 523.095 267.832 516.71 253.414C515.515 254.523 512.315 258.123 511.51 259.438C507.95 265.256 496.115 273.944 493.63 279.473C488.625 273.581 478.345 253.189 474.55 245.828C481.17 232.857 487.5 220.572 492.62 206.947C496.135 197.588 496.09 188.852 501.115 180.584C518.81 151.447 539.23 134.098 572.35 125.308Z" fill="#F4972E"/>
<path d="M572.35 125.308C571.9 126.426 541.25 151.291 538.465 153.402C551.965 150.871 565.5 148.554 579.07 146.452C577.7 149.784 575.985 154.186 574.265 157.334C571.2 160.404 569.28 162.849 567.125 166.646C554.035 189.76 540.79 212.81 527.875 236.027C526.28 238.898 517.22 251.854 516.71 253.413C515.515 254.522 512.315 258.123 511.51 259.438C507.95 265.255 496.115 273.944 493.63 279.473C488.625 273.58 478.345 253.189 474.55 245.828C481.17 232.857 487.5 220.571 492.62 206.947C496.135 197.587 496.09 188.852 501.115 180.583C518.81 151.447 539.23 134.098 572.35 125.308Z" fill="#F08223"/>
<path d="M572.35 125.308C598.135 118.311 634.385 123.835 659.05 133.56C667.205 136.776 674.455 140.479 682.24 144.456C650.39 150.413 629.28 160.685 610.45 188.3C604.515 197.12 599.365 206.448 595.07 216.173C592.01 223.087 589.27 230.474 585.895 237.176C585.96 233.281 588.37 224.496 589.285 220.119C591.865 207.775 594.93 195.414 597.98 183.173C584.925 189.586 568.445 197.745 555.145 203.218C561.26 188.217 568.495 172.179 574.265 157.335C575.985 154.187 577.7 149.784 579.07 146.453C565.5 148.555 551.965 150.872 538.465 153.403C541.25 151.292 571.9 126.426 572.35 125.308Z" fill="#FACA71"/>
<path d="M277.512 2.78054L275.877 1.43053L275.913 0.699528C278.475 0.131028 286.904 0.450029 289.772 0.663029C314.831 2.52053 340.798 9.97953 361.985 23.594C364.84 25.4275 372.805 30.894 375.17 33.285C394.42 48.914 406.245 70.157 410.285 94.5105C412.52 113.337 410.665 130.468 405.575 148.655C404.4 152.86 402.795 156.754 400.035 160.209C395.065 166.337 390.785 172.476 386.63 179.2C376.23 178.775 365.82 178.561 355.41 178.558L330.998 179.014C319.783 160.43 308.678 149.399 292.3 135.801C295.777 131.081 298.905 124.877 301.681 119.644C307.198 105.851 311.598 93.812 311.65 78.6955C311.762 46.352 299.06 25.0135 277.512 2.78054Z" fill="#E75F21"/>
<path d="M277.512 2.78054L275.877 1.43053L275.913 0.699528C278.475 0.131028 286.904 0.450029 289.772 0.663029C314.831 2.52053 340.798 9.97953 361.985 23.594C364.84 25.4275 372.805 30.894 375.17 33.285C375.615 36.569 391.19 64.5035 393.845 70.404C384.445 65.329 371.97 57.674 362.595 53.7195L362.61 68.776C362.32 77.4535 361.005 102.597 359.305 111.152C358.17 110.762 358.705 111.024 357.815 109.978C348.595 101.215 339.085 90.766 329.741 81.58C323.576 90.476 317.497 99.4315 311.506 108.446C310.513 109.957 306.463 115.948 305.891 117.308C304.136 118.823 303.897 118.982 301.681 119.644C307.198 105.851 311.598 93.812 311.65 78.6955C311.762 46.352 299.06 25.0135 277.512 2.78054Z" fill="#FACA71"/>
<path d="M277.512 2.78052C284.638 2.42802 307.217 19.9145 312.684 24.762C332.687 42.5 342.664 66.495 351.76 90.9685C353.605 95.937 357.215 105.145 357.815 109.978C348.595 101.215 339.085 90.766 329.741 81.58C323.576 90.476 317.497 99.4315 311.506 108.446C310.513 109.957 306.463 115.948 305.891 117.308C304.136 118.823 303.897 118.982 301.681 119.644C307.198 105.851 311.598 93.812 311.65 78.6955C311.762 46.352 299.06 25.0135 277.512 2.78052Z" fill="#F4972E"/>
<path d="M375.17 33.285C394.42 48.914 406.245 70.157 410.285 94.5105C412.52 113.337 410.665 130.468 405.575 148.655C404.4 152.86 402.795 156.754 400.035 160.209C395.065 166.337 390.785 172.476 386.63 179.2C376.23 178.775 365.82 178.561 355.41 178.558C356.92 172.869 365.44 163.225 364.575 159.044L364.835 158.695C368.895 132.116 371.065 103.652 367.4 76.9855C366.535 70.721 365.485 62.97 363.125 57.1175C363.105 60.4655 363.26 65.67 362.61 68.776L362.595 53.7195C371.97 57.674 384.445 65.329 393.845 70.404C391.19 64.5035 375.615 36.569 375.17 33.285Z" fill="#F08223"/>
<path d="M364.835 158.695L365.335 159.336C366.41 159.804 366.925 158.388 367.76 157.297C382.32 138.281 395.435 117.849 407.13 96.9625C408 95.4115 408.71 95.0415 410.285 94.5105C412.52 113.337 410.665 130.468 405.575 148.655C404.4 152.859 402.795 156.753 400.035 160.208C395.065 166.337 390.785 172.476 386.63 179.2C376.23 178.775 365.82 178.56 355.41 178.557C356.92 172.869 365.44 163.225 364.575 159.044L364.835 158.695Z" fill="#EC6D25"/>
<path d="M305.891 117.307C315.324 124.841 327.596 132.809 337.444 140.221C343.616 144.868 358.225 155.846 364.575 159.044C365.44 163.225 356.92 172.869 355.41 178.557L330.998 179.013C319.784 160.429 308.678 149.398 292.301 135.801C295.778 131.081 298.905 124.877 301.682 119.644C303.898 118.982 304.137 118.822 305.891 117.307Z" fill="#DA4525"/>
<path d="M391.3 387.51C394.78 392.36 393.89 404.415 395.53 410.515C396.885 407.465 398.615 403.055 400.525 400.49L402.92 399.505L403.325 399.83C402.965 400.88 402.735 400.975 402.89 402.05L402.535 402.7C399.655 408.015 398.105 413.93 395.85 418.79C392.56 425.88 380.88 439.145 374.165 442.94C373.72 443.15 373.27 443.35 372.815 443.54C362.92 447.725 354.82 447.155 345.152 443.2C338.889 439.77 326.289 427.235 323.247 420.585C318.345 409.865 319.43 405.595 310.295 396.915C311.11 395.2 311.019 395.015 312.706 394.09L317.784 399.91C319.351 403.11 321.285 407.76 322.883 410.61C323.355 404.335 323.986 392.97 326.628 387.57C331.109 393.6 335.67 399.565 340.31 405.47C341.384 407.225 341.637 407.735 343.047 409.185C345.404 406.69 350.165 406.575 353.435 406.435C358.415 405.48 365.925 406.235 371.17 406.595C372.65 407.735 373.905 408.695 375.295 409.935C376.56 407.9 377.88 405.9 379.26 403.94C383.415 398.565 387.425 393.09 391.3 387.51Z" fill="white"/>
<path d="M353.435 406.435C358.415 405.48 365.925 406.235 371.17 406.595C372.65 407.735 373.905 408.695 375.295 409.935C377.705 414.175 380.255 416.815 377.215 421.29C371.795 426.65 367.335 429.325 361.405 433.885C360.82 434.335 360.445 435.205 360.31 435.96L360.045 435.97L358.725 434.39C353.57 431.78 343.19 425.065 340.459 419.45C339.283 417.03 341.936 411.78 343.047 409.185C345.404 406.69 350.165 406.575 353.435 406.435Z" fill="#DA4525"/>
<path d="M343.047 409.185C345.404 406.69 350.165 406.575 353.435 406.435C355.745 406.565 356 406.215 357.685 407.275C358.015 409.36 357.065 410.69 356.205 412.93C354.165 413.615 352.455 414.045 350.65 415.21L350.685 415.725C352.32 416.065 352.57 416.035 354.185 416.05C357.665 419.43 358.67 429.75 358.725 434.39C353.57 431.78 343.19 425.065 340.459 419.45C339.283 417.03 341.936 411.78 343.047 409.185Z" fill="#DA4525"/>
<path d="M353.435 406.435C358.415 405.48 365.925 406.235 371.17 406.595C372.65 407.735 373.905 408.695 375.295 409.935C377.705 414.175 380.255 416.815 377.215 421.29C375.29 419.28 373.645 418.815 371.065 417.705C369.065 417.25 366.495 416.96 364.42 416.665C361.13 416.565 357.5 416.26 354.185 416.05C352.57 416.035 352.32 416.065 350.685 415.725L350.65 415.21C352.455 414.045 354.165 413.615 356.205 412.93C357.065 410.69 358.015 409.36 357.685 407.275C356 406.215 355.745 406.565 353.435 406.435Z" fill="#E75F21"/>
<path d="M356.205 412.93C358.905 412.9 361.175 412.78 363.855 413.19L364.42 416.665C361.13 416.565 357.5 416.26 354.185 416.05C352.57 416.035 352.32 416.065 350.685 415.725L350.65 415.21C352.455 414.045 354.165 413.615 356.205 412.93Z" fill="#F69723"/>
<path d="M363.855 413.19C367.75 413.97 368.875 414.365 371.065 417.705C369.065 417.25 366.495 416.96 364.42 416.665L363.855 413.19Z" fill="#FACA71"/>
<path d="M391.3 387.51C394.78 392.36 393.89 404.415 395.53 410.515C393.755 419.015 392.145 422.945 386.235 429.465C378.375 438.13 371.005 438.17 360.31 435.96C360.445 435.205 360.82 434.335 361.405 433.885C367.335 429.325 371.795 426.65 377.215 421.29C380.255 416.815 377.705 414.175 375.295 409.935C376.56 407.9 377.88 405.9 379.26 403.94C383.415 398.565 387.425 393.09 391.3 387.51Z" fill="white"/>
<path d="M310.295 396.915C311.11 395.2 311.019 395.015 312.706 394.09L317.784 399.91C319.351 403.11 321.285 407.76 322.883 410.61C325.461 429.71 340.655 441.145 360.045 435.97L360.31 435.96C371.005 438.17 378.375 438.13 386.235 429.465C392.145 422.945 393.755 419.015 395.53 410.515C396.885 407.465 398.615 403.055 400.525 400.49L402.92 399.505L403.325 399.83C402.965 400.88 402.735 400.975 402.89 402.05L402.535 402.7C399.655 408.015 398.105 413.93 395.85 418.79C392.56 425.88 380.88 439.145 374.165 442.94C373.72 443.15 373.27 443.35 372.815 443.54C362.92 447.725 354.82 447.155 345.152 443.2C338.889 439.77 326.289 427.235 323.247 420.585C318.345 409.865 319.43 405.595 310.295 396.915Z" fill="#DA4525"/>
<path d="M345.152 443.2C348.635 441.88 367.265 440.27 371.56 441.395C372.025 441.515 373.65 442.595 374.165 442.94C373.72 443.15 373.27 443.35 372.815 443.54C362.92 447.725 354.82 447.155 345.152 443.2Z" fill="#F08223"/>
<path d="M493.63 279.473C496.115 273.944 507.95 265.255 511.51 259.438C512.315 258.123 515.515 254.523 516.71 253.414C523.095 267.832 529.665 282.164 536.43 296.405C539.525 302.994 545.005 313.765 547.345 320.385C531.46 338.03 505.54 355.425 481.965 358.905C493.275 353.14 504.665 347.535 516.13 342.095C508.71 332.79 502.46 323.37 496.09 313.35C502.59 312.415 508.96 310.875 515.345 309.365C508.2 299.179 500.235 290.169 493.63 279.473Z" fill="#EC6D25"/>
</g>
<defs>
<clipPath id="clip0_2002_138">
<rect width="718" height="480" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 36 KiB

+22
View File
@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GoClaw</title>
<script>
// Apply persisted theme before first paint to prevent flash
try {
const stored = JSON.parse(localStorage.getItem('goclaw-ui') || '{}')
if (stored.state?.theme === 'light') {
document.documentElement.classList.remove('dark')
}
} catch {}
</script>
<script type="module" crossorigin src="/assets/index-COTE9Ugk.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D6czDNCk.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GoClaw</title>
<script>
// Apply persisted theme before first paint to prevent flash
try {
const stored = JSON.parse(localStorage.getItem('goclaw-ui') || '{}')
if (stored.state?.theme === 'light') {
document.documentElement.classList.remove('dark')
}
} catch {}
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+35
View File
@@ -0,0 +1,35 @@
{
"name": "goclaw-desktop",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"clsx": "^2.1.1",
"framer-motion": "^12.38.0",
"i18next": "^25.10.10",
"immer": "^10.1.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-i18next": "^16.6.6",
"react-markdown": "^9.0.3",
"react-syntax-highlighter": "^15.6.1",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.0",
"zustand": "^5.0.3"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/react-syntax-highlighter": "^15.5.13",
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.2",
"vite": "^6.0.0"
}
}
+2703
View File
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,74 @@
<svg width="718" height="480" viewBox="0 0 718 480" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2002_138)">
<path d="M475.975 111.14C479.22 108.995 482.125 107.284 485.505 105.343C494.22 140.39 496.545 174.152 483.055 208.656C477.735 222.262 471.185 233.521 465.68 246.319C473.555 260.847 480.845 275.458 490.435 289.009C492.05 291.291 500.855 302.539 501.4 304.235C500.21 304.54 499.38 304.82 498.14 304.85C492.925 305.455 487.71 306.015 482.49 306.535C489.165 317.605 495.08 326.745 502.305 337.49L503.29 338.78C502.99 338.91 502.705 339.105 502.38 339.145C498.995 342.1 475.905 352.86 469.955 356.015C446.68 368.37 430.11 378.295 409.91 394.9C409.08 395.73 403.185 401.9 402.89 402.05C402.735 400.975 402.965 400.88 403.325 399.83L402.92 399.505L400.525 400.49C398.615 403.055 396.885 407.465 395.53 410.515C393.89 404.415 394.78 392.36 391.3 387.51C387.425 393.09 383.415 398.565 379.26 403.94C377.88 405.9 376.56 407.9 375.295 409.935C373.905 408.695 372.65 407.735 371.17 406.595C365.925 406.235 358.415 405.48 353.435 406.435C350.165 406.575 345.404 406.69 343.047 409.185C341.637 407.735 341.384 407.225 340.31 405.47C335.67 399.565 331.109 393.6 326.628 387.57C323.986 392.97 323.355 404.335 322.883 410.61C321.285 407.76 319.351 403.11 317.784 399.91L312.706 394.09C311.019 395.015 311.11 395.2 310.295 396.915C290.334 379.59 272.747 368.9 249.053 356.57C239.038 351.36 224.767 345.095 215.441 339.68L214.873 339.345L214.593 338.51C222.06 327.945 229.275 317.2 236.235 306.29C230.543 306.06 225.064 305.675 219.438 304.77C217.635 304.42 217.579 304.75 216.815 303.65C231.937 284.12 238.287 273.55 249.888 251.212L252.646 246.608C247.281 234.241 240.568 223.097 235.317 209.993C221.694 175.992 223.402 140.277 232.644 105.53L237.439 108.304C262.959 122.947 292.157 143.265 311.9 165.499C317.746 172.083 322.162 180.227 327.393 187.017C349.08 186.699 369.595 187.143 391.025 187.081C395.85 179.083 404.47 166.614 411.075 159.903C425.385 145.359 458.04 119.627 475.975 111.14Z" fill="#F08223"/>
<path d="M249.888 251.211C251.835 250.214 253.172 249.412 255.36 250.387C270.46 257.119 284.97 265.137 300.152 271.747C304.538 273.656 310.84 276.006 314.378 279.05L314.531 279.461C318.081 280.559 325.666 283.327 329.094 284.895C330.719 286.329 333.366 290.049 335.057 291.98C343.514 301.633 351.07 313.295 359.44 322.825C366.445 313.69 373.515 304.37 380.815 295.483C382.3 293.674 386.195 288.894 387.81 287.395C387.35 292.673 381.975 307.9 383.33 310.125L383.875 309.675C386.765 309.525 390 309.14 392.935 308.895L393.66 308.825C397.965 307.925 405.28 304.495 409.11 306.775L410.7 306.315C417.56 304.28 421.82 304.295 428.905 304.01L429.51 303.01C430.935 302.198 433.095 302.38 434.83 302.353C438.73 303.125 442.8 303.945 446.555 305.29L447.405 305.83C452.245 304.96 478.275 305.085 482.49 306.535C489.165 317.605 495.08 326.745 502.305 337.49L503.29 338.78C502.99 338.91 502.705 339.105 502.38 339.145C498.995 342.1 475.905 352.86 469.955 356.015C446.68 368.37 430.11 378.295 409.91 394.9C409.08 395.73 403.185 401.9 402.89 402.05C402.735 400.975 402.965 400.88 403.325 399.83L402.92 399.505L400.525 400.49C398.615 403.055 396.885 407.465 395.53 410.515C393.89 404.415 394.78 392.36 391.3 387.51C387.425 393.09 383.415 398.565 379.26 403.94C377.88 405.9 376.56 407.9 375.295 409.935C373.905 408.695 372.65 407.735 371.17 406.595C365.925 406.235 358.415 405.48 353.435 406.435C350.165 406.575 345.404 406.69 343.047 409.185C341.637 407.735 341.384 407.225 340.31 405.47C335.67 399.565 331.109 393.6 326.628 387.57C323.986 392.97 323.355 404.335 322.883 410.61C321.285 407.76 319.351 403.11 317.784 399.91L312.706 394.09C311.019 395.015 311.11 395.2 310.295 396.915C290.334 379.59 272.747 368.9 249.053 356.57C239.038 351.36 224.767 345.095 215.441 339.68L214.873 339.345L214.593 338.51C222.06 327.945 229.275 317.2 236.235 306.29C230.543 306.06 225.064 305.675 219.438 304.77C217.635 304.42 217.579 304.75 216.815 303.65C231.937 284.119 238.287 273.549 249.888 251.211Z" fill="#DA4525"/>
<path d="M434.83 302.353C438.73 303.125 442.8 303.945 446.555 305.29L447.405 305.83C452.245 304.96 478.275 305.085 482.49 306.535C489.165 317.605 495.08 326.745 502.305 337.49L503.29 338.78C502.99 338.91 502.705 339.105 502.38 339.145C498.995 342.1 475.905 352.86 469.955 356.015C446.68 368.37 430.11 378.295 409.91 394.9C409.08 395.73 403.185 401.9 402.89 402.05C402.735 400.975 402.965 400.88 403.325 399.83L402.92 399.505L400.525 400.49C398.615 403.055 396.885 407.465 395.53 410.515C393.89 404.415 394.78 392.36 391.3 387.51L391.915 386.455C391.715 386.09 391.415 385.555 391.285 385.125C387.405 372.375 383.51 359.05 380.835 346.01C380.65 345.115 381.09 344.14 381.445 343.42L382.22 343.615L382.225 343.04C384.98 341.915 386.945 337.445 389.505 334.9C390.545 333.34 391.54 331.825 392.65 330.32C398.095 320.98 399.355 312.075 409.11 306.775L410.7 306.315C417.56 304.28 421.82 304.295 428.905 304.01L429.51 303.01C430.935 302.199 433.095 302.38 434.83 302.353Z" fill="#F4972E"/>
<path d="M446.265 336.37C462.965 336.705 479.35 338.475 496.13 338.66C497.595 338.86 498.825 339.04 500.315 339.105L502.38 339.145C498.995 342.1 475.905 352.86 469.955 356.015C446.68 368.37 430.11 378.295 409.91 394.9C409.08 395.73 403.185 401.9 402.89 402.05C402.735 400.975 402.965 400.88 403.325 399.83L402.92 399.505L400.525 400.49C398.615 403.055 396.885 407.465 395.53 410.515C393.89 404.415 394.78 392.36 391.3 387.51L391.915 386.455C394.31 381.31 396.755 375.905 399.435 370.925C400.44 367.285 440.07 340.3 446.265 336.37Z" fill="white"/>
<path d="M500.315 339.105L502.38 339.145C498.995 342.1 475.905 352.86 469.955 356.015C446.68 368.37 430.11 378.295 409.91 394.9C408.84 394.365 407.62 393.95 406.49 393.52C413.69 383.865 445.835 349.87 455.93 347.36C465.845 344.89 476.66 343.405 486.77 341.93C491.05 341.305 495.905 341.175 500.015 339.885L500.315 339.105Z" fill="#FACA71"/>
<path d="M391.915 386.455C394.31 381.31 396.755 375.905 399.435 370.925C399.98 375.735 400.255 380.575 400.26 385.42C400.27 387.73 399.935 398.565 400.32 400.06L400.525 400.49C398.615 403.055 396.885 407.465 395.53 410.515C393.89 404.415 394.78 392.36 391.3 387.51L391.915 386.455Z" fill="#FACA71"/>
<path d="M406.49 393.52C407.62 393.95 408.84 394.365 409.91 394.9C409.08 395.73 403.185 401.9 402.89 402.05C402.735 400.975 402.965 400.88 403.325 399.83L402.92 399.505L400.525 400.49L400.32 400.06L406.49 393.52Z" fill="#F08223"/>
<path d="M389.505 334.9C393 337.065 417.915 332.855 421.125 334.505L420.16 336.995L420.67 337.15L422.305 334.91C426.325 333.77 453.35 326.36 455.235 327.165C452.62 329.84 448.625 333.655 446.265 336.37C440.07 340.3 400.44 367.285 399.435 370.925C396.755 375.905 394.31 381.31 391.915 386.455C391.715 386.09 391.415 385.555 391.285 385.125C387.405 372.375 383.51 359.05 380.835 346.01C380.65 345.115 381.09 344.14 381.445 343.42L382.22 343.615L382.225 343.04C384.98 341.915 386.945 337.445 389.505 334.9Z" fill="#F08223"/>
<path d="M446.555 305.29L447.405 305.83C452.245 304.96 478.275 305.085 482.49 306.535C489.165 317.605 495.08 326.745 502.305 337.49L503.29 338.78C502.99 338.91 502.705 339.105 502.38 339.145L500.315 339.105C498.825 339.04 497.595 338.86 496.13 338.66C494.09 335.885 462.545 326.815 457.36 325.215C452.42 319.68 448.105 313.665 443.7 307.7L446.555 305.29Z" fill="#F69723"/>
<path d="M446.555 305.29L447.405 305.83C454.18 309.655 499.56 337.28 502.305 337.49L503.29 338.78C502.99 338.91 502.705 339.105 502.38 339.145L500.315 339.105C498.825 339.04 497.595 338.86 496.13 338.66C494.09 335.885 462.545 326.815 457.36 325.215C452.42 319.68 448.105 313.665 443.7 307.7L446.555 305.29Z" fill="#EC6D25"/>
<path d="M443.7 307.7C448.105 313.665 452.42 319.68 457.36 325.215C462.545 326.815 494.09 335.885 496.13 338.66C479.35 338.475 462.965 336.705 446.265 336.37C448.625 333.655 452.62 329.84 455.235 327.165L456.005 326.755L456.1 326.12C453.035 325.54 426.825 332.905 421.63 334.265C426.095 327.89 437.16 315.91 440.08 310.76C441.3 309.755 442.51 308.735 443.7 307.7Z" fill="#F69723"/>
<path d="M409.11 306.775L410.7 306.315C417.56 304.28 421.82 304.295 428.905 304.01C425.445 312.065 424.365 315.4 417.625 321.425C408.89 326.975 402.5 325.235 393.965 329.86C393.13 330.315 393.5 330.19 392.65 330.32C398.095 320.98 399.355 312.075 409.11 306.775Z" fill="white"/>
<path d="M434.83 302.353C438.73 303.125 442.8 303.945 446.555 305.29L443.7 307.7C442.51 308.735 441.3 309.755 440.08 310.76C434.505 313.14 421.24 322.815 417.625 321.425C424.365 315.4 425.445 312.065 428.905 304.01L429.51 303.01C430.935 302.199 433.095 302.38 434.83 302.353Z" fill="#DA4525"/>
<path d="M314.531 279.461C318.081 280.559 325.666 283.327 329.094 284.895C330.719 286.329 333.366 290.05 335.057 291.98C343.514 301.634 351.07 313.295 359.44 322.825C366.445 313.69 373.515 304.37 380.815 295.483C382.3 293.675 386.195 288.894 387.81 287.395C387.35 292.673 381.975 307.9 383.33 310.125L383.875 309.675C386.765 309.525 390 309.14 392.935 308.895L393.66 308.825C397.965 307.925 405.28 304.495 409.11 306.775C399.355 312.075 398.095 320.98 392.65 330.32C391.54 331.825 390.545 333.34 389.505 334.9C386.945 337.445 384.98 341.915 382.225 343.04L382.22 343.615L381.445 343.42C381.09 344.14 380.65 345.115 380.835 346.01C383.51 359.05 387.405 372.375 391.285 385.125C391.415 385.555 391.715 386.09 391.915 386.455L391.3 387.51C387.425 393.09 383.415 398.565 379.26 403.94C377.88 405.9 376.56 407.9 375.295 409.935C373.905 408.695 372.65 407.735 371.17 406.595C365.925 406.235 358.415 405.48 353.435 406.435C350.165 406.575 345.404 406.69 343.047 409.185C341.637 407.735 341.384 407.225 340.31 405.47C342.142 402.15 341.352 380.05 341.785 374.79C343.747 350.935 344.124 326.33 331.977 304.81C327.03 296.049 320.094 288.237 314.776 279.853L314.531 279.461Z" fill="#F4972E"/>
<path d="M393.66 308.825C397.965 307.925 405.28 304.495 409.11 306.775C399.355 312.075 398.095 320.98 392.65 330.32C391.54 331.825 390.545 333.34 389.505 334.9C386.945 337.445 384.98 341.915 382.225 343.04L382.22 343.615L381.445 343.42C381.09 344.14 380.65 345.115 380.835 346.01C383.51 359.05 387.405 372.375 391.285 385.125C391.415 385.555 391.715 386.09 391.915 386.455L391.3 387.51C387.425 393.09 383.415 398.565 379.26 403.94C377.84 402.61 377.385 402.05 377.28 399.92C376.4 382.1 375.52 363.255 375.31 345.51C375.25 340.65 380.975 313.44 383.33 310.125L383.875 309.675C386.765 309.525 390 309.14 392.935 308.895L393.66 308.825Z" fill="#F69723"/>
<path d="M393.66 308.825C397.965 307.925 405.28 304.495 409.11 306.775C399.355 312.075 398.095 320.98 392.65 330.32C391.54 331.825 390.545 333.34 389.505 334.9C386.945 337.445 384.98 341.915 382.225 343.04L382.22 343.615L381.445 343.42C382.455 337.06 390.25 315.39 392.935 308.895L393.66 308.825Z" fill="#DA4525"/>
<path d="M249.887 251.211C251.835 250.214 253.171 249.412 255.36 250.387C270.46 257.119 284.969 265.137 300.152 271.747C304.538 273.656 310.84 276.006 314.377 279.05L314.53 279.461L314.775 279.853C320.093 288.237 327.03 296.049 331.976 304.81C344.123 326.33 343.747 350.935 341.784 374.79C341.351 380.05 342.142 402.15 340.31 405.47C335.67 399.565 331.109 393.6 326.627 387.57L326.236 386.595C324.674 384.21 318.966 372.275 318.379 369.495C321.8 364.125 328.789 344.45 330.96 337.87C328.616 334.9 327.292 333.125 325.309 329.925C321.013 322.865 317.299 310.865 311.052 307.7C314.105 306.01 319.586 308.205 323.587 309.05C309.233 290.833 288.257 288.808 266.548 286.637C259.493 285.931 246.877 293.012 239.771 295.59C234.71 297.426 223.085 301.584 219.437 304.77C217.634 304.42 217.578 304.75 216.814 303.65C231.937 284.119 238.286 273.549 249.887 251.211Z" fill="#E75F21"/>
<path d="M330.96 337.87C332.604 339.94 333.83 342.88 335.655 344.665L336.784 344.075C337.078 345.18 337.36 346.695 337.08 347.785C335.164 355.26 329.211 381.145 326.237 386.595C324.674 384.21 318.966 372.275 318.379 369.495C321.8 364.125 328.789 344.45 330.96 337.87Z" fill="#DA4525"/>
<path d="M311.052 307.7C314.105 306.01 319.586 308.205 323.587 309.05C327.743 311.415 335.05 338.29 336.784 344.075L335.655 344.665C333.829 342.88 332.604 339.94 330.96 337.87C328.616 334.9 327.292 333.125 325.309 329.925C321.013 322.865 317.299 310.865 311.052 307.7Z" fill="#DA4525"/>
<path d="M268.114 336.32C268.887 336.33 270.77 336.415 271.335 336.78C284.292 345.12 307.051 359.89 318.097 369.395L318.379 369.495C318.966 372.275 324.674 384.21 326.237 386.595L326.628 387.57C323.986 392.97 323.355 404.335 322.883 410.61C321.285 407.76 319.351 403.11 317.784 399.91L312.706 394.09C311.019 395.015 311.11 395.2 310.295 396.915C290.334 379.59 272.747 368.9 249.053 356.57C239.038 351.36 224.767 345.095 215.441 339.68L214.873 339.345C216.817 338.915 219.08 339.04 221.099 339.045C236.778 338.265 252.45 337.355 268.114 336.32Z" fill="white"/>
<path d="M214.873 339.345C216.817 338.915 219.08 339.04 221.099 339.045L218.947 339.795C219.863 340.57 219.246 340.26 220.501 340.405C235.123 342.075 249.675 345.575 264.308 347.335C277.918 358.375 290.889 369.745 302.72 382.69C305.29 385.505 310.663 391.055 312.706 394.09C311.019 395.015 311.11 395.2 310.295 396.915C290.334 379.59 272.747 368.9 249.053 356.57C239.038 351.36 224.767 345.095 215.441 339.68L214.873 339.345Z" fill="#F08223"/>
<path d="M318.097 369.395L318.379 369.495C318.966 372.275 324.674 384.21 326.237 386.595L326.628 387.57C323.986 392.97 323.355 404.335 322.883 410.61C321.285 407.76 319.351 403.11 317.784 399.91C317.969 398.145 317.684 390.63 317.676 388.3C317.682 381.995 317.823 375.695 318.097 369.395Z" fill="#F08223"/>
<path d="M268.113 336.32L266.913 335.82L267.786 335.11C267.128 331.325 261.513 329.03 259.178 325.915C261.93 325.08 272.277 311.12 275.076 307.855C283.457 311.925 290.08 317.29 298.126 321.73C299.054 322.24 299.62 321.74 300.413 321.33C308.443 326.055 315.108 325.51 323.212 329.145C324.045 329.52 324.41 329.795 325.309 329.925C327.292 333.125 328.616 334.9 330.96 337.87C328.789 344.45 321.8 364.125 318.379 369.495L318.097 369.395C307.051 359.89 284.292 345.12 271.334 336.78C270.77 336.415 268.886 336.33 268.113 336.32Z" fill="#E85922"/>
<path d="M287.781 303.575C297.485 304.045 301.879 304.265 311.053 307.7C317.3 310.865 321.014 322.865 325.309 329.925C324.411 329.795 324.046 329.52 323.212 329.145C315.109 325.51 308.444 326.055 300.413 321.33C294.081 315.365 292.334 311.07 287.781 303.575Z" fill="white"/>
<path d="M475.975 111.14C479.22 108.995 482.125 107.284 485.505 105.343C494.22 140.39 496.545 174.152 483.055 208.656C477.735 222.262 471.185 233.521 465.68 246.319C473.555 260.847 480.845 275.458 490.435 289.009C492.05 291.291 500.855 302.539 501.4 304.235C500.21 304.54 499.38 304.82 498.14 304.85C492.925 305.455 487.71 306.015 482.49 306.535C478.275 305.085 452.245 304.96 447.405 305.83L446.555 305.29C442.8 303.945 438.73 303.125 434.83 302.354C433.095 302.38 430.935 302.199 429.51 303.01L428.905 304.01C421.82 304.295 417.56 304.28 410.7 306.315L409.11 306.775C405.28 304.495 397.965 307.925 393.66 308.825L392.935 308.895C390 309.14 386.765 309.525 383.875 309.675L383.33 310.125C381.975 307.9 387.35 292.674 387.81 287.395L388.675 285.191C390.86 276.813 392.71 265.406 394.29 256.695C396.565 243.899 399.02 231.136 401.65 218.408C402.865 212.441 404.09 205.534 405.76 199.734C408.57 201.681 411.785 204.312 414.53 206.447C419.4 193.235 427.11 181.252 434.905 169.619C429.295 171.723 414.485 204.218 413.525 204.599C411.235 202.926 408.93 201.274 406.61 199.641C410.015 189.8 416.955 178.363 422.98 169.762C425.32 166.43 429.985 162.012 431.42 158.457C421.49 167.084 410.295 187.99 405.56 200.062C402.89 197.768 392.865 189.414 391.025 187.081C395.85 179.083 404.47 166.614 411.075 159.903C425.385 145.359 458.04 119.627 475.975 111.14Z" fill="#F7A32A"/>
<path d="M434.905 169.618C437.66 167.012 443.08 159.28 446.455 155.702C457.205 144.317 468.31 132.77 480.515 122.928L481.275 123.109L482.24 123.35C483.5 126.867 484.245 135.769 484.56 139.633C486.62 165.019 481.185 184.667 470.58 207.316C465.555 218.054 459.865 226.927 454.66 237.048L454.88 237.673L454.345 237.792C441.635 228.163 427.165 215.247 414.53 206.446C419.4 193.235 427.11 181.252 434.905 169.618Z" fill="#E86123"/>
<path d="M434.905 169.618C437.66 167.012 443.08 159.28 446.455 155.702C457.205 144.317 468.31 132.77 480.515 122.928L481.275 123.109C467.74 141.984 449.24 167.332 438.465 187.451C445.905 185.56 453.31 183.772 460.595 181.335C453.93 188.829 445.545 198.546 439.515 206.538C444.51 207.501 451.155 209.118 455.92 209.873C450.755 213.598 445.955 217.047 441 221.061C445.38 226.021 450.615 231.842 454.66 237.048L454.88 237.673L454.345 237.792C441.635 228.163 427.165 215.247 414.53 206.446C419.4 193.235 427.11 181.252 434.905 169.618Z" fill="white"/>
<path d="M475.975 111.14C477.15 110.878 478.335 110.631 479.52 110.398C479.195 111.261 479.29 111.554 478.475 112.028C472.55 115.474 434.835 152.825 431.42 158.457C421.49 167.084 410.295 187.99 405.56 200.062C402.89 197.768 392.865 189.413 391.025 187.081C395.85 179.082 404.47 166.614 411.075 159.902C425.385 145.359 458.04 119.627 475.975 111.14Z" fill="#EC6D25"/>
<path d="M453.14 286.783C460.12 285.369 483.665 298.451 491.255 300.772C492.935 301.286 496.96 303.47 498.14 304.85C492.925 305.455 487.71 306.015 482.49 306.535C478.275 305.085 452.245 304.96 447.405 305.83L446.555 305.29C442.8 303.945 438.73 303.125 434.83 302.353C437.745 297.95 452.2 291.317 454.275 287.336L453.14 286.783Z" fill="#F08223"/>
<path d="M393.66 308.825C394.06 308.075 394.18 307.74 394.745 307.115C409.915 290.309 432.69 288.779 453.14 286.783L454.275 287.336C452.2 291.317 437.745 297.95 434.83 302.353C433.095 302.38 430.935 302.198 429.51 303.01L428.905 304.01C421.82 304.295 417.56 304.28 410.7 306.315L409.11 306.775C405.28 304.495 397.965 307.925 393.66 308.825Z" fill="#EC6D25"/>
<path d="M388.675 285.191L406.465 278.65C399.885 284.628 386.8 301.284 383.875 309.675L383.33 310.125C381.975 307.9 387.35 292.674 387.81 287.395L388.675 285.191Z" fill="#FACA71"/>
<path d="M232.644 105.53L237.438 108.304C240.228 113.372 250.138 121.211 254.514 125.613C278.743 149.989 299.494 166.835 312.917 199.525C311.366 200.7 304.995 205.971 303.838 206.04C300.248 209.239 264.434 237.583 262.412 238.139C259.443 240.534 255.073 244.83 252.646 246.608C247.28 234.241 240.567 223.097 235.317 209.993C221.693 175.992 223.401 140.277 232.644 105.53Z" fill="#DA4525"/>
<path d="M232.644 105.53L237.438 108.304C240.228 113.372 250.138 121.211 254.514 125.613C278.743 149.989 299.494 166.835 312.917 199.525C311.366 200.7 304.995 205.971 303.838 206.04C292.202 176.579 269.233 151.382 246.013 130.29C243.454 127.965 240.506 125.001 237.649 123.202L236.68 123.214C233.512 127.179 233.319 157.573 234.522 167.705C237.069 189.174 251.852 217.614 262.359 237.084C262.468 237.162 262.578 237.239 262.687 237.317L262.412 238.139C259.443 240.534 255.073 244.83 252.646 246.608C247.28 234.241 240.567 223.097 235.317 209.993C221.693 175.992 223.401 140.277 232.644 105.53Z" fill="#E75F21"/>
<path d="M237.649 123.202C240.506 125.001 243.454 127.965 246.013 130.29C269.233 151.382 292.202 176.579 303.838 206.04C300.248 209.239 264.434 237.583 262.412 238.139L262.687 237.317C267.458 232.339 272.612 226.212 277.221 220.982C272.507 217.272 267.82 213.514 262.958 210.002C268.747 208.621 273.289 207.607 279.187 206.655C272.583 198.399 265.111 189.932 258.254 181.779C264.544 183.94 273.329 186.081 280.072 188.117C265.558 163.876 253.103 146.274 237.649 123.202Z" fill="white"/>
<path d="M312.917 199.525C313.842 208.746 329.501 283.62 329.093 284.895C325.665 283.328 318.08 280.56 314.53 279.462L314.377 279.051C310.84 276.007 304.538 273.657 300.152 271.747C284.969 265.137 270.46 257.12 255.36 250.388C253.171 249.412 251.835 250.214 249.887 251.212L252.646 246.608C255.073 244.83 259.443 240.534 262.412 238.139C264.434 237.583 300.248 209.239 303.838 206.04C304.995 205.971 311.366 200.7 312.917 199.525Z" fill="#EC6D25"/>
<path d="M621.56 250.225C649.19 241.166 690.17 245.625 717.205 256.44C709.7 259.501 703.805 261.849 696.72 265.928C667.78 282.589 653.15 309.35 637.985 337.57C635.265 342.625 626.38 359.54 623.34 363.51C621.565 367.67 618.1 372.775 615.505 376.62C603.8 393.985 590.755 407.065 573.195 418.535C558.59 427.585 542.505 433.99 525.68 437.455C474.11 448.47 409.665 436.945 367.58 474.325L362.02 479.92C364.675 471.09 369.22 460.1 373.15 451.81C385.24 447.205 396.37 434.76 402.66 423.43C404.7 419.76 406.64 409.95 409.285 407C421.92 392.885 440.665 380.49 458.075 373.04C463.07 370.96 472.755 369.86 478.055 368.64C491.315 365.58 503.22 362.38 515.38 356.08C554.775 335.245 570.47 308.165 583.605 268.238C594.21 261.405 609.565 253.776 621.56 250.225Z" fill="#E75F21"/>
<path d="M621.56 250.225C649.19 241.166 690.17 245.625 717.205 256.44C709.7 259.501 703.805 261.849 696.72 265.928C667.78 282.589 653.15 309.35 637.985 337.57C635.265 342.625 626.38 359.54 623.34 363.51C621.565 367.67 618.1 372.775 615.505 376.62C603.8 393.985 590.755 407.065 573.195 418.535C571.44 414.48 545.975 385.205 542.01 381.33C545.73 379.58 548.56 376.67 551.55 373.895C553.47 369.34 558.85 361.395 561.64 356.86C565.865 349.91 569.865 342.82 573.625 335.605C579.675 324.1 585.435 312.17 590.735 300.303C593.075 295.052 595.375 289.951 598.375 285.025L598.865 284.239L598.58 283.248L596.69 282.524C595.035 283.259 592.67 284.079 590.925 284.746C598.69 274.913 607.395 265.636 616.01 256.539C617.96 254.482 619.98 252.581 621.56 250.225Z" fill="#F69723"/>
<path d="M621.56 250.225C649.19 241.166 690.17 245.625 717.205 256.44C709.7 259.501 703.805 261.849 696.72 265.928C667.78 282.589 653.15 309.35 637.985 337.57C635.265 342.625 626.38 359.54 623.34 363.51C623.565 360.315 625.67 352.15 626.47 348.71L634.525 314.865C623.65 320.42 611.72 325.83 600.605 331.08C607.7 312.03 616.01 292.932 623.54 273.98C621.255 274.578 619.18 275.355 616.955 276.14C611.51 277.698 601.165 281.561 596.69 282.524C595.035 283.259 592.67 284.079 590.925 284.746C598.69 274.913 607.395 265.636 616.01 256.539C617.96 254.482 619.98 252.581 621.56 250.225Z" fill="#FACA71"/>
<path d="M616.955 276.139C618.865 276.349 619.025 276.37 620.78 275.62L621.765 275.983C621.55 276.499 620.36 279.537 620.27 279.703C618.645 282.815 616.2 286.393 614.46 289.437C602.325 310.605 571.635 360.585 551.55 373.895C553.47 369.34 558.85 361.395 561.64 356.86C565.865 349.91 569.865 342.82 573.625 335.605C579.675 324.1 585.435 312.17 590.735 300.302C593.075 295.051 595.375 289.951 598.375 285.024L598.865 284.238L598.58 283.248L596.69 282.524C601.165 281.56 611.51 277.697 616.955 276.139Z" fill="#F08223"/>
<path d="M367.58 474.325C369.175 468.785 376.94 462.325 380.92 458.04C402.63 434.675 432.38 421.595 462.36 412.475C487.43 404.85 512.63 399.29 535.565 386.03C537.88 384.695 539.795 382.84 542.01 381.33C545.975 385.205 571.44 414.48 573.195 418.535C558.59 427.585 542.505 433.99 525.68 437.455C474.11 448.47 409.665 436.945 367.58 474.325Z" fill="#EC6D25"/>
<path d="M0.801025 256.56C25.0185 247.36 54.1575 243.004 79.764 246.599C84.7195 247.295 91.8315 248.471 96.5195 250.184C107.353 252.657 125.73 261.55 134.908 268.389C151.273 321.035 182.319 357.225 239.866 368.71C259.977 372.725 273.389 377.2 289.764 390.215C305.109 401.825 309.611 404.04 315.084 422.56C317.848 431.91 335.17 447.435 344.74 451.765C348.92 460.1 353.64 470.705 356.47 479.625C353.74 476.995 350.935 474.185 348 471.815C341.661 467.07 335.043 462.49 327.807 459.22C279.619 437.72 224.65 448.955 175.055 432.73C142.825 422.185 109.913 394.42 94.6255 363.56C93.1065 361.485 91.6015 358.645 90.351 356.335C72.476 323.335 56.065 285.647 22.0795 265.999C15.0615 261.942 8.32553 259.394 0.801025 256.56Z" fill="#E25424"/>
<path d="M96.5196 250.183C107.353 252.656 125.73 261.55 134.908 268.389C151.273 321.035 182.319 357.225 239.866 368.71C259.977 372.725 273.389 377.2 289.764 390.215C305.109 401.825 309.611 404.04 315.084 422.56C317.848 431.91 335.17 447.435 344.74 451.765C348.92 460.1 353.64 470.705 356.47 479.625C353.74 476.995 350.935 474.185 348 471.815C346.347 465.985 337.908 458.67 333.511 454.355C300.489 421.36 253.876 412.4 211.091 398.675C161.126 382.655 136.715 345.09 111.239 302.361C107.851 296.679 100.658 280.062 96.1736 276.477C95.9266 276.2 95.6466 275.431 95.4761 275.041C98.1241 274.429 122.162 282.64 126.602 284.128C121.639 277.875 116.417 271.832 110.95 266.014C107.991 262.893 98.3261 253.311 96.5196 250.183Z" fill="#DA4525"/>
<path d="M0.801025 256.56C25.0185 247.36 54.1575 243.004 79.764 246.599C84.7195 247.295 91.8315 248.471 96.5195 250.184C98.326 253.311 107.991 262.893 110.95 266.015C116.417 271.833 121.639 277.876 126.602 284.128C122.162 282.641 98.124 274.43 95.476 275.041C95.6465 275.431 95.9265 276.201 96.1735 276.477C98.524 284.568 102.091 292.191 105.352 299.903C109.836 310.48 114.246 321.085 118.583 331.725C112.441 327.625 90.966 318.075 83.428 315.2C85.304 322.65 95.0385 358.885 94.6255 363.56C93.1065 361.485 91.6015 358.645 90.351 356.335C72.476 323.335 56.065 285.647 22.0795 265.999C15.0615 261.942 8.32553 259.394 0.801025 256.56Z" fill="#EC6D25"/>
<path d="M36.5449 144.41C39.9939 142.362 43.5309 140.466 47.1459 138.728C71.5904 126.965 101.828 120.272 128.784 122.526C133.934 122.956 142.546 124.004 147.441 125.707C179.429 133.995 200.18 152.645 216.845 180.687C220.918 187.54 221.171 191.645 223.036 199.172C227.129 215.7 236.727 231.113 243.669 246.443C238.264 256.707 231.491 269.571 224.834 279.118C218.987 288.737 209.926 299.947 203.02 309.135C209.213 310.84 215.524 312.075 221.813 313.395C216.298 322.785 208.814 332.995 202.483 341.975C209.473 346.055 217.54 349.635 224.721 353.415C227.875 355.075 231.937 356.795 234.787 358.805C186.018 347.505 155.923 311.75 142.857 265.626C140.49 257.27 135.466 248.348 133.181 239.672C130.294 234.691 126.858 225.314 124.571 219.9C106.437 176.964 83.9889 152.799 36.5449 144.41Z" fill="#DA4525"/>
<path d="M147.441 125.708C179.429 133.996 200.18 152.646 216.845 180.688C220.918 187.54 221.171 191.646 223.036 199.172C227.129 215.701 236.727 231.114 243.669 246.444C238.264 256.708 231.491 269.571 224.834 279.119C219.383 273.408 210.999 265.446 206.282 259.418C189.485 237.952 176.496 211.879 163.029 188.069L149.288 164.041C148.577 162.77 142.818 152.392 142.458 152.068L141.922 153.256C140.917 151.391 139.8 148.753 138.889 146.775C152.344 148.295 166.196 150.78 179.569 153.061C173.076 147.624 166.488 142.3 159.809 137.094C156.534 134.519 149.974 129.652 147.218 126.9L147.441 125.708Z" fill="#E75F21"/>
<path d="M36.5449 144.41C39.9939 142.362 43.5309 140.466 47.1459 138.728C71.5904 126.965 101.828 120.272 128.784 122.526C133.934 122.956 142.546 124.004 147.441 125.707L147.218 126.899C149.974 129.651 156.534 134.519 159.809 137.093C166.488 142.3 173.076 147.623 179.569 153.061C166.196 150.779 152.344 148.294 138.889 146.775C139.8 148.752 140.917 151.39 141.922 153.255C148.71 169.117 156.304 188.254 163.529 203.623C158.986 200.799 151.773 197.847 146.733 195.59L119.912 183.552C121.851 190.84 134.038 235.869 133.181 239.672C130.294 234.691 126.858 225.314 124.571 219.9C106.437 176.964 83.9889 152.799 36.5449 144.41Z" fill="#ED8337"/>
<path d="M572.35 125.308C598.135 118.311 634.385 123.835 659.05 133.56C667.205 136.776 674.455 140.479 682.24 144.456C650.39 150.413 629.28 160.685 610.45 188.3C604.515 197.12 599.365 206.448 595.07 216.173C592.01 223.087 589.27 230.474 585.895 237.176C583.285 245.531 579.88 253.913 576.95 262.181C569.995 281.821 561.8 304.92 547.345 320.385C545.005 313.765 539.525 302.994 536.43 296.405C529.665 282.164 523.095 267.832 516.71 253.414C515.515 254.523 512.315 258.123 511.51 259.438C507.95 265.256 496.115 273.944 493.63 279.473C488.625 273.581 478.345 253.189 474.55 245.828C481.17 232.857 487.5 220.572 492.62 206.947C496.135 197.588 496.09 188.852 501.115 180.584C518.81 151.447 539.23 134.098 572.35 125.308Z" fill="#F4972E"/>
<path d="M572.35 125.308C571.9 126.426 541.25 151.291 538.465 153.402C551.965 150.871 565.5 148.554 579.07 146.452C577.7 149.784 575.985 154.186 574.265 157.334C571.2 160.404 569.28 162.849 567.125 166.646C554.035 189.76 540.79 212.81 527.875 236.027C526.28 238.898 517.22 251.854 516.71 253.413C515.515 254.522 512.315 258.123 511.51 259.438C507.95 265.255 496.115 273.944 493.63 279.473C488.625 273.58 478.345 253.189 474.55 245.828C481.17 232.857 487.5 220.571 492.62 206.947C496.135 197.587 496.09 188.852 501.115 180.583C518.81 151.447 539.23 134.098 572.35 125.308Z" fill="#F08223"/>
<path d="M572.35 125.308C598.135 118.311 634.385 123.835 659.05 133.56C667.205 136.776 674.455 140.479 682.24 144.456C650.39 150.413 629.28 160.685 610.45 188.3C604.515 197.12 599.365 206.448 595.07 216.173C592.01 223.087 589.27 230.474 585.895 237.176C585.96 233.281 588.37 224.496 589.285 220.119C591.865 207.775 594.93 195.414 597.98 183.173C584.925 189.586 568.445 197.745 555.145 203.218C561.26 188.217 568.495 172.179 574.265 157.335C575.985 154.187 577.7 149.784 579.07 146.453C565.5 148.555 551.965 150.872 538.465 153.403C541.25 151.292 571.9 126.426 572.35 125.308Z" fill="#FACA71"/>
<path d="M277.512 2.78054L275.877 1.43053L275.913 0.699528C278.475 0.131028 286.904 0.450029 289.772 0.663029C314.831 2.52053 340.798 9.97953 361.985 23.594C364.84 25.4275 372.805 30.894 375.17 33.285C394.42 48.914 406.245 70.157 410.285 94.5105C412.52 113.337 410.665 130.468 405.575 148.655C404.4 152.86 402.795 156.754 400.035 160.209C395.065 166.337 390.785 172.476 386.63 179.2C376.23 178.775 365.82 178.561 355.41 178.558L330.998 179.014C319.783 160.43 308.678 149.399 292.3 135.801C295.777 131.081 298.905 124.877 301.681 119.644C307.198 105.851 311.598 93.812 311.65 78.6955C311.762 46.352 299.06 25.0135 277.512 2.78054Z" fill="#E75F21"/>
<path d="M277.512 2.78054L275.877 1.43053L275.913 0.699528C278.475 0.131028 286.904 0.450029 289.772 0.663029C314.831 2.52053 340.798 9.97953 361.985 23.594C364.84 25.4275 372.805 30.894 375.17 33.285C375.615 36.569 391.19 64.5035 393.845 70.404C384.445 65.329 371.97 57.674 362.595 53.7195L362.61 68.776C362.32 77.4535 361.005 102.597 359.305 111.152C358.17 110.762 358.705 111.024 357.815 109.978C348.595 101.215 339.085 90.766 329.741 81.58C323.576 90.476 317.497 99.4315 311.506 108.446C310.513 109.957 306.463 115.948 305.891 117.308C304.136 118.823 303.897 118.982 301.681 119.644C307.198 105.851 311.598 93.812 311.65 78.6955C311.762 46.352 299.06 25.0135 277.512 2.78054Z" fill="#FACA71"/>
<path d="M277.512 2.78052C284.638 2.42802 307.217 19.9145 312.684 24.762C332.687 42.5 342.664 66.495 351.76 90.9685C353.605 95.937 357.215 105.145 357.815 109.978C348.595 101.215 339.085 90.766 329.741 81.58C323.576 90.476 317.497 99.4315 311.506 108.446C310.513 109.957 306.463 115.948 305.891 117.308C304.136 118.823 303.897 118.982 301.681 119.644C307.198 105.851 311.598 93.812 311.65 78.6955C311.762 46.352 299.06 25.0135 277.512 2.78052Z" fill="#F4972E"/>
<path d="M375.17 33.285C394.42 48.914 406.245 70.157 410.285 94.5105C412.52 113.337 410.665 130.468 405.575 148.655C404.4 152.86 402.795 156.754 400.035 160.209C395.065 166.337 390.785 172.476 386.63 179.2C376.23 178.775 365.82 178.561 355.41 178.558C356.92 172.869 365.44 163.225 364.575 159.044L364.835 158.695C368.895 132.116 371.065 103.652 367.4 76.9855C366.535 70.721 365.485 62.97 363.125 57.1175C363.105 60.4655 363.26 65.67 362.61 68.776L362.595 53.7195C371.97 57.674 384.445 65.329 393.845 70.404C391.19 64.5035 375.615 36.569 375.17 33.285Z" fill="#F08223"/>
<path d="M364.835 158.695L365.335 159.336C366.41 159.804 366.925 158.388 367.76 157.297C382.32 138.281 395.435 117.849 407.13 96.9625C408 95.4115 408.71 95.0415 410.285 94.5105C412.52 113.337 410.665 130.468 405.575 148.655C404.4 152.859 402.795 156.753 400.035 160.208C395.065 166.337 390.785 172.476 386.63 179.2C376.23 178.775 365.82 178.56 355.41 178.557C356.92 172.869 365.44 163.225 364.575 159.044L364.835 158.695Z" fill="#EC6D25"/>
<path d="M305.891 117.307C315.324 124.841 327.596 132.809 337.444 140.221C343.616 144.868 358.225 155.846 364.575 159.044C365.44 163.225 356.92 172.869 355.41 178.557L330.998 179.013C319.784 160.429 308.678 149.398 292.301 135.801C295.778 131.081 298.905 124.877 301.682 119.644C303.898 118.982 304.137 118.822 305.891 117.307Z" fill="#DA4525"/>
<path d="M391.3 387.51C394.78 392.36 393.89 404.415 395.53 410.515C396.885 407.465 398.615 403.055 400.525 400.49L402.92 399.505L403.325 399.83C402.965 400.88 402.735 400.975 402.89 402.05L402.535 402.7C399.655 408.015 398.105 413.93 395.85 418.79C392.56 425.88 380.88 439.145 374.165 442.94C373.72 443.15 373.27 443.35 372.815 443.54C362.92 447.725 354.82 447.155 345.152 443.2C338.889 439.77 326.289 427.235 323.247 420.585C318.345 409.865 319.43 405.595 310.295 396.915C311.11 395.2 311.019 395.015 312.706 394.09L317.784 399.91C319.351 403.11 321.285 407.76 322.883 410.61C323.355 404.335 323.986 392.97 326.628 387.57C331.109 393.6 335.67 399.565 340.31 405.47C341.384 407.225 341.637 407.735 343.047 409.185C345.404 406.69 350.165 406.575 353.435 406.435C358.415 405.48 365.925 406.235 371.17 406.595C372.65 407.735 373.905 408.695 375.295 409.935C376.56 407.9 377.88 405.9 379.26 403.94C383.415 398.565 387.425 393.09 391.3 387.51Z" fill="white"/>
<path d="M353.435 406.435C358.415 405.48 365.925 406.235 371.17 406.595C372.65 407.735 373.905 408.695 375.295 409.935C377.705 414.175 380.255 416.815 377.215 421.29C371.795 426.65 367.335 429.325 361.405 433.885C360.82 434.335 360.445 435.205 360.31 435.96L360.045 435.97L358.725 434.39C353.57 431.78 343.19 425.065 340.459 419.45C339.283 417.03 341.936 411.78 343.047 409.185C345.404 406.69 350.165 406.575 353.435 406.435Z" fill="#DA4525"/>
<path d="M343.047 409.185C345.404 406.69 350.165 406.575 353.435 406.435C355.745 406.565 356 406.215 357.685 407.275C358.015 409.36 357.065 410.69 356.205 412.93C354.165 413.615 352.455 414.045 350.65 415.21L350.685 415.725C352.32 416.065 352.57 416.035 354.185 416.05C357.665 419.43 358.67 429.75 358.725 434.39C353.57 431.78 343.19 425.065 340.459 419.45C339.283 417.03 341.936 411.78 343.047 409.185Z" fill="#DA4525"/>
<path d="M353.435 406.435C358.415 405.48 365.925 406.235 371.17 406.595C372.65 407.735 373.905 408.695 375.295 409.935C377.705 414.175 380.255 416.815 377.215 421.29C375.29 419.28 373.645 418.815 371.065 417.705C369.065 417.25 366.495 416.96 364.42 416.665C361.13 416.565 357.5 416.26 354.185 416.05C352.57 416.035 352.32 416.065 350.685 415.725L350.65 415.21C352.455 414.045 354.165 413.615 356.205 412.93C357.065 410.69 358.015 409.36 357.685 407.275C356 406.215 355.745 406.565 353.435 406.435Z" fill="#E75F21"/>
<path d="M356.205 412.93C358.905 412.9 361.175 412.78 363.855 413.19L364.42 416.665C361.13 416.565 357.5 416.26 354.185 416.05C352.57 416.035 352.32 416.065 350.685 415.725L350.65 415.21C352.455 414.045 354.165 413.615 356.205 412.93Z" fill="#F69723"/>
<path d="M363.855 413.19C367.75 413.97 368.875 414.365 371.065 417.705C369.065 417.25 366.495 416.96 364.42 416.665L363.855 413.19Z" fill="#FACA71"/>
<path d="M391.3 387.51C394.78 392.36 393.89 404.415 395.53 410.515C393.755 419.015 392.145 422.945 386.235 429.465C378.375 438.13 371.005 438.17 360.31 435.96C360.445 435.205 360.82 434.335 361.405 433.885C367.335 429.325 371.795 426.65 377.215 421.29C380.255 416.815 377.705 414.175 375.295 409.935C376.56 407.9 377.88 405.9 379.26 403.94C383.415 398.565 387.425 393.09 391.3 387.51Z" fill="white"/>
<path d="M310.295 396.915C311.11 395.2 311.019 395.015 312.706 394.09L317.784 399.91C319.351 403.11 321.285 407.76 322.883 410.61C325.461 429.71 340.655 441.145 360.045 435.97L360.31 435.96C371.005 438.17 378.375 438.13 386.235 429.465C392.145 422.945 393.755 419.015 395.53 410.515C396.885 407.465 398.615 403.055 400.525 400.49L402.92 399.505L403.325 399.83C402.965 400.88 402.735 400.975 402.89 402.05L402.535 402.7C399.655 408.015 398.105 413.93 395.85 418.79C392.56 425.88 380.88 439.145 374.165 442.94C373.72 443.15 373.27 443.35 372.815 443.54C362.92 447.725 354.82 447.155 345.152 443.2C338.889 439.77 326.289 427.235 323.247 420.585C318.345 409.865 319.43 405.595 310.295 396.915Z" fill="#DA4525"/>
<path d="M345.152 443.2C348.635 441.88 367.265 440.27 371.56 441.395C372.025 441.515 373.65 442.595 374.165 442.94C373.72 443.15 373.27 443.35 372.815 443.54C362.92 447.725 354.82 447.155 345.152 443.2Z" fill="#F08223"/>
<path d="M493.63 279.473C496.115 273.944 507.95 265.255 511.51 259.438C512.315 258.123 515.515 254.523 516.71 253.414C523.095 267.832 529.665 282.164 536.43 296.405C539.525 302.994 545.005 313.765 547.345 320.385C531.46 338.03 505.54 355.425 481.965 358.905C493.275 353.14 504.665 347.535 516.13 342.095C508.71 332.79 502.46 323.37 496.09 313.35C502.59 312.415 508.96 310.875 515.345 309.365C508.2 299.179 500.235 290.169 493.63 279.473Z" fill="#EC6D25"/>
</g>
<defs>
<clipPath id="clip0_2002_138">
<rect width="718" height="480" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 36 KiB

+119
View File
@@ -0,0 +1,119 @@
import './i18n'
import { useEffect, useState } from 'react'
import { useUiStore } from './stores/ui-store'
import { AppShell } from './components/layout/AppShell'
import { ChatCanvas } from './components/chat/ChatCanvas'
import { OnboardingWizard } from './components/onboarding/OnboardingWizard'
import { wails } from './lib/wails'
import { initWsClient } from './lib/ws'
import { initApiClient } from './lib/api'
import { useSessionStore } from './stores/session-store'
import { useChatStore } from './stores/chat-store'
import { ErrorBoundary } from './components/common/ErrorBoundary'
import { Toaster } from './components/common/Toaster'
import { SplashScreen } from './components/common/SplashScreen'
function AppReady() {
const toggleSidebar = useUiStore((s) => s.toggleSidebar)
const openSettings = useUiStore((s) => s.openSettings)
const closeSettings = useUiStore((s) => s.closeSettings)
const activeView = useUiStore((s) => s.activeView)
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const mod = e.metaKey || e.ctrlKey
if (mod && e.key === 'b') { e.preventDefault(); toggleSidebar() }
if (mod && e.key === 'n') {
e.preventDefault()
// "New Chat" — clear session + chat directly (avoids duplicate useSessions instance)
useSessionStore.getState().setActiveSession(null)
useChatStore.getState().clear()
}
if (mod && e.key === ',') { e.preventDefault(); openSettings() }
if (e.key === 'Escape' && activeView === 'settings') { closeSettings() }
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [toggleSidebar, openSettings, closeSettings, activeView])
return (
<>
<AppShell>
<ChatCanvas />
</AppShell>
<Toaster />
</>
)
}
function App() {
const theme = useUiStore((s) => s.theme)
const onboarded = useUiStore((s) => s.onboarded)
const completeOnboarding = useUiStore((s) => s.completeOnboarding)
const [ready, setReady] = useState(false)
const [splashDone, setSplashDone] = useState(false)
useEffect(() => {
document.documentElement.classList.toggle('dark', theme === 'dark')
}, [theme])
useEffect(() => {
const splashMin = new Promise((r) => setTimeout(r, 2500))
const init = async () => {
let attempts = 0
while (attempts < 30) {
try {
const isReady = await wails.isGatewayReady()
if (isReady) break
} catch { /* not ready yet */ }
await new Promise((r) => setTimeout(r, 500))
attempts++
}
let token = ''
try { token = await wails.getGatewayToken() } catch (e) {
console.warn('[app] failed to get token:', e)
}
const gatewayUrl = await wails.getGatewayURL()
const wsUrl = gatewayUrl.replace(/^http/, 'ws') + '/ws'
initWsClient(wsUrl, token)
const api = initApiClient(gatewayUrl, token)
setReady(true)
// Auto-detect empty DB → reset onboarded flag (handles DB deletion)
try {
const [pRes, aRes] = await Promise.allSettled([
api.get<{ providers?: unknown[] | null }>('/v1/providers'),
api.get<{ agents?: unknown[] | null }>('/v1/agents'),
])
const hasProviders = pRes.status === 'fulfilled' && (pRes.value.providers?.length ?? 0) > 0
const hasAgents = aRes.status === 'fulfilled' && (aRes.value.agents?.length ?? 0) > 0
if (!hasProviders && !hasAgents) {
useUiStore.getState().resetOnboarding()
}
} catch { /* ignore — onboarding wizard will handle */ }
}
// Wait for both gateway init AND minimum splash duration
Promise.all([init(), splashMin]).then(() => setSplashDone(true))
}, [])
if (!splashDone) {
return <SplashScreen ready={ready} />
}
if (!onboarded) {
return <OnboardingWizard onComplete={completeOnboarding} />
}
return (
<ErrorBoundary>
<AppReady />
</ErrorBoundary>
)
}
export default App
@@ -0,0 +1,34 @@
import type { AgentStatus } from '../../stores/agent-store'
interface AgentAvatarProps {
name: string
status: AgentStatus
size?: 'sm' | 'md'
emoji?: string
}
const STATUS_CLASSES: Record<AgentStatus, string> = {
online: 'bg-success',
busy: 'bg-warning animate-pulse',
offline: 'bg-error',
idle: 'border-2 border-idle bg-transparent',
}
export function AgentAvatar({ name, status, size = 'sm', emoji }: AgentAvatarProps) {
const dim = size === 'sm' ? 28 : 36
const textSize = size === 'sm' ? 'text-[11px]' : 'text-sm'
return (
<div className="relative shrink-0" style={{ width: dim, height: dim }}>
<div
className={`flex items-center justify-center rounded-full ${emoji ? 'bg-accent/10' : 'bg-accent text-white'} font-semibold select-none ${textSize}`}
style={{ width: dim, height: dim }}
>
{emoji || name.charAt(0).toUpperCase()}
</div>
<span
className={`absolute bottom-0 right-0 w-2 h-2 rounded-full ring-1 ring-surface-secondary ${STATUS_CLASSES[status]}`}
/>
</div>
)
}
Loaded 100 of 260 files, more files were not shown because too many files have changed in this diff. Show more