mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-15 20:15:48 +00:00
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:
1 parent
0dab087fa3
commit
e183b459c9
260 files changed
+36927
-142
No files matched your search
+13
@@ -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/
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package cmd
|
||||
|
||||
// RunGateway starts the gateway server.
|
||||
// Exported for embedding in desktop app.
|
||||
func RunGateway() {
|
||||
runGateway()
|
||||
}
|
||||
+12
-5
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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(¤t)
|
||||
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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 + "]"
|
||||
}
|
||||
@@ -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 |
@@ -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.
@@ -0,0 +1 @@
|
||||
{"onlyBuiltDependencies":["esbuild"]}
|
||||
+74
@@ -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 |
Vendored
+22
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+2703
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 |
@@ -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
Reference in new issue
Block a user