Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d7c0205c50 | |||
| 196b543bee | |||
| 3d4e6d0939 | |||
| f94fddddb1 | |||
| 631bb64109 | |||
| 4f8f2f4190 | |||
| 1689167a7b | |||
| 5cd81f325f | |||
| cd010fc7a1 | |||
| e3a4a3c5c7 | |||
| 4b3f0b1b55 | |||
| e459775740 | |||
| 5718685c40 | |||
| 5c899d1635 | |||
| 8db2141d45 | |||
| 3198600031 | |||
| 81a3470407 |
@@ -18,6 +18,14 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
go-version: '1.26'
|
go-version: '1.26'
|
||||||
|
|
||||||
|
- name: Set up Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '24'
|
||||||
|
|
||||||
|
- name: Enable pnpm
|
||||||
|
run: corepack enable
|
||||||
|
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
@@ -31,9 +39,37 @@ jobs:
|
|||||||
- name: Download dependencies
|
- name: Download dependencies
|
||||||
run: go mod download
|
run: go mod download
|
||||||
|
|
||||||
|
- name: Set up Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 'lts/*'
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
run: npm install -g pnpm
|
||||||
|
|
||||||
|
- name: Build UI
|
||||||
|
run: |
|
||||||
|
cd ui
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm run build
|
||||||
|
|
||||||
- name: Tidy modules
|
- name: Tidy modules
|
||||||
run: go mod tidy
|
run: go mod tidy
|
||||||
|
|
||||||
|
- name: Set up Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 'lts/*'
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
run: npm install -g pnpm
|
||||||
|
|
||||||
|
- name: Build UI
|
||||||
|
run: |
|
||||||
|
cd ui
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm run build
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: go test ./...
|
run: go test ./...
|
||||||
|
|
||||||
|
|||||||
@@ -36,3 +36,4 @@ ui/.svelte-kit/
|
|||||||
internal/app/ui/dist/*
|
internal/app/ui/dist/*
|
||||||
!internal/app/ui/dist/placeholder.txt
|
!internal/app/ui/dist/placeholder.txt
|
||||||
.codex
|
.codex
|
||||||
|
.worktrees/
|
||||||
|
|||||||
@@ -74,6 +74,38 @@ The AMCS directory is used to store configuration and code for the Avalon Memory
|
|||||||
| `describe_tools` | List all available MCP tools with names, descriptions, categories, and model-authored usage notes; call this at the start of a session to orient yourself |
|
| `describe_tools` | List all available MCP tools with names, descriptions, categories, and model-authored usage notes; call this at the start of a session to orient yourself |
|
||||||
| `annotate_tool` | Persist your own usage notes for a specific tool; notes are returned by `describe_tools` in future sessions |
|
| `annotate_tool` | Persist your own usage notes for a specific tool; notes are returned by `describe_tools` in future sessions |
|
||||||
|
|
||||||
|
## Webhook ingestion
|
||||||
|
|
||||||
|
External automation can create thoughts without speaking MCP by posting JSON to `POST /webhooks/thoughts`. The endpoint is protected by the same AMCS authentication middleware as MCP and file uploads, so pass one configured API key via `x-brain-key`, an authorization bearer token header, or another enabled auth method.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8080/webhooks/thoughts \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-H 'x-brain-key: <api-key>' \
|
||||||
|
-H 'Idempotency-Key: n8n-run-123' \
|
||||||
|
-d '{
|
||||||
|
"content": "External system observed build failure on main",
|
||||||
|
"project": "amcs",
|
||||||
|
"source": "n8n",
|
||||||
|
"type": "task",
|
||||||
|
"topics": ["ci", "webhook"],
|
||||||
|
"metadata": {"workflow": "ci-monitor", "run_id": "123"}
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Payload fields:
|
||||||
|
|
||||||
|
- `content` is required and becomes the thought content.
|
||||||
|
- `project` is optional; when present it must match an existing AMCS project.
|
||||||
|
- `source`, `type`, `topics`, `people`, `action_items`, and `dates_mentioned` are normalized into the standard thought metadata schema. Unknown `type` values fall back to `observation`.
|
||||||
|
- `metadata` or `source_metadata` may contain safe source-specific JSON values; unsupported values and overly deep objects are dropped rather than persisted.
|
||||||
|
- `idempotency_key` or the `Idempotency-Key` header can be supplied to make repeated webhook deliveries return the existing thought with `duplicate: true`.
|
||||||
|
- `external_id` is stored under `metadata.webhook.external_id` for source-side traceability.
|
||||||
|
|
||||||
|
Successful new ingestion returns `201` with the created thought. Duplicate idempotency-key delivery returns `200` and the previously created thought. Invalid JSON, missing content, missing/unknown projects, or unauthenticated requests are rejected before persistence. Metadata and embedding enrichment are queued after the thought is stored.
|
||||||
|
|
||||||
## Learnings
|
## Learnings
|
||||||
|
|
||||||
Learnings are curated, structured memory records for durable insights you want to keep distinct from raw thoughts. Use them for normalized lessons, decisions, and evidence-backed findings that should be easy to retrieve and review over time.
|
Learnings are curated, structured memory records for durable insights you want to keep distinct from raw thoughts. Use them for normalized lessons, decisions, and evidence-backed findings that should be easy to retrieve and review over time.
|
||||||
|
|||||||
@@ -23,12 +23,15 @@ auth:
|
|||||||
keys:
|
keys:
|
||||||
- id: "local-client"
|
- id: "local-client"
|
||||||
value: "replace-me"
|
value: "replace-me"
|
||||||
|
tenant_id: "local"
|
||||||
|
superadmin: true
|
||||||
description: "main local client key"
|
description: "main local client key"
|
||||||
oauth:
|
oauth:
|
||||||
clients:
|
clients:
|
||||||
- id: "oauth-client"
|
- id: "oauth-client"
|
||||||
client_id: ""
|
client_id: ""
|
||||||
client_secret: ""
|
client_secret: ""
|
||||||
|
superadmin: false
|
||||||
description: "optional OAuth client credentials"
|
description: "optional OAuth client credentials"
|
||||||
|
|
||||||
database:
|
database:
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ auth:
|
|||||||
keys:
|
keys:
|
||||||
- id: "local-client"
|
- id: "local-client"
|
||||||
value: "replace-me"
|
value: "replace-me"
|
||||||
|
tenant_id: "local"
|
||||||
|
superadmin: true
|
||||||
description: "main local client key"
|
description: "main local client key"
|
||||||
oauth:
|
oauth:
|
||||||
clients:
|
clients:
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ auth:
|
|||||||
keys:
|
keys:
|
||||||
- id: "local-client"
|
- id: "local-client"
|
||||||
value: "replace-me"
|
value: "replace-me"
|
||||||
|
tenant_id: "local"
|
||||||
|
superadmin: true
|
||||||
description: "main local client key"
|
description: "main local client key"
|
||||||
oauth:
|
oauth:
|
||||||
clients:
|
clients:
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Tenant identity management
|
||||||
|
|
||||||
|
The previous tenancy implementation used every API-key ID as its tenant key. This meant that two keys could not share data and old unscoped rows could not be reached by any key after tenancy was enabled.
|
||||||
|
|
||||||
|
Implemented a separate tenant mapping: configured and managed API keys now resolve to an assigned tenant ID, while unassigned configured keys retain their historical key-ID tenant boundary for compatibility. Added tenant, tenant-user, key-assignment, and managed-secret records, with a one-time, explicit legacy adoption endpoint that assigns only `NULL` tenant-key rows to a selected tenant.
|
||||||
|
|
||||||
|
The admin UI now has an Identity page for tenants, users, configured-key assignment, managed-key creation (secret shown once), disabling keys, and legacy adoption. Managed secrets are stored only as SHA-256 hashes. The AMCS MCP capture-thought tool was unavailable in this session, so this local log is the required fallback summary.
|
||||||
|
|
||||||
|
Configured keys also accept an optional `auth.keys[].tenant_id`. This supplies the initial tenant boundary at startup; an explicit assignment saved through the Identity UI overrides it.
|
||||||
|
|
||||||
|
Tenant-owned root records now reference `tenants(id)` through `tenant_id`; tenant scoped resources include skills, guardrails, personas, parts, traits, and character arcs. Tenant-selection in the UI is sent as `X-AMCS-Tenant-ID` for admin/ResolveSpec requests.
|
||||||
|
|
||||||
|
With explicit approval that tenancy data is disposable, the compatibility migration now renames the ownership column from `tenant_key` to `tenant_id` across the historical migration chain.
|
||||||
|
|
||||||
|
Tenant selection now centrally filters every tenant-owned ResolveSpec read, update, and delete in the admin UI, and injects the selected `tenant_id` on create requests.
|
||||||
|
|
||||||
|
Identity mutations now require a configured API key with `superadmin: true`; tenants themselves remain database-managed.
|
||||||
|
|
||||||
|
Fixed the admin sidebar tenant-scope selector flicker. Its focus handler reloaded the tenant list and disabled the native select while its picker was opening; tenants now load on sidebar mount only, so a selection remains open and usable. Validated with `pnpm check` (0 errors, 0 warnings).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# ResolveSpec array scanning
|
||||||
|
|
||||||
|
Diagnosed `/api/rs/public/agent_skills` failing to scan `tags text[]`. The generated Bun model correctly uses `sqltypes.SqlStringArray`, which implements `sql.Scanner`, but ResolveSpec's read path supplied a separate scan destination instead of scanning the query's registered Bun model. Updated the vendored ResolveSpec handler to call `ScanModel`, and for single-row reads to set the single record as the query model first. This preserves Bun's model field scanner for PostgreSQL arrays.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Per-user tenancy progress review
|
||||||
|
|
||||||
|
Reviewed `docs/per-user-tenancy-plan.md` against the staged worktree. Identity, tenant mappings, schema/model migrations, and primary project/thought/file/catalog scoping are present. The plan's named hardening targets—learnings, plans, chat histories, thought-learning links, and project persona joins—still have no tenant helper predicates. Only auth/keyring tests are staged; the plan's cross-tenant store/tool and migration test matrix has not yet been added or run.
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
# Per-user tenancy implementation plan
|
||||||
|
|
||||||
|
## Scope and current state
|
||||||
|
|
||||||
|
Gitea issue #10 asks AMCS to isolate memories by user, tenant, or workspace. The current branch already carries the first implementation pass, so this plan records the intended model and the remaining hardening work another worker should verify or finish.
|
||||||
|
|
||||||
|
Observed code paths:
|
||||||
|
|
||||||
|
- Authentication enters through `internal/auth/middleware.go`. API-key auth uses the configured header, defaulting to `x-brain-key`; bearer tokens can resolve through OAuth `TokenStore` or API keyring; HTTP Basic resolves OAuth client credentials.
|
||||||
|
- `internal/auth/middleware.go` writes both `auth.key_id` and `tenancy.tenant_id` into the request context. The tenant key is intentionally opaque and currently equals the authenticated key id or OAuth client id.
|
||||||
|
- `internal/tenancy/tenancy.go` exposes `WithTenantKey` and `KeyFromContext` only; callers should not infer user semantics from the tenant string.
|
||||||
|
- Schema already has `tenant_id` on `projects`, `thoughts`, `stored_files`, `learnings`, `plans`, and `chat_histories` in `schema/*.dbml`.
|
||||||
|
- `internal/store/tenancy.go` provides helper functions for appending tenant predicates to SQL.
|
||||||
|
- Tenant scoping is already present in project, thought, and stored-file store paths. Some other project-owned domains still need explicit tenant enforcement.
|
||||||
|
|
||||||
|
## Tenant/user identity source
|
||||||
|
|
||||||
|
Use the authenticated principal id as the tenant boundary:
|
||||||
|
|
||||||
|
1. API key: resolve token through `auth.Keyring.Lookup`; use returned key id as `tenant_id`.
|
||||||
|
2. OAuth bearer token: resolve token through `TokenStore.Lookup`; use returned client id/key id as `tenant_id`.
|
||||||
|
3. OAuth Basic client credentials: resolve through `OAuthRegistry.Lookup`; use returned client id as `tenant_id`.
|
||||||
|
4. Unauthenticated requests must not get tenant context and must not reach protected MCP/API handlers.
|
||||||
|
|
||||||
|
Do not store raw API keys or bearer tokens in tenant columns. Store only stable configured key ids/client ids. Yes, it is less flashy than inventing an account service before breakfast, but it keeps the trust boundary small and auditable.
|
||||||
|
|
||||||
|
## Required schema/model changes
|
||||||
|
|
||||||
|
Source-of-truth DBML changes:
|
||||||
|
|
||||||
|
- Add nullable `tenant_id text` to all user-owned tables:
|
||||||
|
- `projects`
|
||||||
|
- `thoughts`
|
||||||
|
- `stored_files`
|
||||||
|
- `learnings`
|
||||||
|
- `plans`
|
||||||
|
- `chat_histories`
|
||||||
|
- Add tenant indexes:
|
||||||
|
- single-column `tenant_id` for direct filtering
|
||||||
|
- `(tenant_id, name)` unique on `projects`, replacing global `projects.name` uniqueness
|
||||||
|
- `(tenant_id, project_id)` on `thoughts` for common project-scoped memory lookups
|
||||||
|
- Regenerate SQL migrations and generated models from DBML; do not hand-edit generated Go models except as a temporary debugging step.
|
||||||
|
|
||||||
|
Important follow-up: `agent_skills`, `agent_guardrails`, `agent_personas`, `agent_parts`, traits, and arcs are currently global catalogs. Keep them global unless product requirements say skills/personas are private per tenant. Project join tables inherit protection through tenant-scoped project ids, but direct join queries must verify the project belongs to the tenant.
|
||||||
|
|
||||||
|
## Query scoping strategy
|
||||||
|
|
||||||
|
All request-facing store methods that touch tenant-owned rows must include tenant predicates whenever `tenancy.KeyFromContext(ctx)` returns a key.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Inserts must populate `tenant_id` from context.
|
||||||
|
- Gets/updates/deletes by id or guid must add `and tenant_id = $n`.
|
||||||
|
- Lists/searches must add `tenant_id = $n` before other filters.
|
||||||
|
- Joins must scope the tenant-owned root table. Example: project summaries should count only thoughts that belong to the same tenant as the project, not merely thoughts with the same `project_id`.
|
||||||
|
- Background maintenance jobs must either run per tenant, carry tenant context explicitly, or intentionally operate cross-tenant with an internal-only code path documented in the job.
|
||||||
|
|
||||||
|
Already-scoped paths to keep:
|
||||||
|
|
||||||
|
- `internal/store/projects.go`: create/get/list/touch projects.
|
||||||
|
- `internal/store/thoughts.go`: create/list/get/update/delete/archive/search/stats/embedding repair paths.
|
||||||
|
- `internal/store/files.go`: insert/get/list stored files.
|
||||||
|
|
||||||
|
Paths needing audit/hardening:
|
||||||
|
|
||||||
|
- `internal/store/learnings.go`: `CreateLearning`, `GetLearning`, and `ListLearnings` should populate/filter by tenant.
|
||||||
|
- `internal/store/plans.go`: `CreatePlan`, `GetPlan`, `GetPlanDetail`, `UpdatePlan`, `DeletePlan`, `ListPlans`, dependency/related-plan operations, and plan skill/guardrail joins should scope to tenant-owned plans.
|
||||||
|
- `internal/store/chat_histories.go`: chat history create/get/list/update paths should populate/filter by tenant.
|
||||||
|
- `internal/store/thought_learning_links.go`: links traverse tenant-owned thoughts/learnings; queries should join and scope both sides or at least the tenant-owned root.
|
||||||
|
- `internal/store/skills.go` and `internal/store/project_personas.go`: project-scoped joins should verify the project is visible to the current tenant before returning linked global catalog records.
|
||||||
|
- ResolveSpec/admin CRUD endpoints exposing generated models must not bypass tenant-aware store methods. If they use direct generic model access, add middleware-level filter injection or disable tenant-owned tables from generic admin writes.
|
||||||
|
|
||||||
|
## Migration and backfill
|
||||||
|
|
||||||
|
Migration approach for existing single-tenant installs:
|
||||||
|
|
||||||
|
1. Add nullable `tenant_id` columns first; do not make them `not null` initially because existing deployments have historical rows without a principal.
|
||||||
|
2. Backfill existing rows to a configured default tenant only if the instance enables multi-tenant mode or defines `auth.default_tenant_id`. Otherwise leave `NULL` rows visible only to unauthenticated/internal single-tenant contexts.
|
||||||
|
3. Replace global project-name uniqueness with `(tenant_id, name)` uniqueness. For PostgreSQL, preserve legacy `NULL` semantics carefully: multiple null-tenant projects with the same name may be possible unless a partial unique index is added for null tenant rows.
|
||||||
|
4. Add indexes concurrently where practical for production-sized tables.
|
||||||
|
5. Document that after enabling auth-backed tenancy, legacy null-tenant data is not visible to authenticated tenants unless backfilled.
|
||||||
|
|
||||||
|
Recommended config addition:
|
||||||
|
|
||||||
|
- `auth.default_tenant_id` or `tenancy.default_key` for one-time/self-hosted backfill and development.
|
||||||
|
- Optional `tenancy.mode: single|authenticated` so operators can keep current single-user behavior deliberately instead of discovering isolation by accident. An accident in auth is just a breach with better branding.
|
||||||
|
|
||||||
|
## Authorization checks
|
||||||
|
|
||||||
|
Authorization is row ownership by tenant key:
|
||||||
|
|
||||||
|
- A request may only see or mutate rows whose `tenant_id` equals the authenticated tenant key.
|
||||||
|
- Cross-tenant ids/gids should behave as not found, not forbidden, to avoid existence leaks.
|
||||||
|
- Tenant key is server-derived only. Ignore any client-provided `tenant_id` fields on tool/API inputs.
|
||||||
|
- Project id references in create/update operations must be validated against the same tenant before use. This prevents attaching a new thought/file/plan to another tenant's project id.
|
||||||
|
- Relationship operations must verify both endpoints are in the same tenant before creating links.
|
||||||
|
- Global catalogs can be read across tenants only if intentionally shared; project-specific associations must be tenant-checked through the project.
|
||||||
|
|
||||||
|
## Affected endpoints/services/UI surfaces
|
||||||
|
|
||||||
|
Backend/MCP tools:
|
||||||
|
|
||||||
|
- Project tools: create/get/list/set active project.
|
||||||
|
- Thought tools: capture, retrieve, update, delete/archive, semantic search, text search, stats, metadata and embedding repair queues.
|
||||||
|
- File tools and binary file upload/download APIs.
|
||||||
|
- Learning tools and thought-learning link tools.
|
||||||
|
- Plan/task tools including dependencies, related plans, skills, and guardrails.
|
||||||
|
- Chat history/session persistence tools.
|
||||||
|
- Persona/skill/guardrail project-association tools.
|
||||||
|
|
||||||
|
HTTP/API boundaries:
|
||||||
|
|
||||||
|
- MCP SSE and streamable HTTP handlers must run behind auth middleware when auth is configured.
|
||||||
|
- Any non-MCP REST endpoints under the admin/API server must either use tenant-aware stores or explicitly be internal/admin-only.
|
||||||
|
- ResolveSpec admin CRUD views need tenant filter injection or table-level access restrictions for tenant-owned models.
|
||||||
|
|
||||||
|
UI:
|
||||||
|
|
||||||
|
- Project selector/list should naturally show tenant-scoped projects.
|
||||||
|
- Admin tables for projects/thoughts/files/learnings/plans/chat histories must not show `tenant_id` as an editable field.
|
||||||
|
- If a tenant switcher is ever added, it must map to a server-side authenticated principal or admin impersonation path, not a client-side query parameter. Obviously.
|
||||||
|
|
||||||
|
## Test cases
|
||||||
|
|
||||||
|
Minimum test matrix:
|
||||||
|
|
||||||
|
1. Auth middleware propagates tenant key for API-key header auth.
|
||||||
|
2. Auth middleware propagates tenant key for bearer token via OAuth token store.
|
||||||
|
3. Auth middleware propagates tenant key for HTTP Basic OAuth client credentials.
|
||||||
|
4. Tenant A and tenant B can create projects with the same name; each only lists its own project.
|
||||||
|
5. Tenant A cannot get/update/delete/archive Tenant B's thought by guid or numeric id.
|
||||||
|
6. Semantic and text search return only same-tenant thoughts, including project-filtered searches.
|
||||||
|
7. Stored file get/list/download is scoped; a file id from another tenant returns not found.
|
||||||
|
8. Learning create/list/get is scoped, including links to thoughts.
|
||||||
|
9. Plan create/list/get/update/delete and dependency/related-plan operations are scoped.
|
||||||
|
10. Project skill/persona/guardrail association reads verify the project belongs to the tenant.
|
||||||
|
11. Background metadata/embedding retry queues do not cross tenants unless intentionally internal.
|
||||||
|
12. Migration test covers legacy null-tenant rows and backfill to default tenant.
|
||||||
|
13. ResolveSpec/admin API tests prove tenant-owned resources are filtered or unavailable without admin override.
|
||||||
|
|
||||||
|
## Assumptions and blockers
|
||||||
|
|
||||||
|
Assumptions:
|
||||||
|
|
||||||
|
- The first tenancy boundary is authenticated principal id, not human account, org, or workspace. This is consistent with the current auth system and avoids adding an account model prematurely.
|
||||||
|
- Null `tenant_id` remains the compatibility path for existing single-tenant/internal flows.
|
||||||
|
- Global skills/personas/guardrails remain shared catalogs until a separate product decision makes them tenant-private.
|
||||||
|
|
||||||
|
Blockers/decisions needed:
|
||||||
|
|
||||||
|
- Decide whether legacy rows should be backfilled automatically to a configured default tenant during migration or left null until an operator runs an explicit backfill.
|
||||||
|
- Decide whether ResolveSpec admin endpoints are trusted admin-only or must enforce tenant predicates like MCP tools.
|
||||||
|
- Decide whether future OAuth subjects should distinguish user id from client id; the current implementation only has client/key id available.
|
||||||
|
|
||||||
|
## Implementation order
|
||||||
|
|
||||||
|
1. Finish tenant scoping audits for learnings, plans, chat histories, thought-learning links, and project catalog joins.
|
||||||
|
2. Add tests proving cross-tenant invisibility for each store/tool domain before widening coverage. Yes, tests first; future us has enough enemies.
|
||||||
|
3. Add config/backfill migration behavior and document operator steps.
|
||||||
|
4. Lock down or filter ResolveSpec/admin tenant-owned model access.
|
||||||
|
5. Run `make test` and `make build`; include exact results in the issue/PR comment.
|
||||||
@@ -3,7 +3,8 @@ module git.warky.dev/wdevs/amcs
|
|||||||
go 1.26.1
|
go 1.26.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bitechdev/ResolveSpec v1.1.24
|
git.warky.dev/wdevs/relspecgo v1.0.62
|
||||||
|
github.com/bitechdev/ResolveSpec v1.1.27
|
||||||
github.com/google/jsonschema-go v0.4.3
|
github.com/google/jsonschema-go v0.4.3
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/jackc/pgx/v5 v5.9.2
|
github.com/jackc/pgx/v5 v5.9.2
|
||||||
@@ -46,7 +47,6 @@ require (
|
|||||||
github.com/prometheus/procfs v0.20.1 // indirect
|
github.com/prometheus/procfs v0.20.1 // indirect
|
||||||
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
|
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
|
||||||
github.com/redis/go-redis/v9 v9.19.0 // indirect
|
github.com/redis/go-redis/v9 v9.19.0 // indirect
|
||||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
|
||||||
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||||
github.com/segmentio/asm v1.1.3 // indirect
|
github.com/segmentio/asm v1.1.3 // indirect
|
||||||
github.com/segmentio/encoding v0.5.4 // indirect
|
github.com/segmentio/encoding v0.5.4 // indirect
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
|||||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||||
entgo.io/ent v0.14.3 h1:wokAV/kIlH9TeklJWGGS7AYJdVckr0DloWjIcO9iIIQ=
|
entgo.io/ent v0.14.3 h1:wokAV/kIlH9TeklJWGGS7AYJdVckr0DloWjIcO9iIIQ=
|
||||||
entgo.io/ent v0.14.3/go.mod h1:aDPE/OziPEu8+OWbzy4UlvWmD2/kbRuWfK2A40hcxJM=
|
entgo.io/ent v0.14.3/go.mod h1:aDPE/OziPEu8+OWbzy4UlvWmD2/kbRuWfK2A40hcxJM=
|
||||||
|
git.warky.dev/wdevs/relspecgo v1.0.62 h1:byBe2IlcwQRKsV5qXHfGITtfvGZewHlNstP+O8BGnns=
|
||||||
|
git.warky.dev/wdevs/relspecgo v1.0.62/go.mod h1:JpZBvui9dYc/QcD5TZ1wKab+4bUvPw5/rOLOA/h2F6A=
|
||||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
|
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
|
||||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
|
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
|
||||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo=
|
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo=
|
||||||
@@ -34,8 +36,8 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
|
|||||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
github.com/bitechdev/ResolveSpec v1.1.24 h1:+Ku3jE8ZSQ2c6IdVyqYp9CdCh4wSasCd1yLDF8GXy5U=
|
github.com/bitechdev/ResolveSpec v1.1.27 h1:qugBwR3Qoy4tNKhAyJsFszZE+kRR0gzl5w42XE3/scY=
|
||||||
github.com/bitechdev/ResolveSpec v1.1.24/go.mod h1:GF51sMRCWbAyri2WNae3IZAFM/2s6DG6i3eTTrobbVs=
|
github.com/bitechdev/ResolveSpec v1.1.27/go.mod h1:GF51sMRCWbAyri2WNae3IZAFM/2s6DG6i3eTTrobbVs=
|
||||||
github.com/bradfitz/gomemcache v0.0.0-20260422231931-4d751bb6e37c h1:6Gpm9YYUEQx2T9zMsYolQhr6sjwwGtFitSA0pQsa7a8=
|
github.com/bradfitz/gomemcache v0.0.0-20260422231931-4d751bb6e37c h1:6Gpm9YYUEQx2T9zMsYolQhr6sjwwGtFitSA0pQsa7a8=
|
||||||
github.com/bradfitz/gomemcache v0.0.0-20260422231931-4d751bb6e37c/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c=
|
github.com/bradfitz/gomemcache v0.0.0-20260422231931-4d751bb6e37c/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c=
|
||||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
@@ -452,8 +454,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
|||||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
|
|||||||
@@ -0,0 +1,372 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
type identityAdmin struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
keyring *auth.Keyring
|
||||||
|
oauthRegistry *auth.OAuthRegistry
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func newIdentityAdmin(pool *pgxpool.Pool, keyring *auth.Keyring, oauthRegistry *auth.OAuthRegistry, logger *slog.Logger) *identityAdmin {
|
||||||
|
return &identityAdmin{pool: pool, keyring: keyring, oauthRegistry: oauthRegistry, logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *identityAdmin) isSuperadmin(keyID string) bool {
|
||||||
|
if a.keyring != nil && a.keyring.IsSuperadmin(keyID) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return a.oauthRegistry != nil && a.oauthRegistry.IsSuperadmin(keyID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadIdentityKeyring(ctx context.Context, pool *pgxpool.Pool, keyring *auth.Keyring) error {
|
||||||
|
if keyring == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rows, err := pool.Query(ctx, `select a.key_id, a.tenant_id, a.enabled, coalesce(m.secret_hash, '') from api_key_assignments a left join managed_api_keys m on m.key_id = a.key_id`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var keyID, tenantID, hash string
|
||||||
|
var enabled bool
|
||||||
|
if err := rows.Scan(&keyID, &tenantID, &enabled, &hash); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
keyring.AssignTenant(keyID, tenantID)
|
||||||
|
if hash != "" {
|
||||||
|
keyring.AddManaged(keyID, hash, enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureConfiguredTenants makes a tenant referenced in static YAML visible to
|
||||||
|
// the admin UI as well as to the authentication middleware.
|
||||||
|
func ensureConfiguredTenants(ctx context.Context, pool *pgxpool.Pool, keyring *auth.Keyring) error {
|
||||||
|
if keyring == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, key := range keyring.ConfiguredKeys() {
|
||||||
|
tenantID := strings.TrimSpace(key.TenantID)
|
||||||
|
if tenantID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, `insert into tenants (id, name) values ($1, $1) on conflict (id) do nothing`, tenantID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *identityAdmin) handler() http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
keyID, ok := auth.KeyIDFromContext(r.Context())
|
||||||
|
if !ok || !a.isSuperadmin(keyID) {
|
||||||
|
writeJSON(w, http.StatusForbidden, map[string]string{"error": "superadmin API key required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
path := strings.TrimPrefix(r.URL.Path, "/api/admin/identity")
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodGet && path == "":
|
||||||
|
a.list(w, r)
|
||||||
|
case r.Method == http.MethodPost && path == "/tenants":
|
||||||
|
a.createTenant(w, r)
|
||||||
|
case r.Method == http.MethodPost && path == "/users":
|
||||||
|
a.createUser(w, r)
|
||||||
|
case r.Method == http.MethodPost && path == "/keys":
|
||||||
|
a.createKey(w, r)
|
||||||
|
case r.Method == http.MethodPatch && strings.HasPrefix(path, "/keys/"):
|
||||||
|
a.updateKey(w, r, strings.TrimPrefix(path, "/keys/"))
|
||||||
|
case r.Method == http.MethodPost && strings.HasPrefix(path, "/tenants/") && strings.HasSuffix(path, "/adopt-legacy"):
|
||||||
|
a.adoptLegacy(w, r, strings.TrimSuffix(strings.TrimPrefix(path, "/tenants/"), "/adopt-legacy"))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type tenantDTO struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
type userDTO struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
TenantID string `json:"tenant_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Email *string `json:"email,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
type keyDTO struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
TenantID string `json:"tenant_id"`
|
||||||
|
UserID *string `json:"user_id,omitempty"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *identityAdmin) list(w http.ResponseWriter, r *http.Request) {
|
||||||
|
result := struct {
|
||||||
|
Tenants []tenantDTO `json:"tenants"`
|
||||||
|
Users []userDTO `json:"users"`
|
||||||
|
Keys []keyDTO `json:"keys"`
|
||||||
|
}{Tenants: []tenantDTO{}, Users: []userDTO{}, Keys: []keyDTO{}}
|
||||||
|
rows, err := a.pool.Query(r.Context(), `select id, name, created_at from tenants order by name`)
|
||||||
|
if err != nil {
|
||||||
|
identityError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var x tenantDTO
|
||||||
|
if err := rows.Scan(&x.ID, &x.Name, &x.CreatedAt); err != nil {
|
||||||
|
identityError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result.Tenants = append(result.Tenants, x)
|
||||||
|
}
|
||||||
|
rows, err = a.pool.Query(r.Context(), `select id, tenant_id, name, email, created_at from tenant_users order by name`)
|
||||||
|
if err != nil {
|
||||||
|
identityError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var x userDTO
|
||||||
|
if err := rows.Scan(&x.ID, &x.TenantID, &x.Name, &x.Email, &x.CreatedAt); err != nil {
|
||||||
|
identityError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result.Users = append(result.Users, x)
|
||||||
|
}
|
||||||
|
configured := make(map[string]authKey)
|
||||||
|
if a.keyring != nil {
|
||||||
|
for _, key := range a.keyring.ConfiguredKeys() {
|
||||||
|
configured[key.ID] = authKey{description: key.Description}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows, err = a.pool.Query(r.Context(), `select key_id, tenant_id, user_id, description, source, enabled, created_at from api_key_assignments order by key_id`)
|
||||||
|
if err != nil {
|
||||||
|
identityError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var x keyDTO
|
||||||
|
if err := rows.Scan(&x.ID, &x.TenantID, &x.UserID, &x.Description, &x.Source, &x.Enabled, &x.CreatedAt); err != nil {
|
||||||
|
identityError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result.Keys = append(result.Keys, x)
|
||||||
|
delete(configured, x.ID)
|
||||||
|
}
|
||||||
|
for id, key := range configured {
|
||||||
|
result.Keys = append(result.Keys, keyDTO{ID: id, Description: key.description, Source: "configured", Enabled: true})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
type authKey struct{ description string }
|
||||||
|
|
||||||
|
func (a *identityAdmin) createTenant(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if !decodeJSON(w, r, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body.Name = strings.TrimSpace(body.Name)
|
||||||
|
if body.Name == "" {
|
||||||
|
badRequest(w, "name is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
x := tenantDTO{ID: newIdentityID(), Name: body.Name}
|
||||||
|
err := a.pool.QueryRow(r.Context(), `insert into tenants (id,name) values ($1,$2) returning created_at`, x.ID, x.Name).Scan(&x.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
identityError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, x)
|
||||||
|
}
|
||||||
|
func (a *identityAdmin) createUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
TenantID, Name string
|
||||||
|
Email *string `json:"email"`
|
||||||
|
}
|
||||||
|
if !decodeJSON(w, r, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body.TenantID = strings.TrimSpace(body.TenantID)
|
||||||
|
body.Name = strings.TrimSpace(body.Name)
|
||||||
|
if body.TenantID == "" || body.Name == "" {
|
||||||
|
badRequest(w, "tenant_id and name are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
x := userDTO{ID: newIdentityID(), TenantID: body.TenantID, Name: body.Name, Email: body.Email}
|
||||||
|
err := a.pool.QueryRow(r.Context(), `insert into tenant_users (id,tenant_id,name,email) values ($1,$2,$3,$4) returning created_at`, x.ID, x.TenantID, x.Name, x.Email).Scan(&x.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
identityError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, x)
|
||||||
|
}
|
||||||
|
func (a *identityAdmin) createKey(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
TenantID string `json:"tenant_id"`
|
||||||
|
UserID *string `json:"user_id"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
if !decodeJSON(w, r, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body.TenantID = strings.TrimSpace(body.TenantID)
|
||||||
|
if body.TenantID == "" {
|
||||||
|
badRequest(w, "tenant_id is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
secret, hash, err := auth.GenerateSecret()
|
||||||
|
if err != nil {
|
||||||
|
identityError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
x := keyDTO{ID: newIdentityID(), TenantID: body.TenantID, UserID: body.UserID, Description: strings.TrimSpace(body.Description), Source: "managed", Enabled: true}
|
||||||
|
tx, err := a.pool.Begin(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
identityError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback(r.Context())
|
||||||
|
if err = tx.QueryRow(r.Context(), `insert into api_key_assignments (key_id,tenant_id,user_id,description,source,enabled) values ($1,$2,$3,$4,'managed',true) returning created_at`, x.ID, x.TenantID, x.UserID, x.Description).Scan(&x.CreatedAt); err == nil {
|
||||||
|
_, err = tx.Exec(r.Context(), `insert into managed_api_keys (key_id,secret_hash) values ($1,$2)`, x.ID, hash)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
err = tx.Commit(r.Context())
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
identityError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.keyring.AddManaged(x.ID, hash, true)
|
||||||
|
a.keyring.AssignTenant(x.ID, x.TenantID)
|
||||||
|
writeJSON(w, http.StatusCreated, struct {
|
||||||
|
Key keyDTO `json:"key"`
|
||||||
|
Secret string `json:"secret"`
|
||||||
|
}{x, secret})
|
||||||
|
}
|
||||||
|
func (a *identityAdmin) updateKey(w http.ResponseWriter, r *http.Request, keyID string) {
|
||||||
|
var body struct {
|
||||||
|
TenantID string `json:"tenant_id"`
|
||||||
|
UserID *string `json:"user_id"`
|
||||||
|
Description *string `json:"description"`
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
if !decodeJSON(w, r, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body.TenantID = strings.TrimSpace(body.TenantID)
|
||||||
|
if body.TenantID == "" {
|
||||||
|
badRequest(w, "tenant_id is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !a.keyring.IsConfigured(keyID) {
|
||||||
|
var exists bool
|
||||||
|
if err := a.pool.QueryRow(r.Context(), `select exists(select 1 from managed_api_keys where key_id=$1)`, keyID).Scan(&exists); err != nil {
|
||||||
|
identityError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
badRequest(w, "unknown key id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var x keyDTO
|
||||||
|
err := a.pool.QueryRow(r.Context(), `insert into api_key_assignments (key_id,tenant_id,user_id,description,source,enabled) values ($1,$2,$3,coalesce($4,''),case when $6 then 'configured' else 'managed' end,coalesce($5,true)) on conflict (key_id) do update set tenant_id=excluded.tenant_id,user_id=excluded.user_id,description=coalesce($4,api_key_assignments.description),enabled=coalesce($5,api_key_assignments.enabled) returning key_id,tenant_id,user_id,description,source,enabled,created_at`, keyID, body.TenantID, body.UserID, body.Description, body.Enabled, a.keyring.IsConfigured(keyID)).Scan(&x.ID, &x.TenantID, &x.UserID, &x.Description, &x.Source, &x.Enabled, &x.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
identityError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.keyring.AssignTenant(x.ID, x.TenantID)
|
||||||
|
a.keyring.SetManagedEnabled(x.ID, x.Enabled)
|
||||||
|
writeJSON(w, http.StatusOK, x)
|
||||||
|
}
|
||||||
|
func (a *identityAdmin) adoptLegacy(w http.ResponseWriter, r *http.Request, tenantID string) {
|
||||||
|
tenantID = strings.TrimSpace(tenantID)
|
||||||
|
if tenantID == "" {
|
||||||
|
badRequest(w, "tenant id is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tables := []string{"projects", "thoughts", "stored_files", "learnings", "plans", "chat_histories"}
|
||||||
|
tx, err := a.pool.Begin(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
identityError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback(r.Context())
|
||||||
|
var exists bool
|
||||||
|
if err = tx.QueryRow(r.Context(), `select exists(select 1 from tenants where id=$1)`, tenantID).Scan(&exists); err == nil && !exists {
|
||||||
|
badRequest(w, "tenant does not exist")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, table := range tables {
|
||||||
|
if _, err = tx.Exec(r.Context(), fmt.Sprintf("update %s set tenant_id=$1 where tenant_id is null", table), tenantID); err != nil {
|
||||||
|
identityError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err = tx.Commit(r.Context()); err != nil {
|
||||||
|
identityError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
func newIdentityID() string {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
func decodeJSON(w http.ResponseWriter, r *http.Request, v any) bool {
|
||||||
|
defer r.Body.Close()
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
|
||||||
|
badRequest(w, "invalid JSON")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
func badRequest(w http.ResponseWriter, message string) {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": message})
|
||||||
|
}
|
||||||
|
func identityError(w http.ResponseWriter, err error) {
|
||||||
|
if a, ok := err.(interface{ SQLState() string }); ok && a.SQLState() == "23505" {
|
||||||
|
badRequest(w, "that value already exists")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "identity operation failed"})
|
||||||
|
}
|
||||||
@@ -91,6 +91,14 @@ func Run(ctx context.Context, configPath string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
keyring = auth.NewManagedKeyring()
|
||||||
|
}
|
||||||
|
if err := ensureConfiguredTenants(ctx, db.Pool(), keyring); err != nil {
|
||||||
|
return fmt.Errorf("create configured tenants: %w", err)
|
||||||
|
}
|
||||||
|
if err := loadIdentityKeyring(ctx, db.Pool(), keyring); err != nil {
|
||||||
|
return fmt.Errorf("load identity key assignments: %w", err)
|
||||||
}
|
}
|
||||||
tokenStore = auth.NewTokenStore(0)
|
tokenStore = auth.NewTokenStore(0)
|
||||||
if len(cfg.Auth.OAuth.Clients) > 0 {
|
if len(cfg.Auth.OAuth.Clients) > 0 {
|
||||||
@@ -192,6 +200,7 @@ func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *st
|
|||||||
enrichmentRetryer := tools.NewEnrichmentRetryer(context.Background(), db, bgMetadata, cfg.Capture, cfg.AI.Metadata.Timeout, activeProjects, logger)
|
enrichmentRetryer := tools.NewEnrichmentRetryer(context.Background(), db, bgMetadata, cfg.Capture, cfg.AI.Metadata.Timeout, activeProjects, logger)
|
||||||
backfillTool := tools.NewBackfillTool(db, bgEmbeddings, activeProjects, logger)
|
backfillTool := tools.NewBackfillTool(db, bgEmbeddings, activeProjects, logger)
|
||||||
adminActions := newAdminActions(backfillTool, enrichmentRetryer, logger)
|
adminActions := newAdminActions(backfillTool, enrichmentRetryer, logger)
|
||||||
|
identityAdmin := newIdentityAdmin(db.Pool(), keyring, oauthRegistry, logger)
|
||||||
|
|
||||||
toolSet := mcpserver.ToolSet{
|
toolSet := mcpserver.ToolSet{
|
||||||
Capture: tools.NewCaptureTool(db, embeddings, cfg.Capture, activeProjects, enrichmentRetryer, backfillTool),
|
Capture: tools.NewCaptureTool(db, embeddings, cfg.Capture, activeProjects, enrichmentRetryer, backfillTool),
|
||||||
@@ -202,12 +211,14 @@ func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *st
|
|||||||
Update: tools.NewUpdateTool(db, embeddings, metadata, cfg.Capture, logger),
|
Update: tools.NewUpdateTool(db, embeddings, metadata, cfg.Capture, logger),
|
||||||
Delete: tools.NewDeleteTool(db),
|
Delete: tools.NewDeleteTool(db),
|
||||||
Archive: tools.NewArchiveTool(db),
|
Archive: tools.NewArchiveTool(db),
|
||||||
|
DuplicateAudit: tools.NewDuplicateAuditTool(db, cfg.Search, activeProjects),
|
||||||
Projects: tools.NewProjectsTool(db, activeProjects),
|
Projects: tools.NewProjectsTool(db, activeProjects),
|
||||||
Version: tools.NewVersionTool(cfg.MCP.ServerName, info),
|
Version: tools.NewVersionTool(cfg.MCP.ServerName, info),
|
||||||
Learnings: tools.NewLearningsTool(db, activeProjects, cfg.Search),
|
Learnings: tools.NewLearningsTool(db, activeProjects, cfg.Search),
|
||||||
Plans: tools.NewPlansTool(db, activeProjects, cfg.Search),
|
Plans: tools.NewPlansTool(db, activeProjects, cfg.Search),
|
||||||
ProjectPersonas: tools.NewProjectPersonasTool(db, activeProjects),
|
ProjectPersonas: tools.NewProjectPersonasTool(db, activeProjects),
|
||||||
WorldModel: tools.NewWorldModelTool(db, activeProjects),
|
WorldModel: tools.NewWorldModelTool(db, activeProjects),
|
||||||
|
ThoughtLearningLinks: tools.NewThoughtLearningLinksTool(db),
|
||||||
Context: tools.NewContextTool(db, embeddings, cfg.Search, activeProjects),
|
Context: tools.NewContextTool(db, embeddings, cfg.Search, activeProjects),
|
||||||
Recall: tools.NewRecallTool(db, embeddings, cfg.Search, activeProjects),
|
Recall: tools.NewRecallTool(db, embeddings, cfg.Search, activeProjects),
|
||||||
Summarize: tools.NewSummarizeTool(db, embeddings, metadata, cfg.Search, activeProjects),
|
Summarize: tools.NewSummarizeTool(db, embeddings, metadata, cfg.Search, activeProjects),
|
||||||
@@ -237,12 +248,15 @@ func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *st
|
|||||||
}
|
}
|
||||||
mux.Handle("/files", authMiddleware(fileHandler(filesTool)))
|
mux.Handle("/files", authMiddleware(fileHandler(filesTool)))
|
||||||
mux.Handle("/files/{id}", authMiddleware(fileHandler(filesTool)))
|
mux.Handle("/files/{id}", authMiddleware(fileHandler(filesTool)))
|
||||||
|
mux.Handle("/webhooks/thoughts", authMiddleware(newWebhookThoughtHandler(db, embeddings, cfg.Capture, enrichmentRetryer, backfillTool)))
|
||||||
mux.HandleFunc("/.well-known/oauth-authorization-server", oauthMetadataHandler())
|
mux.HandleFunc("/.well-known/oauth-authorization-server", oauthMetadataHandler())
|
||||||
mux.HandleFunc("/api/oauth/register", oauthRegisterHandler(dynClients, logger))
|
mux.HandleFunc("/api/oauth/register", oauthRegisterHandler(dynClients, logger))
|
||||||
mux.HandleFunc("/api/oauth/authorize", oauthAuthorizeHandler(dynClients, authCodes, logger))
|
mux.HandleFunc("/api/oauth/authorize", oauthAuthorizeHandler(dynClients, authCodes, logger))
|
||||||
mux.HandleFunc("/api/oauth/token", oauthTokenHandler(oauthRegistry, tokenStore, authCodes, logger))
|
mux.HandleFunc("/api/oauth/token", oauthTokenHandler(oauthRegistry, tokenStore, authCodes, logger))
|
||||||
mux.Handle("/api/admin/actions/backfill", authMiddleware(adminActions.backfillHandler()))
|
mux.Handle("/api/admin/actions/backfill", authMiddleware(adminActions.backfillHandler()))
|
||||||
mux.Handle("/api/admin/actions/retry-metadata", authMiddleware(adminActions.retryMetadataHandler()))
|
mux.Handle("/api/admin/actions/retry-metadata", authMiddleware(adminActions.retryMetadataHandler()))
|
||||||
|
mux.Handle("/api/admin/identity", authMiddleware(identityAdmin.handler()))
|
||||||
|
mux.Handle("/api/admin/identity/", authMiddleware(identityAdmin.handler()))
|
||||||
mux.HandleFunc("/favicon.ico", serveFavicon)
|
mux.HandleFunc("/favicon.ico", serveFavicon)
|
||||||
mux.HandleFunc("/images/project.jpg", serveHomeImage)
|
mux.HandleFunc("/images/project.jpg", serveHomeImage)
|
||||||
mux.HandleFunc("/images/icon.png", serveIcon)
|
mux.HandleFunc("/images/icon.png", serveIcon)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/uptrace/bunrouter"
|
"github.com/uptrace/bunrouter"
|
||||||
|
|
||||||
"git.warky.dev/wdevs/amcs/internal/store"
|
"git.warky.dev/wdevs/amcs/internal/store"
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/tenancy"
|
||||||
)
|
)
|
||||||
|
|
||||||
func registerResolveSpecAdminRoutes(mux *http.ServeMux, db *store.DB, middleware func(http.Handler) http.Handler, logger *slog.Logger) error {
|
func registerResolveSpecAdminRoutes(mux *http.ServeMux, db *store.DB, middleware func(http.Handler) http.Handler, logger *slog.Logger) error {
|
||||||
@@ -45,7 +46,12 @@ func registerResolveSpecAdminRoutes(mux *http.ServeMux, db *store.DB, middleware
|
|||||||
rsMount.ServeHTTP(w, r)
|
rsMount.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
middleware(rsMount).ServeHTTP(w, r)
|
middleware(http.HandlerFunc(func(w http.ResponseWriter, authenticated *http.Request) {
|
||||||
|
if tenantID := strings.TrimSpace(authenticated.Header.Get("X-AMCS-Tenant-ID")); tenantID != "" {
|
||||||
|
authenticated = authenticated.WithContext(tenancy.WithTenantKey(authenticated.Context(), tenantID))
|
||||||
|
}
|
||||||
|
rsMount.ServeHTTP(w, authenticated)
|
||||||
|
})).ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
|
|
||||||
mux.Handle("/api/rs/", protectedRSMount)
|
mux.Handle("/api/rs/", protectedRSMount)
|
||||||
|
|||||||
@@ -14,12 +14,14 @@ func resolveSpecModels() []resolveSpecModel {
|
|||||||
{schema: "public", entity: "agent_personas", model: generatedmodels.ModelPublicAgentPersonas{}},
|
{schema: "public", entity: "agent_personas", model: generatedmodels.ModelPublicAgentPersonas{}},
|
||||||
{schema: "public", entity: "agent_skills", model: generatedmodels.ModelPublicAgentSkills{}},
|
{schema: "public", entity: "agent_skills", model: generatedmodels.ModelPublicAgentSkills{}},
|
||||||
{schema: "public", entity: "agent_traits", model: generatedmodels.ModelPublicAgentTraits{}},
|
{schema: "public", entity: "agent_traits", model: generatedmodels.ModelPublicAgentTraits{}},
|
||||||
|
{schema: "public", entity: "api_key_assignments", model: generatedmodels.ModelPublicAPIKeyAssignments{}},
|
||||||
{schema: "public", entity: "arc_stage_parts", model: generatedmodels.ModelPublicArcStageParts{}},
|
{schema: "public", entity: "arc_stage_parts", model: generatedmodels.ModelPublicArcStageParts{}},
|
||||||
{schema: "public", entity: "arc_stages", model: generatedmodels.ModelPublicArcStages{}},
|
{schema: "public", entity: "arc_stages", model: generatedmodels.ModelPublicArcStages{}},
|
||||||
{schema: "public", entity: "character_arcs", model: generatedmodels.ModelPublicCharacterArcs{}},
|
{schema: "public", entity: "character_arcs", model: generatedmodels.ModelPublicCharacterArcs{}},
|
||||||
{schema: "public", entity: "chat_histories", model: generatedmodels.ModelPublicChatHistories{}},
|
{schema: "public", entity: "chat_histories", model: generatedmodels.ModelPublicChatHistories{}},
|
||||||
{schema: "public", entity: "embeddings", model: generatedmodels.ModelPublicEmbeddings{}},
|
{schema: "public", entity: "embeddings", model: generatedmodels.ModelPublicEmbeddings{}},
|
||||||
{schema: "public", entity: "learnings", model: generatedmodels.ModelPublicLearnings{}},
|
{schema: "public", entity: "learnings", model: generatedmodels.ModelPublicLearnings{}},
|
||||||
|
{schema: "public", entity: "managed_api_keys", model: generatedmodels.ModelPublicManagedAPIKeys{}},
|
||||||
{schema: "public", entity: "oauth_clients", model: generatedmodels.ModelPublicOauthClients{}},
|
{schema: "public", entity: "oauth_clients", model: generatedmodels.ModelPublicOauthClients{}},
|
||||||
{schema: "public", entity: "persona_arc", model: generatedmodels.ModelPublicPersonaArc{}},
|
{schema: "public", entity: "persona_arc", model: generatedmodels.ModelPublicPersonaArc{}},
|
||||||
{schema: "public", entity: "plan_dependencies", model: generatedmodels.ModelPublicPlanDependencies{}},
|
{schema: "public", entity: "plan_dependencies", model: generatedmodels.ModelPublicPlanDependencies{}},
|
||||||
@@ -32,6 +34,9 @@ func resolveSpecModels() []resolveSpecModel {
|
|||||||
{schema: "public", entity: "project_skills", model: generatedmodels.ModelPublicProjectSkills{}},
|
{schema: "public", entity: "project_skills", model: generatedmodels.ModelPublicProjectSkills{}},
|
||||||
{schema: "public", entity: "projects", model: generatedmodels.ModelPublicProjects{}},
|
{schema: "public", entity: "projects", model: generatedmodels.ModelPublicProjects{}},
|
||||||
{schema: "public", entity: "stored_files", model: generatedmodels.ModelPublicStoredFiles{}},
|
{schema: "public", entity: "stored_files", model: generatedmodels.ModelPublicStoredFiles{}},
|
||||||
|
{schema: "public", entity: "tenant_users", model: generatedmodels.ModelPublicTenantUsers{}},
|
||||||
|
{schema: "public", entity: "tenants", model: generatedmodels.ModelPublicTenants{}},
|
||||||
|
{schema: "public", entity: "thought_learning_links", model: generatedmodels.ModelPublicThoughtLearningLinks{}},
|
||||||
{schema: "public", entity: "thought_links", model: generatedmodels.ModelPublicThoughtLinks{}},
|
{schema: "public", entity: "thought_links", model: generatedmodels.ModelPublicThoughtLinks{}},
|
||||||
{schema: "public", entity: "thoughts", model: generatedmodels.ModelPublicThoughts{}},
|
{schema: "public", entity: "thoughts", model: generatedmodels.ModelPublicThoughts{}},
|
||||||
{schema: "public", entity: "tool_annotations", model: generatedmodels.ModelPublicToolAnnotations{}},
|
{schema: "public", entity: "tool_annotations", model: generatedmodels.ModelPublicToolAnnotations{}},
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/ai"
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/config"
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/metadata"
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/store"
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/tools"
|
||||||
|
thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxWebhookBodyBytes = 1 << 20
|
||||||
|
|
||||||
|
type webhookThoughtRequest struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
Project string `json:"project,omitempty"`
|
||||||
|
Source string `json:"source,omitempty"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
Topics []string `json:"topics,omitempty"`
|
||||||
|
People []string `json:"people,omitempty"`
|
||||||
|
ActionItems []string `json:"action_items,omitempty"`
|
||||||
|
DatesMentioned []string `json:"dates_mentioned,omitempty"`
|
||||||
|
Metadata map[string]any `json:"metadata,omitempty"`
|
||||||
|
SourceMetadata map[string]any `json:"source_metadata,omitempty"`
|
||||||
|
IDempotencyKey string `json:"idempotency_key,omitempty"`
|
||||||
|
ExternalID string `json:"external_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type webhookThoughtResponse struct {
|
||||||
|
Thought thoughttypes.Thought `json:"thought"`
|
||||||
|
Duplicate bool `json:"duplicate"`
|
||||||
|
WebhookMeta thoughttypes.WebhookMetadata `json:"webhook"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type webhookThoughtHandler struct {
|
||||||
|
store *store.DB
|
||||||
|
embeddings *ai.EmbeddingRunner
|
||||||
|
capture config.CaptureConfig
|
||||||
|
retryer tools.MetadataQueuer
|
||||||
|
embedRetryer tools.EmbeddingQueuer
|
||||||
|
}
|
||||||
|
|
||||||
|
func newWebhookThoughtHandler(db *store.DB, embeddings *ai.EmbeddingRunner, capture config.CaptureConfig, retryer tools.MetadataQueuer, embedRetryer tools.EmbeddingQueuer) http.Handler {
|
||||||
|
return &webhookThoughtHandler{store: db, embeddings: embeddings, capture: capture, retryer: retryer, embedRetryer: embedRetryer}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *webhookThoughtHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/webhooks/thoughts" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
w.Header().Set("Allow", http.MethodPost)
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxWebhookBodyBytes)
|
||||||
|
in, err := parseWebhookThoughtRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
webhookMeta := buildWebhookMetadata(in, r.Header.Get("Idempotency-Key"), time.Now().UTC())
|
||||||
|
if webhookMeta.IDempotencyKey != "" {
|
||||||
|
if existing, err := h.store.GetThoughtByWebhookIDempotencyKey(r.Context(), webhookMeta.IDempotencyKey); err == nil {
|
||||||
|
writeWebhookThoughtResponse(w, http.StatusOK, webhookThoughtResponse{Thought: existing, Duplicate: true, WebhookMeta: webhookMeta})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
projectID, err := h.resolveWebhookProject(r, in.Project)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
thought := thoughttypes.Thought{
|
||||||
|
Content: strings.TrimSpace(in.Content),
|
||||||
|
Metadata: normalizeWebhookThoughtMetadata(in, webhookMeta, h.capture),
|
||||||
|
ProjectID: projectID,
|
||||||
|
}
|
||||||
|
created, err := h.store.InsertThought(r.Context(), thought, h.embeddings.PrimaryModel())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "insert thought: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if projectID != nil {
|
||||||
|
_ = h.store.TouchProject(r.Context(), *projectID)
|
||||||
|
}
|
||||||
|
if h.retryer != nil {
|
||||||
|
h.retryer.QueueThought(created.ID)
|
||||||
|
}
|
||||||
|
if h.embedRetryer != nil {
|
||||||
|
h.embedRetryer.QueueThought(r.Context(), created.ID, created.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeWebhookThoughtResponse(w, http.StatusCreated, webhookThoughtResponse{Thought: created, WebhookMeta: webhookMeta})
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseWebhookThoughtRequest(r *http.Request) (webhookThoughtRequest, error) {
|
||||||
|
if !strings.Contains(r.Header.Get("Content-Type"), "application/json") {
|
||||||
|
return webhookThoughtRequest{}, errors.New("webhook requires application/json")
|
||||||
|
}
|
||||||
|
defer r.Body.Close()
|
||||||
|
decoder := json.NewDecoder(r.Body)
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
var in webhookThoughtRequest
|
||||||
|
if err := decoder.Decode(&in); err != nil {
|
||||||
|
return webhookThoughtRequest{}, err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(in.Content) == "" {
|
||||||
|
return webhookThoughtRequest{}, errors.New("content is required")
|
||||||
|
}
|
||||||
|
return in, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *webhookThoughtHandler) resolveWebhookProject(r *http.Request, projectName string) (*int64, error) {
|
||||||
|
projectName = strings.TrimSpace(projectName)
|
||||||
|
if projectName == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
project, err := h.store.GetProject(r.Context(), projectName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &project.NumericID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildWebhookMetadata(in webhookThoughtRequest, headerKey string, now time.Time) thoughttypes.WebhookMetadata {
|
||||||
|
sourceMetadata := in.SourceMetadata
|
||||||
|
if len(sourceMetadata) == 0 {
|
||||||
|
sourceMetadata = in.Metadata
|
||||||
|
}
|
||||||
|
return thoughttypes.WebhookMetadata{
|
||||||
|
ReceivedAt: now.Format(time.RFC3339),
|
||||||
|
IDempotencyKey: firstNonEmpty(in.IDempotencyKey, headerKey),
|
||||||
|
ExternalID: strings.TrimSpace(in.ExternalID),
|
||||||
|
SourceMetadata: sanitizeWebhookMetadata(sourceMetadata),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeWebhookThoughtMetadata(in webhookThoughtRequest, webhookMeta thoughttypes.WebhookMetadata, capture config.CaptureConfig) thoughttypes.ThoughtMetadata {
|
||||||
|
return metadata.Normalize(thoughttypes.ThoughtMetadata{
|
||||||
|
People: in.People,
|
||||||
|
ActionItems: in.ActionItems,
|
||||||
|
DatesMentioned: in.DatesMentioned,
|
||||||
|
Topics: in.Topics,
|
||||||
|
Type: in.Type,
|
||||||
|
Source: firstNonEmpty(in.Source, "webhook"),
|
||||||
|
Webhook: &webhookMeta,
|
||||||
|
}, capture)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeWebhookMetadata(in map[string]any) map[string]any {
|
||||||
|
if len(in) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make(map[string]any, len(in))
|
||||||
|
for key, value := range in {
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if sanitized, ok := sanitizeWebhookMetadataValue(value, 0); ok {
|
||||||
|
out[key] = sanitized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeWebhookMetadataValue(value any, depth int) (any, bool) {
|
||||||
|
if depth > 3 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
switch v := value.(type) {
|
||||||
|
case nil, bool, float64, string:
|
||||||
|
return v, true
|
||||||
|
case []any:
|
||||||
|
if len(v) > 50 {
|
||||||
|
v = v[:50]
|
||||||
|
}
|
||||||
|
out := make([]any, 0, len(v))
|
||||||
|
for _, item := range v {
|
||||||
|
if sanitized, ok := sanitizeWebhookMetadataValue(item, depth+1); ok {
|
||||||
|
out = append(out, sanitized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, true
|
||||||
|
case map[string]any:
|
||||||
|
if len(v) > 50 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return sanitizeWebhookMetadata(v), true
|
||||||
|
default:
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeWebhookThoughtResponse(w http.ResponseWriter, status int, out webhookThoughtResponse) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(out)
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseWebhookThoughtRequestRequiresJSON(t *testing.T) {
|
||||||
|
req := httptestRequest("text/plain", `{"content":"hello"}`)
|
||||||
|
|
||||||
|
_, err := parseWebhookThoughtRequest(req)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for non-json content type")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseWebhookThoughtRequestRequiresContent(t *testing.T) {
|
||||||
|
req := httptestRequest("application/json", `{"source":"n8n"}`)
|
||||||
|
|
||||||
|
_, err := parseWebhookThoughtRequest(req)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "content is required") {
|
||||||
|
t.Fatalf("error = %v, want content required", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildWebhookMetadataUsesHeaderIdempotencyAndSanitizesMetadata(t *testing.T) {
|
||||||
|
now := time.Date(2026, 7, 15, 4, 0, 0, 0, time.UTC)
|
||||||
|
got := buildWebhookMetadata(webhookThoughtRequest{
|
||||||
|
ExternalID: " ext-1 ",
|
||||||
|
Metadata: map[string]any{
|
||||||
|
"service": "n8n",
|
||||||
|
"unsafe": struct{}{},
|
||||||
|
"nested": map[string]any{"ok": true},
|
||||||
|
},
|
||||||
|
}, " key-1 ", now)
|
||||||
|
|
||||||
|
if got.IDempotencyKey != "key-1" {
|
||||||
|
t.Fatalf("IDempotencyKey = %q, want key-1", got.IDempotencyKey)
|
||||||
|
}
|
||||||
|
if got.ExternalID != "ext-1" {
|
||||||
|
t.Fatalf("ExternalID = %q, want ext-1", got.ExternalID)
|
||||||
|
}
|
||||||
|
if got.ReceivedAt != "2026-07-15T04:00:00Z" {
|
||||||
|
t.Fatalf("ReceivedAt = %q", got.ReceivedAt)
|
||||||
|
}
|
||||||
|
if _, ok := got.SourceMetadata["unsafe"]; ok {
|
||||||
|
t.Fatal("unsafe metadata value was not removed")
|
||||||
|
}
|
||||||
|
if got.SourceMetadata["service"] != "n8n" {
|
||||||
|
t.Fatalf("service metadata = %#v", got.SourceMetadata["service"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeWebhookThoughtMetadata(t *testing.T) {
|
||||||
|
webhookMeta := buildWebhookMetadata(webhookThoughtRequest{IDempotencyKey: "abc"}, "", time.Date(2026, 7, 15, 4, 0, 0, 0, time.UTC))
|
||||||
|
got := normalizeWebhookThoughtMetadata(webhookThoughtRequest{
|
||||||
|
Source: "github",
|
||||||
|
Type: "task",
|
||||||
|
Topics: []string{"ci", "ci", ""},
|
||||||
|
People: []string{" Sam "},
|
||||||
|
}, webhookMeta, config.CaptureConfig{})
|
||||||
|
|
||||||
|
if got.Source != "github" {
|
||||||
|
t.Fatalf("Source = %q, want github", got.Source)
|
||||||
|
}
|
||||||
|
if got.Type != "task" {
|
||||||
|
t.Fatalf("Type = %q, want task", got.Type)
|
||||||
|
}
|
||||||
|
if len(got.Topics) != 1 || got.Topics[0] != "ci" {
|
||||||
|
t.Fatalf("Topics = %#v, want [ci]", got.Topics)
|
||||||
|
}
|
||||||
|
if got.Webhook == nil || got.Webhook.IDempotencyKey != "abc" {
|
||||||
|
t.Fatalf("Webhook = %#v, want idempotency key abc", got.Webhook)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func httptestRequest(contentType, body string) *http.Request {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/webhooks/thoughts", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", contentType)
|
||||||
|
return req
|
||||||
|
}
|
||||||
+112
-1
@@ -1,14 +1,27 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"git.warky.dev/wdevs/amcs/internal/config"
|
"git.warky.dev/wdevs/amcs/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Keyring struct {
|
type Keyring struct {
|
||||||
|
mu sync.RWMutex
|
||||||
keys []config.APIKey
|
keys []config.APIKey
|
||||||
|
tenantsByKeyID map[string]string
|
||||||
|
managed map[string]managedKey
|
||||||
|
}
|
||||||
|
|
||||||
|
type managedKey struct {
|
||||||
|
secretHash string
|
||||||
|
enabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewKeyring(keys []config.APIKey) (*Keyring, error) {
|
func NewKeyring(keys []config.APIKey) (*Keyring, error) {
|
||||||
@@ -16,14 +29,112 @@ func NewKeyring(keys []config.APIKey) (*Keyring, error) {
|
|||||||
return nil, fmt.Errorf("keyring requires at least one key")
|
return nil, fmt.Errorf("keyring requires at least one key")
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Keyring{keys: append([]config.APIKey(nil), keys...)}, nil
|
tenantsByKeyID := make(map[string]string)
|
||||||
|
for _, key := range keys {
|
||||||
|
if tenantID := strings.TrimSpace(key.TenantID); tenantID != "" {
|
||||||
|
tenantsByKeyID[key.ID] = tenantID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &Keyring{keys: append([]config.APIKey(nil), keys...), tenantsByKeyID: tenantsByKeyID, managed: make(map[string]managedKey)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewManagedKeyring is used when API credentials are administered in the
|
||||||
|
// database rather than supplied through static configuration.
|
||||||
|
func NewManagedKeyring() *Keyring {
|
||||||
|
return &Keyring{tenantsByKeyID: make(map[string]string), managed: make(map[string]managedKey)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (k *Keyring) Lookup(value string) (string, bool) {
|
func (k *Keyring) Lookup(value string) (string, bool) {
|
||||||
|
k.mu.RLock()
|
||||||
|
defer k.mu.RUnlock()
|
||||||
for _, key := range k.keys {
|
for _, key := range k.keys {
|
||||||
if subtle.ConstantTimeCompare([]byte(key.Value), []byte(value)) == 1 {
|
if subtle.ConstantTimeCompare([]byte(key.Value), []byte(value)) == 1 {
|
||||||
return key.ID, true
|
return key.ID, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
hash := secretHash(value)
|
||||||
|
for keyID, key := range k.managed {
|
||||||
|
if key.enabled && subtle.ConstantTimeCompare([]byte(key.secretHash), []byte(hash)) == 1 {
|
||||||
|
return keyID, true
|
||||||
|
}
|
||||||
|
}
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TenantForKey returns the tenant boundary assigned to keyID. Unassigned
|
||||||
|
// configured keys retain the historical key-ID boundary for compatibility.
|
||||||
|
func (k *Keyring) TenantForKey(keyID string) string {
|
||||||
|
k.mu.RLock()
|
||||||
|
defer k.mu.RUnlock()
|
||||||
|
if tenantID := k.tenantsByKeyID[keyID]; tenantID != "" {
|
||||||
|
return tenantID
|
||||||
|
}
|
||||||
|
return keyID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *Keyring) AssignTenant(keyID, tenantID string) {
|
||||||
|
k.mu.Lock()
|
||||||
|
defer k.mu.Unlock()
|
||||||
|
if tenantID == "" {
|
||||||
|
delete(k.tenantsByKeyID, keyID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
k.tenantsByKeyID[keyID] = tenantID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *Keyring) AddManaged(keyID, secretHash string, enabled bool) {
|
||||||
|
k.mu.Lock()
|
||||||
|
defer k.mu.Unlock()
|
||||||
|
k.managed[keyID] = managedKey{secretHash: secretHash, enabled: enabled}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *Keyring) ConfiguredKeys() []config.APIKey {
|
||||||
|
k.mu.RLock()
|
||||||
|
defer k.mu.RUnlock()
|
||||||
|
return append([]config.APIKey(nil), k.keys...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *Keyring) IsConfigured(keyID string) bool {
|
||||||
|
k.mu.RLock()
|
||||||
|
defer k.mu.RUnlock()
|
||||||
|
for _, key := range k.keys {
|
||||||
|
if key.ID == keyID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *Keyring) IsSuperadmin(keyID string) bool {
|
||||||
|
k.mu.RLock()
|
||||||
|
defer k.mu.RUnlock()
|
||||||
|
for _, key := range k.keys {
|
||||||
|
if key.ID == keyID {
|
||||||
|
return key.Superadmin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *Keyring) SetManagedEnabled(keyID string, enabled bool) {
|
||||||
|
k.mu.Lock()
|
||||||
|
defer k.mu.Unlock()
|
||||||
|
if key, ok := k.managed[keyID]; ok {
|
||||||
|
key.enabled = enabled
|
||||||
|
k.managed[keyID] = key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateSecret() (string, string, error) {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
secret := "amcs_" + hex.EncodeToString(b)
|
||||||
|
return secret, secretHash(secret), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func secretHash(secret string) string {
|
||||||
|
sum := sha256.Sum256([]byte(secret))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,6 +36,26 @@ func TestNewKeyringAndLookup(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConfiguredKeyUsesTenantID(t *testing.T) {
|
||||||
|
keyring, err := NewKeyring([]config.APIKey{{ID: "agent-key", Value: "secret", TenantID: "acme"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewKeyring() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := keyring.TenantForKey("agent-key"); got != "acme" {
|
||||||
|
t.Fatalf("TenantForKey() = %q, want acme", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfiguredKeySuperadmin(t *testing.T) {
|
||||||
|
keyring, err := NewKeyring([]config.APIKey{{ID: "operator", Value: "secret", Superadmin: true}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewKeyring() error = %v", err)
|
||||||
|
}
|
||||||
|
if !keyring.IsSuperadmin("operator") || keyring.IsSuperadmin("missing") {
|
||||||
|
t.Fatal("IsSuperadmin() did not return the configured role")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMiddlewareAllowsHeaderAuthAndSetsContext(t *testing.T) {
|
func TestMiddlewareAllowsHeaderAuthAndSetsContext(t *testing.T) {
|
||||||
keyring, err := NewKeyring([]config.APIKey{{ID: "client-a", Value: "secret"}})
|
keyring, err := NewKeyring([]config.APIKey{{ID: "client-a", Value: "secret"}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"git.warky.dev/wdevs/amcs/internal/config"
|
"git.warky.dev/wdevs/amcs/internal/config"
|
||||||
"git.warky.dev/wdevs/amcs/internal/observability"
|
"git.warky.dev/wdevs/amcs/internal/observability"
|
||||||
"git.warky.dev/wdevs/amcs/internal/requestip"
|
"git.warky.dev/wdevs/amcs/internal/requestip"
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/tenancy"
|
||||||
)
|
)
|
||||||
|
|
||||||
type contextKey string
|
type contextKey string
|
||||||
@@ -50,6 +51,14 @@ func Middleware(cfg config.AuthConfig, keyring *Keyring, oauthRegistry *OAuthReg
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
withTenant := func(ctx context.Context, keyID string) context.Context {
|
||||||
|
ctx = context.WithValue(ctx, keyIDContextKey, keyID)
|
||||||
|
tenantID := keyID
|
||||||
|
if keyring != nil {
|
||||||
|
tenantID = keyring.TenantForKey(keyID)
|
||||||
|
}
|
||||||
|
return tenancy.WithTenantKey(ctx, tenantID)
|
||||||
|
}
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
remoteAddr := requestip.FromRequest(r)
|
remoteAddr := requestip.FromRequest(r)
|
||||||
@@ -63,7 +72,7 @@ func Middleware(cfg config.AuthConfig, keyring *Keyring, oauthRegistry *OAuthReg
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
recordAccess(r, keyID)
|
recordAccess(r, keyID)
|
||||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), keyIDContextKey, keyID)))
|
next.ServeHTTP(w, r.WithContext(withTenant(r.Context(), keyID)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,14 +82,14 @@ func Middleware(cfg config.AuthConfig, keyring *Keyring, oauthRegistry *OAuthReg
|
|||||||
if tokenStore != nil {
|
if tokenStore != nil {
|
||||||
if keyID, ok := tokenStore.Lookup(bearer); ok {
|
if keyID, ok := tokenStore.Lookup(bearer); ok {
|
||||||
recordAccess(r, keyID)
|
recordAccess(r, keyID)
|
||||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), keyIDContextKey, keyID)))
|
next.ServeHTTP(w, r.WithContext(withTenant(r.Context(), keyID)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if keyring != nil {
|
if keyring != nil {
|
||||||
if keyID, ok := keyring.Lookup(bearer); ok {
|
if keyID, ok := keyring.Lookup(bearer); ok {
|
||||||
recordAccess(r, keyID)
|
recordAccess(r, keyID)
|
||||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), keyIDContextKey, keyID)))
|
next.ServeHTTP(w, r.WithContext(withTenant(r.Context(), keyID)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,7 +112,7 @@ func Middleware(cfg config.AuthConfig, keyring *Keyring, oauthRegistry *OAuthReg
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
recordAccess(r, keyID)
|
recordAccess(r, keyID)
|
||||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), keyIDContextKey, keyID)))
|
next.ServeHTTP(w, r.WithContext(withTenant(r.Context(), keyID)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,7 +126,7 @@ func Middleware(cfg config.AuthConfig, keyring *Keyring, oauthRegistry *OAuthReg
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
recordAccess(r, keyID)
|
recordAccess(r, keyID)
|
||||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), keyIDContextKey, keyID)))
|
next.ServeHTTP(w, r.WithContext(withTenant(r.Context(), keyID)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,3 +31,18 @@ func (o *OAuthRegistry) Lookup(clientID string, clientSecret string) (string, bo
|
|||||||
}
|
}
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsSuperadmin reports whether keyID (as returned by Lookup) belongs to an
|
||||||
|
// OAuth client configured with superadmin: true.
|
||||||
|
func (o *OAuthRegistry) IsSuperadmin(keyID string) bool {
|
||||||
|
for _, client := range o.clients {
|
||||||
|
id := client.ID
|
||||||
|
if id == "" {
|
||||||
|
id = client.ClientID
|
||||||
|
}
|
||||||
|
if id == keyID {
|
||||||
|
return client.Superadmin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,6 +32,26 @@ func TestNewOAuthRegistryAndLookup(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOAuthRegistryIsSuperadmin(t *testing.T) {
|
||||||
|
registry, err := NewOAuthRegistry([]config.OAuthClient{
|
||||||
|
{ID: "oauth-admin", ClientID: "admin-id", ClientSecret: "admin-secret", Superadmin: true},
|
||||||
|
{ID: "oauth-client", ClientID: "client-id", ClientSecret: "client-secret"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewOAuthRegistry() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !registry.IsSuperadmin("oauth-admin") {
|
||||||
|
t.Fatal("IsSuperadmin(oauth-admin) = false, want true")
|
||||||
|
}
|
||||||
|
if registry.IsSuperadmin("oauth-client") {
|
||||||
|
t.Fatal("IsSuperadmin(oauth-client) = true, want false")
|
||||||
|
}
|
||||||
|
if registry.IsSuperadmin("unknown") {
|
||||||
|
t.Fatal("IsSuperadmin(unknown) = true, want false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMiddlewareAllowsOAuthBasicAuthAndSetsContext(t *testing.T) {
|
func TestMiddlewareAllowsOAuthBasicAuthAndSetsContext(t *testing.T) {
|
||||||
oauthRegistry, err := NewOAuthRegistry([]config.OAuthClient{{
|
oauthRegistry, err := NewOAuthRegistry([]config.OAuthClient{{
|
||||||
ID: "oauth-client",
|
ID: "oauth-client",
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/config"
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/tenancy"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMiddlewareAddsTenantKeyToContext(t *testing.T) {
|
||||||
|
keyring, err := NewKeyring([]config.APIKey{{ID: "user-a", Value: "secret-a"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewKeyring error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var gotKeyID, gotTenant string
|
||||||
|
handler := Middleware(config.AuthConfig{}, keyring, nil, nil, nil, slog.Default())(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var ok bool
|
||||||
|
gotKeyID, ok = KeyIDFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("KeyIDFromContext ok = false")
|
||||||
|
}
|
||||||
|
gotTenant, ok = tenancy.KeyFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("tenancy.KeyFromContext ok = false")
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
|
||||||
|
req.Header.Set("x-brain-key", "secret-a")
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("status = %d, want %d", rr.Code, http.StatusNoContent)
|
||||||
|
}
|
||||||
|
if gotKeyID != "user-a" || gotTenant != "user-a" {
|
||||||
|
t.Fatalf("keyID=%q tenant=%q, want user-a/user-a", gotKeyID, gotTenant)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,6 +53,8 @@ type AuthConfig struct {
|
|||||||
type APIKey struct {
|
type APIKey struct {
|
||||||
ID string `yaml:"id"`
|
ID string `yaml:"id"`
|
||||||
Value string `yaml:"value"`
|
Value string `yaml:"value"`
|
||||||
|
TenantID string `yaml:"tenant_id"`
|
||||||
|
Superadmin bool `yaml:"superadmin"`
|
||||||
Description string `yaml:"description"`
|
Description string `yaml:"description"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +66,7 @@ type OAuthClient struct {
|
|||||||
ID string `yaml:"id"`
|
ID string `yaml:"id"`
|
||||||
ClientID string `yaml:"client_id"`
|
ClientID string `yaml:"client_id"`
|
||||||
ClientSecret string `yaml:"client_secret"`
|
ClientSecret string `yaml:"client_secret"`
|
||||||
|
Superadmin bool `yaml:"superadmin"`
|
||||||
Description string `yaml:"description"`
|
Description string `yaml:"description"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,21 +3,23 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicAgentGuardrails struct {
|
type ModelPublicAgentGuardrails struct {
|
||||||
bun.BaseModel `bun:"table:public.agent_guardrails,alias:agent_guardrails"`
|
bun.BaseModel `bun:"table:public.agent_guardrails,alias:agent_guardrails"`
|
||||||
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
||||||
Content resolvespec_common.SqlString `bun:"content,type:text,notnull," json:"content"`
|
Content sql_types.SqlString `bun:"content,type:text,notnull," json:"content"`
|
||||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
Description resolvespec_common.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
|
Description sql_types.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
|
||||||
GUID resolvespec_common.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
|
GUID sql_types.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
|
||||||
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"`
|
Name sql_types.SqlString `bun:"name,type:text,notnull," json:"name"`
|
||||||
Severity resolvespec_common.SqlString `bun:"severity,type:text,default:'medium',notnull," json:"severity"`
|
Severity sql_types.SqlString `bun:"severity,type:text,default:'medium',notnull," json:"severity"`
|
||||||
Tags resolvespec_common.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
|
Tags sql_types.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
|
||||||
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
TenantID sql_types.SqlString `bun:"tenant_id,type:text,nullzero," json:"tenant_id"`
|
||||||
|
UpdatedAt sql_types.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
||||||
|
RelTenantID *ModelPublicTenants `bun:"rel:has-one,join:tenant_id=id" json:"reltenantid,omitempty"` // Has one ModelPublicTenants
|
||||||
RelGuardrailIDPublicAgentPersonaGuardrails []*ModelPublicAgentPersonaGuardrails `bun:"rel:has-many,join:id=guardrail_id" json:"relguardrailidpublicagentpersonaguardrails,omitempty"` // Has many ModelPublicAgentPersonaGuardrails
|
RelGuardrailIDPublicAgentPersonaGuardrails []*ModelPublicAgentPersonaGuardrails `bun:"rel:has-many,join:id=guardrail_id" json:"relguardrailidpublicagentpersonaguardrails,omitempty"` // Has many ModelPublicAgentPersonaGuardrails
|
||||||
RelGuardrailIDPublicPlanGuardrails []*ModelPublicPlanGuardrails `bun:"rel:has-many,join:id=guardrail_id" json:"relguardrailidpublicplanguardrails,omitempty"` // Has many ModelPublicPlanGuardrails
|
RelGuardrailIDPublicPlanGuardrails []*ModelPublicPlanGuardrails `bun:"rel:has-many,join:id=guardrail_id" json:"relguardrailidpublicplanguardrails,omitempty"` // Has many ModelPublicPlanGuardrails
|
||||||
RelGuardrailIDPublicProjectGuardrails []*ModelPublicProjectGuardrails `bun:"rel:has-many,join:id=guardrail_id" json:"relguardrailidpublicprojectguardrails,omitempty"` // Has many ModelPublicProjectGuardrails
|
RelGuardrailIDPublicProjectGuardrails []*ModelPublicProjectGuardrails `bun:"rel:has-many,join:id=guardrail_id" json:"relguardrailidpublicprojectguardrails,omitempty"` // Has many ModelPublicProjectGuardrails
|
||||||
|
|||||||
@@ -3,22 +3,24 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicAgentParts struct {
|
type ModelPublicAgentParts struct {
|
||||||
bun.BaseModel `bun:"table:public.agent_parts,alias:agent_parts"`
|
bun.BaseModel `bun:"table:public.agent_parts,alias:agent_parts"`
|
||||||
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
||||||
Content resolvespec_common.SqlString `bun:"content,type:text,default:'',notnull," json:"content"`
|
Content sql_types.SqlString `bun:"content,type:text,default:'',notnull," json:"content"`
|
||||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
Description resolvespec_common.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
|
Description sql_types.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
|
||||||
GUID resolvespec_common.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
|
GUID sql_types.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
|
||||||
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"`
|
Name sql_types.SqlString `bun:"name,type:text,notnull," json:"name"`
|
||||||
PartType resolvespec_common.SqlString `bun:"part_type,type:text,notnull," json:"part_type"`
|
PartType sql_types.SqlString `bun:"part_type,type:text,notnull," json:"part_type"`
|
||||||
Summary resolvespec_common.SqlString `bun:"summary,type:text,notnull," json:"summary"`
|
Summary sql_types.SqlString `bun:"summary,type:text,notnull," json:"summary"`
|
||||||
Tags resolvespec_common.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
|
Tags sql_types.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
|
||||||
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
TenantID sql_types.SqlString `bun:"tenant_id,type:text,nullzero," json:"tenant_id"`
|
||||||
|
UpdatedAt sql_types.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
||||||
|
RelTenantID *ModelPublicTenants `bun:"rel:has-one,join:tenant_id=id" json:"reltenantid,omitempty"` // Has one ModelPublicTenants
|
||||||
RelPartIDPublicAgentPersonaParts []*ModelPublicAgentPersonaParts `bun:"rel:has-many,join:id=part_id" json:"relpartidpublicagentpersonaparts,omitempty"` // Has many ModelPublicAgentPersonaParts
|
RelPartIDPublicAgentPersonaParts []*ModelPublicAgentPersonaParts `bun:"rel:has-many,join:id=part_id" json:"relpartidpublicagentpersonaparts,omitempty"` // Has many ModelPublicAgentPersonaParts
|
||||||
RelPartIDPublicArcStageParts []*ModelPublicArcStageParts `bun:"rel:has-many,join:id=part_id" json:"relpartidpublicarcstageparts,omitempty"` // Has many ModelPublicArcStageParts
|
RelPartIDPublicArcStageParts []*ModelPublicArcStageParts `bun:"rel:has-many,join:id=part_id" json:"relpartidpublicarcstageparts,omitempty"` // Has many ModelPublicArcStageParts
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicAgentPersonaGuardrails struct {
|
type ModelPublicAgentPersonaGuardrails struct {
|
||||||
bun.BaseModel `bun:"table:public.agent_persona_guardrails,alias:agent_persona_guardrails"`
|
bun.BaseModel `bun:"table:public.agent_persona_guardrails,alias:agent_persona_guardrails"`
|
||||||
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
||||||
GuardrailID int64 `bun:"guardrail_id,type:bigint,notnull," json:"guardrail_id"`
|
GuardrailID int64 `bun:"guardrail_id,type:bigint,notnull," json:"guardrail_id"`
|
||||||
PersonaID int64 `bun:"persona_id,type:bigint,notnull," json:"persona_id"`
|
PersonaID int64 `bun:"persona_id,type:bigint,notnull," json:"persona_id"`
|
||||||
RelGuardrailID *ModelPublicAgentGuardrails `bun:"rel:has-one,join:guardrail_id=id" json:"relguardrailid,omitempty"` // Has one ModelPublicAgentGuardrails
|
RelGuardrailID *ModelPublicAgentGuardrails `bun:"rel:has-one,join:guardrail_id=id" json:"relguardrailid,omitempty"` // Has one ModelPublicAgentGuardrails
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicAgentPersonaParts struct {
|
type ModelPublicAgentPersonaParts struct {
|
||||||
bun.BaseModel `bun:"table:public.agent_persona_parts,alias:agent_persona_parts"`
|
bun.BaseModel `bun:"table:public.agent_persona_parts,alias:agent_persona_parts"`
|
||||||
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
||||||
PartID int64 `bun:"part_id,type:bigint,notnull," json:"part_id"`
|
PartID int64 `bun:"part_id,type:bigint,notnull," json:"part_id"`
|
||||||
PartOrder int32 `bun:"part_order,type:int,default:0,notnull," json:"part_order"`
|
PartOrder int32 `bun:"part_order,type:int,default:0,notnull," json:"part_order"`
|
||||||
PersonaID int64 `bun:"persona_id,type:bigint,notnull," json:"persona_id"`
|
PersonaID int64 `bun:"persona_id,type:bigint,notnull," json:"persona_id"`
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicAgentPersonaSkills struct {
|
type ModelPublicAgentPersonaSkills struct {
|
||||||
bun.BaseModel `bun:"table:public.agent_persona_skills,alias:agent_persona_skills"`
|
bun.BaseModel `bun:"table:public.agent_persona_skills,alias:agent_persona_skills"`
|
||||||
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
||||||
Override bool `bun:"override,type:boolean,default:false,notnull," json:"override"`
|
Override bool `bun:"override,type:boolean,default:false,notnull," json:"override"`
|
||||||
PersonaID int64 `bun:"persona_id,type:bigint,notnull," json:"persona_id"`
|
PersonaID int64 `bun:"persona_id,type:bigint,notnull," json:"persona_id"`
|
||||||
SkillID int64 `bun:"skill_id,type:bigint,notnull," json:"skill_id"`
|
SkillID int64 `bun:"skill_id,type:bigint,notnull," json:"skill_id"`
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicAgentPersonaTraits struct {
|
type ModelPublicAgentPersonaTraits struct {
|
||||||
bun.BaseModel `bun:"table:public.agent_persona_traits,alias:agent_persona_traits"`
|
bun.BaseModel `bun:"table:public.agent_persona_traits,alias:agent_persona_traits"`
|
||||||
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
||||||
PersonaID int64 `bun:"persona_id,type:bigint,notnull," json:"persona_id"`
|
PersonaID int64 `bun:"persona_id,type:bigint,notnull," json:"persona_id"`
|
||||||
TraitID int64 `bun:"trait_id,type:bigint,notnull," json:"trait_id"`
|
TraitID int64 `bun:"trait_id,type:bigint,notnull," json:"trait_id"`
|
||||||
RelPersonaID *ModelPublicAgentPersonas `bun:"rel:has-one,join:persona_id=id" json:"relpersonaid,omitempty"` // Has one ModelPublicAgentPersonas
|
RelPersonaID *ModelPublicAgentPersonas `bun:"rel:has-one,join:persona_id=id" json:"relpersonaid,omitempty"` // Has one ModelPublicAgentPersonas
|
||||||
|
|||||||
@@ -3,24 +3,26 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicAgentPersonas struct {
|
type ModelPublicAgentPersonas struct {
|
||||||
bun.BaseModel `bun:"table:public.agent_personas,alias:agent_personas"`
|
bun.BaseModel `bun:"table:public.agent_personas,alias:agent_personas"`
|
||||||
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
||||||
CompiledAt resolvespec_common.SqlTimeStamp `bun:"compiled_at,type:timestamptz,nullzero," json:"compiled_at"`
|
CompiledAt sql_types.SqlTimeStamp `bun:"compiled_at,type:timestamptz,nullzero," json:"compiled_at"`
|
||||||
CompiledDetail resolvespec_common.SqlString `bun:"compiled_detail,type:text,default:'',notnull," json:"compiled_detail"`
|
CompiledDetail sql_types.SqlString `bun:"compiled_detail,type:text,default:'',notnull," json:"compiled_detail"`
|
||||||
CompiledSummary resolvespec_common.SqlString `bun:"compiled_summary,type:text,default:'',notnull," json:"compiled_summary"`
|
CompiledSummary sql_types.SqlString `bun:"compiled_summary,type:text,default:'',notnull," json:"compiled_summary"`
|
||||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
Description resolvespec_common.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
|
Description sql_types.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
|
||||||
Detail resolvespec_common.SqlString `bun:"detail,type:text,default:'',notnull," json:"detail"`
|
Detail sql_types.SqlString `bun:"detail,type:text,default:'',notnull," json:"detail"`
|
||||||
GUID resolvespec_common.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
|
GUID sql_types.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
|
||||||
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"`
|
Name sql_types.SqlString `bun:"name,type:text,notnull," json:"name"`
|
||||||
Summary resolvespec_common.SqlString `bun:"summary,type:text,notnull," json:"summary"`
|
Summary sql_types.SqlString `bun:"summary,type:text,notnull," json:"summary"`
|
||||||
Tags resolvespec_common.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
|
Tags sql_types.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
|
||||||
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
TenantID sql_types.SqlString `bun:"tenant_id,type:text,nullzero," json:"tenant_id"`
|
||||||
|
UpdatedAt sql_types.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
||||||
|
RelTenantID *ModelPublicTenants `bun:"rel:has-one,join:tenant_id=id" json:"reltenantid,omitempty"` // Has one ModelPublicTenants
|
||||||
RelPersonaIDPublicAgentPersonaParts []*ModelPublicAgentPersonaParts `bun:"rel:has-many,join:id=persona_id" json:"relpersonaidpublicagentpersonaparts,omitempty"` // Has many ModelPublicAgentPersonaParts
|
RelPersonaIDPublicAgentPersonaParts []*ModelPublicAgentPersonaParts `bun:"rel:has-many,join:id=persona_id" json:"relpersonaidpublicagentpersonaparts,omitempty"` // Has many ModelPublicAgentPersonaParts
|
||||||
RelPersonaIDPublicAgentPersonaSkills []*ModelPublicAgentPersonaSkills `bun:"rel:has-many,join:id=persona_id" json:"relpersonaidpublicagentpersonaskills,omitempty"` // Has many ModelPublicAgentPersonaSkills
|
RelPersonaIDPublicAgentPersonaSkills []*ModelPublicAgentPersonaSkills `bun:"rel:has-many,join:id=persona_id" json:"relpersonaidpublicagentpersonaskills,omitempty"` // Has many ModelPublicAgentPersonaSkills
|
||||||
RelPersonaIDPublicProjectPersonas []*ModelPublicProjectPersonas `bun:"rel:has-many,join:id=persona_id" json:"relpersonaidpublicprojectpersonas,omitempty"` // Has many ModelPublicProjectPersonas
|
RelPersonaIDPublicProjectPersonas []*ModelPublicProjectPersonas `bun:"rel:has-many,join:id=persona_id" json:"relpersonaidpublicprojectpersonas,omitempty"` // Has many ModelPublicProjectPersonas
|
||||||
|
|||||||
@@ -3,24 +3,27 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
|
||||||
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicAgentSkills struct {
|
type ModelPublicAgentSkills struct {
|
||||||
bun.BaseModel `bun:"table:public.agent_skills,alias:agent_skills"`
|
bun.BaseModel `bun:"table:public.agent_skills,alias:agent_skills"`
|
||||||
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
||||||
Content resolvespec_common.SqlString `bun:"content,type:text,notnull," json:"content"`
|
Content sql_types.SqlString `bun:"content,type:text,notnull," json:"content"`
|
||||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
Description resolvespec_common.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
|
Description sql_types.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
|
||||||
DomainTags resolvespec_common.SqlStringArray `bun:"domain_tags,type:text[],default:'{}',notnull," json:"domain_tags"`
|
DomainTags sql_types.SqlStringArray `bun:"domain_tags,type:text[],default:'{}',notnull," json:"domain_tags"`
|
||||||
FrameworkTags resolvespec_common.SqlStringArray `bun:"framework_tags,type:text[],default:'{}',notnull," json:"framework_tags"`
|
FrameworkTags sql_types.SqlStringArray `bun:"framework_tags,type:text[],default:'{}',notnull," json:"framework_tags"`
|
||||||
GUID resolvespec_common.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
|
GUID sql_types.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
|
||||||
LanguageTags resolvespec_common.SqlStringArray `bun:"language_tags,type:text[],default:'{}',notnull," json:"language_tags"`
|
LanguageTags sql_types.SqlStringArray `bun:"language_tags,type:text[],default:'{}',notnull," json:"language_tags"`
|
||||||
LibraryTags resolvespec_common.SqlStringArray `bun:"library_tags,type:text[],default:'{}',notnull," json:"library_tags"`
|
LibraryTags sql_types.SqlStringArray `bun:"library_tags,type:text[],default:'{}',notnull," json:"library_tags"`
|
||||||
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"`
|
Name sql_types.SqlString `bun:"name,type:text,notnull," json:"name"`
|
||||||
Tags resolvespec_common.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
|
Tags sql_types.SqlStringArray `bun:"tags,array,type:text[],default:'{}',notnull," json:"tags"`
|
||||||
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
TenantID sql_types.SqlString `bun:"tenant_id,type:text,nullzero," json:"tenant_id"`
|
||||||
|
UpdatedAt sql_types.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
||||||
|
RelTenantID *ModelPublicTenants `bun:"rel:has-one,join:tenant_id=id" json:"reltenantid,omitempty"` // Has one ModelPublicTenants
|
||||||
RelSkillIDPublicAgentPersonaSkills []*ModelPublicAgentPersonaSkills `bun:"rel:has-many,join:id=skill_id" json:"relskillidpublicagentpersonaskills,omitempty"` // Has many ModelPublicAgentPersonaSkills
|
RelSkillIDPublicAgentPersonaSkills []*ModelPublicAgentPersonaSkills `bun:"rel:has-many,join:id=skill_id" json:"relskillidpublicagentpersonaskills,omitempty"` // Has many ModelPublicAgentPersonaSkills
|
||||||
RelRelatedSkillIDPublicLearnings []*ModelPublicLearnings `bun:"rel:has-many,join:id=related_skill_id" json:"relrelatedskillidpubliclearnings,omitempty"` // Has many ModelPublicLearnings
|
RelRelatedSkillIDPublicLearnings []*ModelPublicLearnings `bun:"rel:has-many,join:id=related_skill_id" json:"relrelatedskillidpubliclearnings,omitempty"` // Has many ModelPublicLearnings
|
||||||
RelSkillIDPublicPlanSkills []*ModelPublicPlanSkills `bun:"rel:has-many,join:id=skill_id" json:"relskillidpublicplanskills,omitempty"` // Has many ModelPublicPlanSkills
|
RelSkillIDPublicPlanSkills []*ModelPublicPlanSkills `bun:"rel:has-many,join:id=skill_id" json:"relskillidpublicplanskills,omitempty"` // Has many ModelPublicPlanSkills
|
||||||
|
|||||||
@@ -3,21 +3,23 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicAgentTraits struct {
|
type ModelPublicAgentTraits struct {
|
||||||
bun.BaseModel `bun:"table:public.agent_traits,alias:agent_traits"`
|
bun.BaseModel `bun:"table:public.agent_traits,alias:agent_traits"`
|
||||||
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
||||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
Description resolvespec_common.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
|
Description sql_types.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
|
||||||
GUID resolvespec_common.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
|
GUID sql_types.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
|
||||||
Instruction resolvespec_common.SqlString `bun:"instruction,type:text,default:'',notnull," json:"instruction"`
|
Instruction sql_types.SqlString `bun:"instruction,type:text,default:'',notnull," json:"instruction"`
|
||||||
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"`
|
Name sql_types.SqlString `bun:"name,type:text,notnull," json:"name"`
|
||||||
Tags resolvespec_common.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
|
Tags sql_types.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
|
||||||
TraitType resolvespec_common.SqlString `bun:"trait_type,type:text,notnull," json:"trait_type"`
|
TenantID sql_types.SqlString `bun:"tenant_id,type:text,nullzero," json:"tenant_id"`
|
||||||
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
TraitType sql_types.SqlString `bun:"trait_type,type:text,notnull," json:"trait_type"`
|
||||||
|
UpdatedAt sql_types.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
||||||
|
RelTenantID *ModelPublicTenants `bun:"rel:has-one,join:tenant_id=id" json:"reltenantid,omitempty"` // Has one ModelPublicTenants
|
||||||
RelTraitIDPublicAgentPersonaTraits []*ModelPublicAgentPersonaTraits `bun:"rel:has-many,join:id=trait_id" json:"reltraitidpublicagentpersonatraits,omitempty"` // Has many ModelPublicAgentPersonaTraits
|
RelTraitIDPublicAgentPersonaTraits []*ModelPublicAgentPersonaTraits `bun:"rel:has-many,join:id=trait_id" json:"reltraitidpublicagentpersonatraits,omitempty"` // Has many ModelPublicAgentPersonaTraits
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// Code generated by relspecgo. DO NOT EDIT.
|
||||||
|
package generatedmodels
|
||||||
|
|
||||||
|
import (
|
||||||
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ModelPublicAPIKeyAssignments struct {
|
||||||
|
bun.BaseModel `bun:"table:public.api_key_assignments,alias:api_key_assignments"`
|
||||||
|
KeyID sql_types.SqlString `bun:"key_id,type:text,pk," json:"key_id"`
|
||||||
|
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
|
Description sql_types.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
|
||||||
|
Enabled bool `bun:"enabled,type:boolean,default:true,notnull," json:"enabled"`
|
||||||
|
Source sql_types.SqlString `bun:"source,type:text,notnull," json:"source"`
|
||||||
|
TenantID sql_types.SqlString `bun:"tenant_id,type:text,notnull," json:"tenant_id"`
|
||||||
|
UserID sql_types.SqlString `bun:"user_id,type:text,nullzero," json:"user_id"`
|
||||||
|
RelTenantID *ModelPublicTenants `bun:"rel:has-one,join:tenant_id=id" json:"reltenantid,omitempty"` // Has one ModelPublicTenants
|
||||||
|
RelUserID *ModelPublicTenantUsers `bun:"rel:has-one,join:user_id=id" json:"reluserid,omitempty"` // Has one ModelPublicTenantUsers
|
||||||
|
RelKeyIDPublicManagedAPIKeys []*ModelPublicManagedAPIKeys `bun:"rel:has-many,join:key_id=key_id" json:"relkeyidpublicmanagedapikeys,omitempty"` // Has many ModelPublicManagedAPIKeys
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName returns the table name for ModelPublicAPIKeyAssignments
|
||||||
|
func (m ModelPublicAPIKeyAssignments) TableName() string {
|
||||||
|
return "public.api_key_assignments"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableNameOnly returns the table name without schema for ModelPublicAPIKeyAssignments
|
||||||
|
func (m ModelPublicAPIKeyAssignments) TableNameOnly() string {
|
||||||
|
return "api_key_assignments"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SchemaName returns the schema name for ModelPublicAPIKeyAssignments
|
||||||
|
func (m ModelPublicAPIKeyAssignments) SchemaName() string {
|
||||||
|
return "public"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetID returns the primary key value
|
||||||
|
func (m ModelPublicAPIKeyAssignments) GetID() string {
|
||||||
|
return m.KeyID.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDStr returns the primary key as a string
|
||||||
|
func (m ModelPublicAPIKeyAssignments) GetIDStr() string {
|
||||||
|
return m.KeyID.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetID sets the primary key value
|
||||||
|
func (m ModelPublicAPIKeyAssignments) SetID(newid string) {
|
||||||
|
m.UpdateID(newid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateID updates the primary key value
|
||||||
|
func (m *ModelPublicAPIKeyAssignments) UpdateID(newid string) {
|
||||||
|
m.KeyID.FromString(newid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDName returns the name of the primary key column
|
||||||
|
func (m ModelPublicAPIKeyAssignments) GetIDName() string {
|
||||||
|
return "key_id"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPrefix returns the table prefix
|
||||||
|
func (m ModelPublicAPIKeyAssignments) GetPrefix() string {
|
||||||
|
return "AKA"
|
||||||
|
}
|
||||||
@@ -3,13 +3,13 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicArcStageParts struct {
|
type ModelPublicArcStageParts struct {
|
||||||
bun.BaseModel `bun:"table:public.arc_stage_parts,alias:arc_stage_parts"`
|
bun.BaseModel `bun:"table:public.arc_stage_parts,alias:arc_stage_parts"`
|
||||||
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
||||||
PartID int64 `bun:"part_id,type:bigint,notnull," json:"part_id"`
|
PartID int64 `bun:"part_id,type:bigint,notnull," json:"part_id"`
|
||||||
StageID int64 `bun:"stage_id,type:bigint,notnull," json:"stage_id"`
|
StageID int64 `bun:"stage_id,type:bigint,notnull," json:"stage_id"`
|
||||||
RelPartID *ModelPublicAgentParts `bun:"rel:has-one,join:part_id=id" json:"relpartid,omitempty"` // Has one ModelPublicAgentParts
|
RelPartID *ModelPublicAgentParts `bun:"rel:has-one,join:part_id=id" json:"relpartid,omitempty"` // Has one ModelPublicAgentParts
|
||||||
|
|||||||
@@ -3,18 +3,18 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicArcStages struct {
|
type ModelPublicArcStages struct {
|
||||||
bun.BaseModel `bun:"table:public.arc_stages,alias:arc_stages"`
|
bun.BaseModel `bun:"table:public.arc_stages,alias:arc_stages"`
|
||||||
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
||||||
ArcID int64 `bun:"arc_id,type:bigint,notnull," json:"arc_id"`
|
ArcID int64 `bun:"arc_id,type:bigint,notnull," json:"arc_id"`
|
||||||
Condition resolvespec_common.SqlString `bun:"condition,type:text,default:'',notnull," json:"condition"`
|
Condition sql_types.SqlString `bun:"condition,type:text,default:'',notnull," json:"condition"`
|
||||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
Description resolvespec_common.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
|
Description sql_types.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
|
||||||
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"`
|
Name sql_types.SqlString `bun:"name,type:text,notnull," json:"name"`
|
||||||
StageOrder int32 `bun:"stage_order,type:int,default:0,notnull," json:"stage_order"`
|
StageOrder int32 `bun:"stage_order,type:int,default:0,notnull," json:"stage_order"`
|
||||||
RelArcID *ModelPublicCharacterArcs `bun:"rel:has-one,join:arc_id=id" json:"relarcid,omitempty"` // Has one ModelPublicCharacterArcs
|
RelArcID *ModelPublicCharacterArcs `bun:"rel:has-one,join:arc_id=id" json:"relarcid,omitempty"` // Has one ModelPublicCharacterArcs
|
||||||
RelStageIDPublicArcStageParts []*ModelPublicArcStageParts `bun:"rel:has-many,join:id=stage_id" json:"relstageidpublicarcstageparts,omitempty"` // Has many ModelPublicArcStageParts
|
RelStageIDPublicArcStageParts []*ModelPublicArcStageParts `bun:"rel:has-many,join:id=stage_id" json:"relstageidpublicarcstageparts,omitempty"` // Has many ModelPublicArcStageParts
|
||||||
|
|||||||
@@ -3,18 +3,20 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicCharacterArcs struct {
|
type ModelPublicCharacterArcs struct {
|
||||||
bun.BaseModel `bun:"table:public.character_arcs,alias:character_arcs"`
|
bun.BaseModel `bun:"table:public.character_arcs,alias:character_arcs"`
|
||||||
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
||||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
Description resolvespec_common.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
|
Description sql_types.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
|
||||||
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"`
|
Name sql_types.SqlString `bun:"name,type:text,notnull," json:"name"`
|
||||||
Summary resolvespec_common.SqlString `bun:"summary,type:text,default:'',notnull," json:"summary"`
|
Summary sql_types.SqlString `bun:"summary,type:text,default:'',notnull," json:"summary"`
|
||||||
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
TenantID sql_types.SqlString `bun:"tenant_id,type:text,nullzero," json:"tenant_id"`
|
||||||
|
UpdatedAt sql_types.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
||||||
|
RelTenantID *ModelPublicTenants `bun:"rel:has-one,join:tenant_id=id" json:"reltenantid,omitempty"` // Has one ModelPublicTenants
|
||||||
RelArcIDPublicArcStages []*ModelPublicArcStages `bun:"rel:has-many,join:id=arc_id" json:"relarcidpublicarcstages,omitempty"` // Has many ModelPublicArcStages
|
RelArcIDPublicArcStages []*ModelPublicArcStages `bun:"rel:has-many,join:id=arc_id" json:"relarcidpublicarcstages,omitempty"` // Has many ModelPublicArcStages
|
||||||
RelArcIDPublicPersonaArcs []*ModelPublicPersonaArc `bun:"rel:has-many,join:id=arc_id" json:"relarcidpublicpersonaarcs,omitempty"` // Has many ModelPublicPersonaArc
|
RelArcIDPublicPersonaArcs []*ModelPublicPersonaArc `bun:"rel:has-many,join:id=arc_id" json:"relarcidpublicpersonaarcs,omitempty"` // Has many ModelPublicPersonaArc
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,25 +3,27 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicChatHistories struct {
|
type ModelPublicChatHistories struct {
|
||||||
bun.BaseModel `bun:"table:public.chat_histories,alias:chat_histories"`
|
bun.BaseModel `bun:"table:public.chat_histories,alias:chat_histories"`
|
||||||
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
||||||
AgentID resolvespec_common.SqlString `bun:"agent_id,type:text,nullzero," json:"agent_id"`
|
AgentID sql_types.SqlString `bun:"agent_id,type:text,nullzero," json:"agent_id"`
|
||||||
Channel resolvespec_common.SqlString `bun:"channel,type:text,nullzero," json:"channel"`
|
Channel sql_types.SqlString `bun:"channel,type:text,nullzero," json:"channel"`
|
||||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
GUID resolvespec_common.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
|
GUID sql_types.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
|
||||||
Messages resolvespec_common.SqlJSONB `bun:"messages,type:jsonb,default:'[]',notnull," json:"messages"`
|
Messages sql_types.SqlJSONB `bun:"messages,type:jsonb,default:'[]',notnull," json:"messages"`
|
||||||
Metadata resolvespec_common.SqlJSONB `bun:"metadata,type:jsonb,default:'{}',notnull," json:"metadata"`
|
Metadata sql_types.SqlJSONB `bun:"metadata,type:jsonb,default:'{}',notnull," json:"metadata"`
|
||||||
ProjectID resolvespec_common.SqlInt64 `bun:"project_id,type:bigint,nullzero," json:"project_id"`
|
ProjectID sql_types.SqlInt64 `bun:"project_id,type:bigint,nullzero," json:"project_id"`
|
||||||
SessionID resolvespec_common.SqlString `bun:"session_id,type:text,notnull," json:"session_id"`
|
SessionID sql_types.SqlString `bun:"session_id,type:text,notnull," json:"session_id"`
|
||||||
Summary resolvespec_common.SqlString `bun:"summary,type:text,nullzero," json:"summary"`
|
Summary sql_types.SqlString `bun:"summary,type:text,nullzero," json:"summary"`
|
||||||
Title resolvespec_common.SqlString `bun:"title,type:text,nullzero," json:"title"`
|
TenantID sql_types.SqlString `bun:"tenant_id,type:text,nullzero," json:"tenant_id"`
|
||||||
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
Title sql_types.SqlString `bun:"title,type:text,nullzero," json:"title"`
|
||||||
|
UpdatedAt sql_types.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
||||||
RelProjectID *ModelPublicProjects `bun:"rel:has-one,join:project_id=id" json:"relprojectid,omitempty"` // Has one ModelPublicProjects
|
RelProjectID *ModelPublicProjects `bun:"rel:has-one,join:project_id=id" json:"relprojectid,omitempty"` // Has one ModelPublicProjects
|
||||||
|
RelTenantID *ModelPublicTenants `bun:"rel:has-one,join:tenant_id=id" json:"reltenantid,omitempty"` // Has one ModelPublicTenants
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName returns the table name for ModelPublicChatHistories
|
// TableName returns the table name for ModelPublicChatHistories
|
||||||
|
|||||||
@@ -3,20 +3,20 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicEmbeddings struct {
|
type ModelPublicEmbeddings struct {
|
||||||
bun.BaseModel `bun:"table:public.embeddings,alias:embeddings"`
|
bun.BaseModel `bun:"table:public.embeddings,alias:embeddings"`
|
||||||
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
||||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),nullzero," json:"created_at"`
|
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),nullzero," json:"created_at"`
|
||||||
Dim int32 `bun:"dim,type:int,notnull," json:"dim"`
|
Dim int32 `bun:"dim,type:int,notnull," json:"dim"`
|
||||||
Embedding resolvespec_common.SqlVector `bun:"embedding,type:vector,notnull," json:"embedding"`
|
Embedding sql_types.SqlVector `bun:"embedding,type:vector,notnull," json:"embedding"`
|
||||||
GUID resolvespec_common.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
|
GUID sql_types.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
|
||||||
Model resolvespec_common.SqlString `bun:"model,type:text,notnull,unique:uidx_embeddings_thought_id_model," json:"model"`
|
Model sql_types.SqlString `bun:"model,type:text,notnull,unique:uidx_embeddings_thought_id_model," json:"model"`
|
||||||
ThoughtID int64 `bun:"thought_id,type:bigint,notnull,unique:uidx_embeddings_thought_id_model," json:"thought_id"`
|
ThoughtID int64 `bun:"thought_id,type:bigint,notnull,unique:uidx_embeddings_thought_id_model," json:"thought_id"`
|
||||||
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),nullzero," json:"updated_at"`
|
UpdatedAt sql_types.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),nullzero," json:"updated_at"`
|
||||||
RelThoughtID *ModelPublicThoughts `bun:"rel:has-one,join:thought_id=id" json:"relthoughtid,omitempty"` // Has one ModelPublicThoughts
|
RelThoughtID *ModelPublicThoughts `bun:"rel:has-one,join:thought_id=id" json:"relthoughtid,omitempty"` // Has one ModelPublicThoughts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,39 +3,42 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicLearnings struct {
|
type ModelPublicLearnings struct {
|
||||||
bun.BaseModel `bun:"table:public.learnings,alias:learnings"`
|
bun.BaseModel `bun:"table:public.learnings,alias:learnings"`
|
||||||
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
||||||
ActionRequired bool `bun:"action_required,type:boolean,default:false,notnull," json:"action_required"`
|
ActionRequired bool `bun:"action_required,type:boolean,default:false,notnull," json:"action_required"`
|
||||||
Area resolvespec_common.SqlString `bun:"area,type:text,default:'other',notnull," json:"area"`
|
Area sql_types.SqlString `bun:"area,type:text,default:'other',notnull," json:"area"`
|
||||||
Category resolvespec_common.SqlString `bun:"category,type:text,default:'insight',notnull," json:"category"`
|
Category sql_types.SqlString `bun:"category,type:text,default:'insight',notnull," json:"category"`
|
||||||
Confidence resolvespec_common.SqlString `bun:"confidence,type:text,default:'hypothesis',notnull," json:"confidence"`
|
Confidence sql_types.SqlString `bun:"confidence,type:text,default:'hypothesis',notnull," json:"confidence"`
|
||||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
Details resolvespec_common.SqlString `bun:"details,type:text,default:'',notnull," json:"details"`
|
Details sql_types.SqlString `bun:"details,type:text,default:'',notnull," json:"details"`
|
||||||
DuplicateOfLearningID resolvespec_common.SqlInt64 `bun:"duplicate_of_learning_id,type:bigint,nullzero," json:"duplicate_of_learning_id"`
|
DuplicateOfLearningID sql_types.SqlInt64 `bun:"duplicate_of_learning_id,type:bigint,nullzero," json:"duplicate_of_learning_id"`
|
||||||
GUID resolvespec_common.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
|
GUID sql_types.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
|
||||||
Priority resolvespec_common.SqlString `bun:"priority,type:text,default:'medium',notnull," json:"priority"`
|
Priority sql_types.SqlString `bun:"priority,type:text,default:'medium',notnull," json:"priority"`
|
||||||
ProjectID resolvespec_common.SqlInt64 `bun:"project_id,type:bigint,nullzero," json:"project_id"`
|
ProjectID sql_types.SqlInt64 `bun:"project_id,type:bigint,nullzero," json:"project_id"`
|
||||||
RelatedSkillID resolvespec_common.SqlInt64 `bun:"related_skill_id,type:bigint,nullzero," json:"related_skill_id"`
|
RelatedSkillID sql_types.SqlInt64 `bun:"related_skill_id,type:bigint,nullzero," json:"related_skill_id"`
|
||||||
RelatedThoughtID resolvespec_common.SqlInt64 `bun:"related_thought_id,type:bigint,nullzero," json:"related_thought_id"`
|
RelatedThoughtID sql_types.SqlInt64 `bun:"related_thought_id,type:bigint,nullzero," json:"related_thought_id"`
|
||||||
ReviewedAt resolvespec_common.SqlTimeStamp `bun:"reviewed_at,type:timestamptz,nullzero," json:"reviewed_at"`
|
ReviewedAt sql_types.SqlTimeStamp `bun:"reviewed_at,type:timestamptz,nullzero," json:"reviewed_at"`
|
||||||
ReviewedBy resolvespec_common.SqlString `bun:"reviewed_by,type:text,nullzero," json:"reviewed_by"`
|
ReviewedBy sql_types.SqlString `bun:"reviewed_by,type:text,nullzero," json:"reviewed_by"`
|
||||||
SourceRef resolvespec_common.SqlString `bun:"source_ref,type:text,nullzero," json:"source_ref"`
|
SourceRef sql_types.SqlString `bun:"source_ref,type:text,nullzero," json:"source_ref"`
|
||||||
SourceType resolvespec_common.SqlString `bun:"source_type,type:text,nullzero," json:"source_type"`
|
SourceType sql_types.SqlString `bun:"source_type,type:text,nullzero," json:"source_type"`
|
||||||
Status resolvespec_common.SqlString `bun:"status,type:text,default:'pending',notnull," json:"status"`
|
Status sql_types.SqlString `bun:"status,type:text,default:'pending',notnull," json:"status"`
|
||||||
Summary resolvespec_common.SqlString `bun:"summary,type:text,notnull," json:"summary"`
|
Summary sql_types.SqlString `bun:"summary,type:text,notnull," json:"summary"`
|
||||||
SupersedesLearningID resolvespec_common.SqlInt64 `bun:"supersedes_learning_id,type:bigint,nullzero," json:"supersedes_learning_id"`
|
SupersedesLearningID sql_types.SqlInt64 `bun:"supersedes_learning_id,type:bigint,nullzero," json:"supersedes_learning_id"`
|
||||||
Tags resolvespec_common.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
|
Tags sql_types.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
|
||||||
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
TenantID sql_types.SqlString `bun:"tenant_id,type:text,nullzero," json:"tenant_id"`
|
||||||
|
UpdatedAt sql_types.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
||||||
RelDuplicateOfLearningID *ModelPublicLearnings `bun:"rel:has-one,join:duplicate_of_learning_id=id" json:"relduplicateoflearningid,omitempty"` // Has one ModelPublicLearnings
|
RelDuplicateOfLearningID *ModelPublicLearnings `bun:"rel:has-one,join:duplicate_of_learning_id=id" json:"relduplicateoflearningid,omitempty"` // Has one ModelPublicLearnings
|
||||||
RelProjectID *ModelPublicProjects `bun:"rel:has-one,join:project_id=id" json:"relprojectid,omitempty"` // Has one ModelPublicProjects
|
RelProjectID *ModelPublicProjects `bun:"rel:has-one,join:project_id=id" json:"relprojectid,omitempty"` // Has one ModelPublicProjects
|
||||||
RelRelatedSkillID *ModelPublicAgentSkills `bun:"rel:has-one,join:related_skill_id=id" json:"relrelatedskillid,omitempty"` // Has one ModelPublicAgentSkills
|
RelRelatedSkillID *ModelPublicAgentSkills `bun:"rel:has-one,join:related_skill_id=id" json:"relrelatedskillid,omitempty"` // Has one ModelPublicAgentSkills
|
||||||
RelRelatedThoughtID *ModelPublicThoughts `bun:"rel:has-one,join:related_thought_id=id" json:"relrelatedthoughtid,omitempty"` // Has one ModelPublicThoughts
|
RelRelatedThoughtID *ModelPublicThoughts `bun:"rel:has-one,join:related_thought_id=id" json:"relrelatedthoughtid,omitempty"` // Has one ModelPublicThoughts
|
||||||
RelSupersedesLearningID *ModelPublicLearnings `bun:"rel:has-one,join:supersedes_learning_id=id" json:"relsupersedeslearningid,omitempty"` // Has one ModelPublicLearnings
|
RelSupersedesLearningID *ModelPublicLearnings `bun:"rel:has-one,join:supersedes_learning_id=id" json:"relsupersedeslearningid,omitempty"` // Has one ModelPublicLearnings
|
||||||
|
RelTenantID *ModelPublicTenants `bun:"rel:has-one,join:tenant_id=id" json:"reltenantid,omitempty"` // Has one ModelPublicTenants
|
||||||
|
RelLearningIDPublicThoughtLearningLinks []*ModelPublicThoughtLearningLinks `bun:"rel:has-many,join:id=learning_id" json:"rellearningidpublicthoughtlearninglinks,omitempty"` // Has many ModelPublicThoughtLearningLinks
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName returns the table name for ModelPublicLearnings
|
// TableName returns the table name for ModelPublicLearnings
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// Code generated by relspecgo. DO NOT EDIT.
|
||||||
|
package generatedmodels
|
||||||
|
|
||||||
|
import (
|
||||||
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ModelPublicManagedAPIKeys struct {
|
||||||
|
bun.BaseModel `bun:"table:public.managed_api_keys,alias:managed_api_keys"`
|
||||||
|
KeyID sql_types.SqlString `bun:"key_id,type:text,pk," json:"key_id"`
|
||||||
|
SecretHash sql_types.SqlString `bun:"secret_hash,type:text,notnull," json:"secret_hash"`
|
||||||
|
RelKeyID *ModelPublicAPIKeyAssignments `bun:"rel:has-one,join:key_id=key_id" json:"relkeyid,omitempty"` // Has one ModelPublicAPIKeyAssignments
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName returns the table name for ModelPublicManagedAPIKeys
|
||||||
|
func (m ModelPublicManagedAPIKeys) TableName() string {
|
||||||
|
return "public.managed_api_keys"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableNameOnly returns the table name without schema for ModelPublicManagedAPIKeys
|
||||||
|
func (m ModelPublicManagedAPIKeys) TableNameOnly() string {
|
||||||
|
return "managed_api_keys"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SchemaName returns the schema name for ModelPublicManagedAPIKeys
|
||||||
|
func (m ModelPublicManagedAPIKeys) SchemaName() string {
|
||||||
|
return "public"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetID returns the primary key value
|
||||||
|
func (m ModelPublicManagedAPIKeys) GetID() string {
|
||||||
|
return m.KeyID.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDStr returns the primary key as a string
|
||||||
|
func (m ModelPublicManagedAPIKeys) GetIDStr() string {
|
||||||
|
return m.KeyID.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetID sets the primary key value
|
||||||
|
func (m ModelPublicManagedAPIKeys) SetID(newid string) {
|
||||||
|
m.UpdateID(newid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateID updates the primary key value
|
||||||
|
func (m *ModelPublicManagedAPIKeys) UpdateID(newid string) {
|
||||||
|
m.KeyID.FromString(newid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDName returns the name of the primary key column
|
||||||
|
func (m ModelPublicManagedAPIKeys) GetIDName() string {
|
||||||
|
return "key_id"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPrefix returns the table prefix
|
||||||
|
func (m ModelPublicManagedAPIKeys) GetPrefix() string {
|
||||||
|
return "MAK"
|
||||||
|
}
|
||||||
@@ -3,17 +3,17 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicOauthClients struct {
|
type ModelPublicOauthClients struct {
|
||||||
bun.BaseModel `bun:"table:public.oauth_clients,alias:oauth_clients"`
|
bun.BaseModel `bun:"table:public.oauth_clients,alias:oauth_clients"`
|
||||||
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
||||||
ClientID resolvespec_common.SqlString `bun:"client_id,type:text,notnull," json:"client_id"`
|
ClientID sql_types.SqlString `bun:"client_id,type:text,notnull," json:"client_id"`
|
||||||
ClientName resolvespec_common.SqlString `bun:"client_name,type:text,default:'',notnull," json:"client_name"`
|
ClientName sql_types.SqlString `bun:"client_name,type:text,default:'',notnull," json:"client_name"`
|
||||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
RedirectUris resolvespec_common.SqlStringArray `bun:"redirect_uris,type:text[],default:'{}',notnull," json:"redirect_uris"`
|
RedirectUris sql_types.SqlStringArray `bun:"redirect_uris,type:text[],default:'{}',notnull," json:"redirect_uris"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName returns the table name for ModelPublicOauthClients
|
// TableName returns the table name for ModelPublicOauthClients
|
||||||
|
|||||||
@@ -3,17 +3,17 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicPersonaArc struct {
|
type ModelPublicPersonaArc struct {
|
||||||
bun.BaseModel `bun:"table:public.persona_arc,alias:persona_arc"`
|
bun.BaseModel `bun:"table:public.persona_arc,alias:persona_arc"`
|
||||||
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
||||||
PersonaID int64 `bun:"persona_id,type:bigint,pk," json:"persona_id"`
|
|
||||||
ArcID int64 `bun:"arc_id,type:bigint,notnull," json:"arc_id"`
|
ArcID int64 `bun:"arc_id,type:bigint,notnull," json:"arc_id"`
|
||||||
CurrentStageID int64 `bun:"current_stage_id,type:bigint,notnull," json:"current_stage_id"`
|
CurrentStageID int64 `bun:"current_stage_id,type:bigint,notnull," json:"current_stage_id"`
|
||||||
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
PersonaID int64 `bun:"persona_id,type:bigint,notnull," json:"persona_id"`
|
||||||
|
UpdatedAt sql_types.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
||||||
RelArcID *ModelPublicCharacterArcs `bun:"rel:has-one,join:arc_id=id" json:"relarcid,omitempty"` // Has one ModelPublicCharacterArcs
|
RelArcID *ModelPublicCharacterArcs `bun:"rel:has-one,join:arc_id=id" json:"relarcid,omitempty"` // Has one ModelPublicCharacterArcs
|
||||||
RelCurrentStageID *ModelPublicArcStages `bun:"rel:has-one,join:current_stage_id=id" json:"relcurrentstageid,omitempty"` // Has one ModelPublicArcStages
|
RelCurrentStageID *ModelPublicArcStages `bun:"rel:has-one,join:current_stage_id=id" json:"relcurrentstageid,omitempty"` // Has one ModelPublicArcStages
|
||||||
RelPersonaID *ModelPublicAgentPersonas `bun:"rel:has-one,join:persona_id=id" json:"relpersonaid,omitempty"` // Has one ModelPublicAgentPersonas
|
RelPersonaID *ModelPublicAgentPersonas `bun:"rel:has-one,join:persona_id=id" json:"relpersonaid,omitempty"` // Has one ModelPublicAgentPersonas
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicPlanDependencies struct {
|
type ModelPublicPlanDependencies struct {
|
||||||
bun.BaseModel `bun:"table:public.plan_dependencies,alias:plan_dependencies"`
|
bun.BaseModel `bun:"table:public.plan_dependencies,alias:plan_dependencies"`
|
||||||
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
||||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
DependsOnPlanID int64 `bun:"depends_on_plan_id,type:bigint,notnull,unique:uidx_plan_dependencies_plan_id_depends_on_plan_id," json:"depends_on_plan_id"`
|
DependsOnPlanID int64 `bun:"depends_on_plan_id,type:bigint,notnull,unique:uidx_plan_dependencies_plan_id_depends_on_plan_id," json:"depends_on_plan_id"`
|
||||||
PlanID int64 `bun:"plan_id,type:bigint,notnull,unique:uidx_plan_dependencies_plan_id_depends_on_plan_id," json:"plan_id"`
|
PlanID int64 `bun:"plan_id,type:bigint,notnull,unique:uidx_plan_dependencies_plan_id_depends_on_plan_id," json:"plan_id"`
|
||||||
RelDependsOnPlanID *ModelPublicPlans `bun:"rel:has-one,join:depends_on_plan_id=id" json:"reldependsonplanid,omitempty"` // Has one ModelPublicPlans
|
RelDependsOnPlanID *ModelPublicPlans `bun:"rel:has-one,join:depends_on_plan_id=id" json:"reldependsonplanid,omitempty"` // Has one ModelPublicPlans
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicPlanGuardrails struct {
|
type ModelPublicPlanGuardrails struct {
|
||||||
bun.BaseModel `bun:"table:public.plan_guardrails,alias:plan_guardrails"`
|
bun.BaseModel `bun:"table:public.plan_guardrails,alias:plan_guardrails"`
|
||||||
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
||||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
GuardrailID int64 `bun:"guardrail_id,type:bigint,notnull,unique:uidx_plan_guardrails_plan_id_guardrail_id," json:"guardrail_id"`
|
GuardrailID int64 `bun:"guardrail_id,type:bigint,notnull,unique:uidx_plan_guardrails_plan_id_guardrail_id," json:"guardrail_id"`
|
||||||
PlanID int64 `bun:"plan_id,type:bigint,notnull,unique:uidx_plan_guardrails_plan_id_guardrail_id," json:"plan_id"`
|
PlanID int64 `bun:"plan_id,type:bigint,notnull,unique:uidx_plan_guardrails_plan_id_guardrail_id," json:"plan_id"`
|
||||||
RelGuardrailID *ModelPublicAgentGuardrails `bun:"rel:has-one,join:guardrail_id=id" json:"relguardrailid,omitempty"` // Has one ModelPublicAgentGuardrails
|
RelGuardrailID *ModelPublicAgentGuardrails `bun:"rel:has-one,join:guardrail_id=id" json:"relguardrailid,omitempty"` // Has one ModelPublicAgentGuardrails
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicPlanRelatedPlans struct {
|
type ModelPublicPlanRelatedPlans struct {
|
||||||
bun.BaseModel `bun:"table:public.plan_related_plans,alias:plan_related_plans"`
|
bun.BaseModel `bun:"table:public.plan_related_plans,alias:plan_related_plans"`
|
||||||
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
||||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
PlanAID int64 `bun:"plan_a_id,type:bigint,notnull,unique:uidx_plan_related_plans_plan_a_id_plan_b_id," json:"plan_a_id"`
|
PlanAID int64 `bun:"plan_a_id,type:bigint,notnull,unique:uidx_plan_related_plans_plan_a_id_plan_b_id," json:"plan_a_id"`
|
||||||
PlanBID int64 `bun:"plan_b_id,type:bigint,notnull,unique:uidx_plan_related_plans_plan_a_id_plan_b_id," json:"plan_b_id"`
|
PlanBID int64 `bun:"plan_b_id,type:bigint,notnull,unique:uidx_plan_related_plans_plan_a_id_plan_b_id," json:"plan_b_id"`
|
||||||
RelPlanAID *ModelPublicPlans `bun:"rel:has-one,join:plan_a_id=id" json:"relplanaid,omitempty"` // Has one ModelPublicPlans
|
RelPlanAID *ModelPublicPlans `bun:"rel:has-one,join:plan_a_id=id" json:"relplanaid,omitempty"` // Has one ModelPublicPlans
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicPlanSkills struct {
|
type ModelPublicPlanSkills struct {
|
||||||
bun.BaseModel `bun:"table:public.plan_skills,alias:plan_skills"`
|
bun.BaseModel `bun:"table:public.plan_skills,alias:plan_skills"`
|
||||||
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
||||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
PlanID int64 `bun:"plan_id,type:bigint,notnull,unique:uidx_plan_skills_plan_id_skill_id," json:"plan_id"`
|
PlanID int64 `bun:"plan_id,type:bigint,notnull,unique:uidx_plan_skills_plan_id_skill_id," json:"plan_id"`
|
||||||
SkillID int64 `bun:"skill_id,type:bigint,notnull,unique:uidx_plan_skills_plan_id_skill_id," json:"skill_id"`
|
SkillID int64 `bun:"skill_id,type:bigint,notnull,unique:uidx_plan_skills_plan_id_skill_id," json:"skill_id"`
|
||||||
RelPlanID *ModelPublicPlans `bun:"rel:has-one,join:plan_id=id" json:"relplanid,omitempty"` // Has one ModelPublicPlans
|
RelPlanID *ModelPublicPlans `bun:"rel:has-one,join:plan_id=id" json:"relplanid,omitempty"` // Has one ModelPublicPlans
|
||||||
|
|||||||
@@ -3,30 +3,32 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicPlans struct {
|
type ModelPublicPlans struct {
|
||||||
bun.BaseModel `bun:"table:public.plans,alias:plans"`
|
bun.BaseModel `bun:"table:public.plans,alias:plans"`
|
||||||
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
||||||
CompletedAt resolvespec_common.SqlTimeStamp `bun:"completed_at,type:timestamptz,nullzero," json:"completed_at"`
|
CompletedAt sql_types.SqlTimeStamp `bun:"completed_at,type:timestamptz,nullzero," json:"completed_at"`
|
||||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
Description resolvespec_common.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
|
Description sql_types.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
|
||||||
DueDate resolvespec_common.SqlTimeStamp `bun:"due_date,type:timestamptz,nullzero," json:"due_date"`
|
DueDate sql_types.SqlTimeStamp `bun:"due_date,type:timestamptz,nullzero," json:"due_date"`
|
||||||
GUID resolvespec_common.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
|
GUID sql_types.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
|
||||||
LastReviewedAt resolvespec_common.SqlTimeStamp `bun:"last_reviewed_at,type:timestamptz,nullzero," json:"last_reviewed_at"`
|
LastReviewedAt sql_types.SqlTimeStamp `bun:"last_reviewed_at,type:timestamptz,nullzero," json:"last_reviewed_at"`
|
||||||
Owner resolvespec_common.SqlString `bun:"owner,type:text,nullzero," json:"owner"`
|
Owner sql_types.SqlString `bun:"owner,type:text,nullzero," json:"owner"`
|
||||||
Priority resolvespec_common.SqlString `bun:"priority,type:text,default:'medium',notnull," json:"priority"` // low, medium, high, critical
|
Priority sql_types.SqlString `bun:"priority,type:text,default:'medium',notnull," json:"priority"` // low, medium, high, critical
|
||||||
ProjectID resolvespec_common.SqlInt64 `bun:"project_id,type:bigint,nullzero," json:"project_id"`
|
ProjectID sql_types.SqlInt64 `bun:"project_id,type:bigint,nullzero," json:"project_id"`
|
||||||
ReviewedBy resolvespec_common.SqlString `bun:"reviewed_by,type:text,nullzero," json:"reviewed_by"`
|
ReviewedBy sql_types.SqlString `bun:"reviewed_by,type:text,nullzero," json:"reviewed_by"`
|
||||||
Status resolvespec_common.SqlString `bun:"status,type:text,default:'draft',notnull," json:"status"` // draft, active, blocked, completed, cancelled, superseded
|
Status sql_types.SqlString `bun:"status,type:text,default:'draft',notnull," json:"status"` // draft, active, blocked, completed, cancelled, superseded
|
||||||
SupersedesPlanID resolvespec_common.SqlInt64 `bun:"supersedes_plan_id,type:bigint,nullzero," json:"supersedes_plan_id"`
|
SupersedesPlanID sql_types.SqlInt64 `bun:"supersedes_plan_id,type:bigint,nullzero," json:"supersedes_plan_id"`
|
||||||
Tags resolvespec_common.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
|
Tags sql_types.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
|
||||||
Title resolvespec_common.SqlString `bun:"title,type:text,notnull," json:"title"`
|
TenantID sql_types.SqlString `bun:"tenant_id,type:text,nullzero," json:"tenant_id"`
|
||||||
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
Title sql_types.SqlString `bun:"title,type:text,notnull," json:"title"`
|
||||||
|
UpdatedAt sql_types.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
||||||
RelProjectID *ModelPublicProjects `bun:"rel:has-one,join:project_id=id" json:"relprojectid,omitempty"` // Has one ModelPublicProjects
|
RelProjectID *ModelPublicProjects `bun:"rel:has-one,join:project_id=id" json:"relprojectid,omitempty"` // Has one ModelPublicProjects
|
||||||
RelSupersedesPlanID *ModelPublicPlans `bun:"rel:has-one,join:supersedes_plan_id=id" json:"relsupersedesplanid,omitempty"` // Has one ModelPublicPlans
|
RelSupersedesPlanID *ModelPublicPlans `bun:"rel:has-one,join:supersedes_plan_id=id" json:"relsupersedesplanid,omitempty"` // Has one ModelPublicPlans
|
||||||
|
RelTenantID *ModelPublicTenants `bun:"rel:has-one,join:tenant_id=id" json:"reltenantid,omitempty"` // Has one ModelPublicTenants
|
||||||
RelDependsOnPlanIDPublicPlanDependencies []*ModelPublicPlanDependencies `bun:"rel:has-many,join:id=depends_on_plan_id" json:"reldependsonplanidpublicplandependencies,omitempty"` // Has many ModelPublicPlanDependencies
|
RelDependsOnPlanIDPublicPlanDependencies []*ModelPublicPlanDependencies `bun:"rel:has-many,join:id=depends_on_plan_id" json:"reldependsonplanidpublicplandependencies,omitempty"` // Has many ModelPublicPlanDependencies
|
||||||
RelPlanIDPublicPlanDependencies []*ModelPublicPlanDependencies `bun:"rel:has-many,join:id=plan_id" json:"relplanidpublicplandependencies,omitempty"` // Has many ModelPublicPlanDependencies
|
RelPlanIDPublicPlanDependencies []*ModelPublicPlanDependencies `bun:"rel:has-many,join:id=plan_id" json:"relplanidpublicplandependencies,omitempty"` // Has many ModelPublicPlanDependencies
|
||||||
RelPlanAIDPublicPlanRelatedPlans []*ModelPublicPlanRelatedPlans `bun:"rel:has-many,join:id=plan_a_id" json:"relplanaidpublicplanrelatedplans,omitempty"` // Has many ModelPublicPlanRelatedPlans
|
RelPlanAIDPublicPlanRelatedPlans []*ModelPublicPlanRelatedPlans `bun:"rel:has-many,join:id=plan_a_id" json:"relplanaidpublicplanrelatedplans,omitempty"` // Has many ModelPublicPlanRelatedPlans
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicProjectGuardrails struct {
|
type ModelPublicProjectGuardrails struct {
|
||||||
bun.BaseModel `bun:"table:public.project_guardrails,alias:project_guardrails"`
|
bun.BaseModel `bun:"table:public.project_guardrails,alias:project_guardrails"`
|
||||||
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
||||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
GuardrailID int64 `bun:"guardrail_id,type:bigint,notnull," json:"guardrail_id"`
|
GuardrailID int64 `bun:"guardrail_id,type:bigint,notnull," json:"guardrail_id"`
|
||||||
ProjectID int64 `bun:"project_id,type:bigint,notnull," json:"project_id"`
|
ProjectID int64 `bun:"project_id,type:bigint,notnull," json:"project_id"`
|
||||||
RelGuardrailID *ModelPublicAgentGuardrails `bun:"rel:has-one,join:guardrail_id=id" json:"relguardrailid,omitempty"` // Has one ModelPublicAgentGuardrails
|
RelGuardrailID *ModelPublicAgentGuardrails `bun:"rel:has-one,join:guardrail_id=id" json:"relguardrailid,omitempty"` // Has one ModelPublicAgentGuardrails
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicProjectPersonas struct {
|
type ModelPublicProjectPersonas struct {
|
||||||
bun.BaseModel `bun:"table:public.project_personas,alias:project_personas"`
|
bun.BaseModel `bun:"table:public.project_personas,alias:project_personas"`
|
||||||
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
||||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
IsDefault bool `bun:"is_default,type:boolean,default:false,notnull," json:"is_default"`
|
IsDefault bool `bun:"is_default,type:boolean,default:false,notnull," json:"is_default"`
|
||||||
PersonaID int64 `bun:"persona_id,type:bigint,notnull," json:"persona_id"`
|
PersonaID int64 `bun:"persona_id,type:bigint,notnull," json:"persona_id"`
|
||||||
ProjectID int64 `bun:"project_id,type:bigint,notnull," json:"project_id"`
|
ProjectID int64 `bun:"project_id,type:bigint,notnull," json:"project_id"`
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicProjectSkills struct {
|
type ModelPublicProjectSkills struct {
|
||||||
bun.BaseModel `bun:"table:public.project_skills,alias:project_skills"`
|
bun.BaseModel `bun:"table:public.project_skills,alias:project_skills"`
|
||||||
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
||||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
Override bool `bun:"override,type:boolean,default:false,notnull," json:"override"`
|
Override bool `bun:"override,type:boolean,default:false,notnull," json:"override"`
|
||||||
ProjectID int64 `bun:"project_id,type:bigint,notnull," json:"project_id"`
|
ProjectID int64 `bun:"project_id,type:bigint,notnull," json:"project_id"`
|
||||||
SkillID int64 `bun:"skill_id,type:bigint,notnull," json:"skill_id"`
|
SkillID int64 `bun:"skill_id,type:bigint,notnull," json:"skill_id"`
|
||||||
|
|||||||
@@ -3,19 +3,20 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicProjects struct {
|
type ModelPublicProjects struct {
|
||||||
bun.BaseModel `bun:"table:public.projects,alias:projects"`
|
bun.BaseModel `bun:"table:public.projects,alias:projects"`
|
||||||
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
||||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),nullzero," json:"created_at"`
|
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),nullzero," json:"created_at"`
|
||||||
Description resolvespec_common.SqlString `bun:"description,type:text,nullzero," json:"description"`
|
Description sql_types.SqlString `bun:"description,type:text,nullzero," json:"description"`
|
||||||
GUID resolvespec_common.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
|
GUID sql_types.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
|
||||||
LastActiveAt resolvespec_common.SqlTimeStamp `bun:"last_active_at,type:timestamptz,default:now(),nullzero," json:"last_active_at"`
|
LastActiveAt sql_types.SqlTimeStamp `bun:"last_active_at,type:timestamptz,default:now(),nullzero," json:"last_active_at"`
|
||||||
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"`
|
Name sql_types.SqlString `bun:"name,type:text,notnull,unique:uidx_projects_tenant_id_name," json:"name"`
|
||||||
ThoughtCount resolvespec_common.SqlInt64 `bun:"thought_count,scanonly" json:"thought_count"`
|
TenantID sql_types.SqlString `bun:"tenant_id,type:text,nullzero,unique:uidx_projects_tenant_id_name," json:"tenant_id"`
|
||||||
|
RelTenantID *ModelPublicTenants `bun:"rel:has-one,join:tenant_id=id" json:"reltenantid,omitempty"` // Has one ModelPublicTenants
|
||||||
RelProjectIDPublicProjectPersonas []*ModelPublicProjectPersonas `bun:"rel:has-many,join:id=project_id" json:"relprojectidpublicprojectpersonas,omitempty"` // Has many ModelPublicProjectPersonas
|
RelProjectIDPublicProjectPersonas []*ModelPublicProjectPersonas `bun:"rel:has-many,join:id=project_id" json:"relprojectidpublicprojectpersonas,omitempty"` // Has many ModelPublicProjectPersonas
|
||||||
RelProjectIDPublicThoughts []*ModelPublicThoughts `bun:"rel:has-many,join:id=project_id" json:"relprojectidpublicthoughts,omitempty"` // Has many ModelPublicThoughts
|
RelProjectIDPublicThoughts []*ModelPublicThoughts `bun:"rel:has-many,join:id=project_id" json:"relprojectidpublicthoughts,omitempty"` // Has many ModelPublicThoughts
|
||||||
RelProjectIDPublicStoredFiles []*ModelPublicStoredFiles `bun:"rel:has-many,join:id=project_id" json:"relprojectidpublicstoredfiles,omitempty"` // Has many ModelPublicStoredFiles
|
RelProjectIDPublicStoredFiles []*ModelPublicStoredFiles `bun:"rel:has-many,join:id=project_id" json:"relprojectidpublicstoredfiles,omitempty"` // Has many ModelPublicStoredFiles
|
||||||
|
|||||||
@@ -3,26 +3,28 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicStoredFiles struct {
|
type ModelPublicStoredFiles struct {
|
||||||
bun.BaseModel `bun:"table:public.stored_files,alias:stored_files"`
|
bun.BaseModel `bun:"table:public.stored_files,alias:stored_files"`
|
||||||
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
||||||
Content []byte `bun:"content,type:bytea,notnull," json:"content"`
|
Content []byte `bun:"content,type:bytea,notnull," json:"content"`
|
||||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
Encoding resolvespec_common.SqlString `bun:"encoding,type:text,default:'base64',notnull," json:"encoding"`
|
Encoding sql_types.SqlString `bun:"encoding,type:text,default:'base64',notnull," json:"encoding"`
|
||||||
GUID resolvespec_common.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
|
GUID sql_types.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
|
||||||
Kind resolvespec_common.SqlString `bun:"kind,type:text,default:'file',notnull," json:"kind"`
|
Kind sql_types.SqlString `bun:"kind,type:text,default:'file',notnull," json:"kind"`
|
||||||
MediaType resolvespec_common.SqlString `bun:"media_type,type:text,notnull," json:"media_type"`
|
MediaType sql_types.SqlString `bun:"media_type,type:text,notnull," json:"media_type"`
|
||||||
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"`
|
Name sql_types.SqlString `bun:"name,type:text,notnull," json:"name"`
|
||||||
ProjectID resolvespec_common.SqlInt64 `bun:"project_id,type:bigint,nullzero," json:"project_id"`
|
ProjectID sql_types.SqlInt64 `bun:"project_id,type:bigint,nullzero," json:"project_id"`
|
||||||
Sha256 resolvespec_common.SqlString `bun:"sha256,type:text,notnull," json:"sha256"`
|
Sha256 sql_types.SqlString `bun:"sha256,type:text,notnull," json:"sha256"`
|
||||||
SizeBytes int64 `bun:"size_bytes,type:bigint,notnull," json:"size_bytes"`
|
SizeBytes int64 `bun:"size_bytes,type:bigint,notnull," json:"size_bytes"`
|
||||||
ThoughtID resolvespec_common.SqlInt64 `bun:"thought_id,type:bigint,nullzero," json:"thought_id"`
|
TenantID sql_types.SqlString `bun:"tenant_id,type:text,nullzero," json:"tenant_id"`
|
||||||
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
ThoughtID sql_types.SqlInt64 `bun:"thought_id,type:bigint,nullzero," json:"thought_id"`
|
||||||
|
UpdatedAt sql_types.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
||||||
RelProjectID *ModelPublicProjects `bun:"rel:has-one,join:project_id=id" json:"relprojectid,omitempty"` // Has one ModelPublicProjects
|
RelProjectID *ModelPublicProjects `bun:"rel:has-one,join:project_id=id" json:"relprojectid,omitempty"` // Has one ModelPublicProjects
|
||||||
|
RelTenantID *ModelPublicTenants `bun:"rel:has-one,join:tenant_id=id" json:"reltenantid,omitempty"` // Has one ModelPublicTenants
|
||||||
RelThoughtID *ModelPublicThoughts `bun:"rel:has-one,join:thought_id=id" json:"relthoughtid,omitempty"` // Has one ModelPublicThoughts
|
RelThoughtID *ModelPublicThoughts `bun:"rel:has-one,join:thought_id=id" json:"relthoughtid,omitempty"` // Has one ModelPublicThoughts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// Code generated by relspecgo. DO NOT EDIT.
|
||||||
|
package generatedmodels
|
||||||
|
|
||||||
|
import (
|
||||||
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ModelPublicTenantUsers struct {
|
||||||
|
bun.BaseModel `bun:"table:public.tenant_users,alias:tenant_users"`
|
||||||
|
ID sql_types.SqlString `bun:"id,type:text,pk," json:"id"`
|
||||||
|
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
|
Email sql_types.SqlString `bun:"email,type:text,nullzero,unique:uidx_tenant_users_tenant_id_email," json:"email"`
|
||||||
|
Name sql_types.SqlString `bun:"name,type:text,notnull," json:"name"`
|
||||||
|
TenantID sql_types.SqlString `bun:"tenant_id,type:text,notnull,unique:uidx_tenant_users_tenant_id_email," json:"tenant_id"`
|
||||||
|
RelTenantID *ModelPublicTenants `bun:"rel:has-one,join:tenant_id=id" json:"reltenantid,omitempty"` // Has one ModelPublicTenants
|
||||||
|
RelUserIDPublicAPIKeyAssignments []*ModelPublicAPIKeyAssignments `bun:"rel:has-many,join:id=user_id" json:"reluseridpublicapikeyassignments,omitempty"` // Has many ModelPublicAPIKeyAssignments
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName returns the table name for ModelPublicTenantUsers
|
||||||
|
func (m ModelPublicTenantUsers) TableName() string {
|
||||||
|
return "public.tenant_users"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableNameOnly returns the table name without schema for ModelPublicTenantUsers
|
||||||
|
func (m ModelPublicTenantUsers) TableNameOnly() string {
|
||||||
|
return "tenant_users"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SchemaName returns the schema name for ModelPublicTenantUsers
|
||||||
|
func (m ModelPublicTenantUsers) SchemaName() string {
|
||||||
|
return "public"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetID returns the primary key value
|
||||||
|
func (m ModelPublicTenantUsers) GetID() string {
|
||||||
|
return m.ID.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDStr returns the primary key as a string
|
||||||
|
func (m ModelPublicTenantUsers) GetIDStr() string {
|
||||||
|
return m.ID.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetID sets the primary key value
|
||||||
|
func (m ModelPublicTenantUsers) SetID(newid string) {
|
||||||
|
m.UpdateID(newid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateID updates the primary key value
|
||||||
|
func (m *ModelPublicTenantUsers) UpdateID(newid string) {
|
||||||
|
m.ID.FromString(newid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDName returns the name of the primary key column
|
||||||
|
func (m ModelPublicTenantUsers) GetIDName() string {
|
||||||
|
return "id"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPrefix returns the table prefix
|
||||||
|
func (m ModelPublicTenantUsers) GetPrefix() string {
|
||||||
|
return "TUE"
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// Code generated by relspecgo. DO NOT EDIT.
|
||||||
|
package generatedmodels
|
||||||
|
|
||||||
|
import (
|
||||||
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ModelPublicTenants struct {
|
||||||
|
bun.BaseModel `bun:"table:public.tenants,alias:tenants"`
|
||||||
|
ID sql_types.SqlString `bun:"id,type:text,pk," json:"id"`
|
||||||
|
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
|
Name sql_types.SqlString `bun:"name,type:text,notnull," json:"name"`
|
||||||
|
RelTenantIDPublicAgentPersonas []*ModelPublicAgentPersonas `bun:"rel:has-many,join:id=tenant_id" json:"reltenantidpublicagentpersonas,omitempty"` // Has many ModelPublicAgentPersonas
|
||||||
|
RelTenantIDPublicAgentParts []*ModelPublicAgentParts `bun:"rel:has-many,join:id=tenant_id" json:"reltenantidpublicagentparts,omitempty"` // Has many ModelPublicAgentParts
|
||||||
|
RelTenantIDPublicAgentTraits []*ModelPublicAgentTraits `bun:"rel:has-many,join:id=tenant_id" json:"reltenantidpublicagenttraits,omitempty"` // Has many ModelPublicAgentTraits
|
||||||
|
RelTenantIDPublicCharacterArcs []*ModelPublicCharacterArcs `bun:"rel:has-many,join:id=tenant_id" json:"reltenantidpubliccharacterarcs,omitempty"` // Has many ModelPublicCharacterArcs
|
||||||
|
RelTenantIDPublicThoughts []*ModelPublicThoughts `bun:"rel:has-many,join:id=tenant_id" json:"reltenantidpublicthoughts,omitempty"` // Has many ModelPublicThoughts
|
||||||
|
RelTenantIDPublicProjects []*ModelPublicProjects `bun:"rel:has-many,join:id=tenant_id" json:"reltenantidpublicprojects,omitempty"` // Has many ModelPublicProjects
|
||||||
|
RelTenantIDPublicStoredFiles []*ModelPublicStoredFiles `bun:"rel:has-many,join:id=tenant_id" json:"reltenantidpublicstoredfiles,omitempty"` // Has many ModelPublicStoredFiles
|
||||||
|
RelTenantIDPublicTenantUsers []*ModelPublicTenantUsers `bun:"rel:has-many,join:id=tenant_id" json:"reltenantidpublictenantusers,omitempty"` // Has many ModelPublicTenantUsers
|
||||||
|
RelTenantIDPublicAPIKeyAssignments []*ModelPublicAPIKeyAssignments `bun:"rel:has-many,join:id=tenant_id" json:"reltenantidpublicapikeyassignments,omitempty"` // Has many ModelPublicAPIKeyAssignments
|
||||||
|
RelTenantIDPublicChatHistories []*ModelPublicChatHistories `bun:"rel:has-many,join:id=tenant_id" json:"reltenantidpublicchathistories,omitempty"` // Has many ModelPublicChatHistories
|
||||||
|
RelTenantIDPublicLearnings []*ModelPublicLearnings `bun:"rel:has-many,join:id=tenant_id" json:"reltenantidpubliclearnings,omitempty"` // Has many ModelPublicLearnings
|
||||||
|
RelTenantIDPublicPlans []*ModelPublicPlans `bun:"rel:has-many,join:id=tenant_id" json:"reltenantidpublicplans,omitempty"` // Has many ModelPublicPlans
|
||||||
|
RelTenantIDPublicAgentSkills []*ModelPublicAgentSkills `bun:"rel:has-many,join:id=tenant_id" json:"reltenantidpublicagentskills,omitempty"` // Has many ModelPublicAgentSkills
|
||||||
|
RelTenantIDPublicAgentGuardrails []*ModelPublicAgentGuardrails `bun:"rel:has-many,join:id=tenant_id" json:"reltenantidpublicagentguardrails,omitempty"` // Has many ModelPublicAgentGuardrails
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName returns the table name for ModelPublicTenants
|
||||||
|
func (m ModelPublicTenants) TableName() string {
|
||||||
|
return "public.tenants"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableNameOnly returns the table name without schema for ModelPublicTenants
|
||||||
|
func (m ModelPublicTenants) TableNameOnly() string {
|
||||||
|
return "tenants"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SchemaName returns the schema name for ModelPublicTenants
|
||||||
|
func (m ModelPublicTenants) SchemaName() string {
|
||||||
|
return "public"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetID returns the primary key value
|
||||||
|
func (m ModelPublicTenants) GetID() string {
|
||||||
|
return m.ID.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDStr returns the primary key as a string
|
||||||
|
func (m ModelPublicTenants) GetIDStr() string {
|
||||||
|
return m.ID.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetID sets the primary key value
|
||||||
|
func (m ModelPublicTenants) SetID(newid string) {
|
||||||
|
m.UpdateID(newid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateID updates the primary key value
|
||||||
|
func (m *ModelPublicTenants) UpdateID(newid string) {
|
||||||
|
m.ID.FromString(newid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDName returns the name of the primary key column
|
||||||
|
func (m ModelPublicTenants) GetIDName() string {
|
||||||
|
return "id"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPrefix returns the table prefix
|
||||||
|
func (m ModelPublicTenants) GetPrefix() string {
|
||||||
|
return "TEN"
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// Code generated by relspecgo. DO NOT EDIT.
|
||||||
|
package generatedmodels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ModelPublicThoughtLearningLinks struct {
|
||||||
|
bun.BaseModel `bun:"table:public.thought_learning_links,alias:thought_learning_links"`
|
||||||
|
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
||||||
|
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
|
LearningID int64 `bun:"learning_id,type:bigint,notnull,unique:uidx_thought_learning_links_thought_id_learning_id," json:"learning_id"`
|
||||||
|
Relation sql_types.SqlString `bun:"relation,type:text,default:'source',notnull," json:"relation"`
|
||||||
|
ThoughtID int64 `bun:"thought_id,type:bigint,notnull,unique:uidx_thought_learning_links_thought_id_learning_id," json:"thought_id"`
|
||||||
|
RelLearningID *ModelPublicLearnings `bun:"rel:has-one,join:learning_id=id" json:"rellearningid,omitempty"` // Has one ModelPublicLearnings
|
||||||
|
RelThoughtID *ModelPublicThoughts `bun:"rel:has-one,join:thought_id=id" json:"relthoughtid,omitempty"` // Has one ModelPublicThoughts
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName returns the table name for ModelPublicThoughtLearningLinks
|
||||||
|
func (m ModelPublicThoughtLearningLinks) TableName() string {
|
||||||
|
return "public.thought_learning_links"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableNameOnly returns the table name without schema for ModelPublicThoughtLearningLinks
|
||||||
|
func (m ModelPublicThoughtLearningLinks) TableNameOnly() string {
|
||||||
|
return "thought_learning_links"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SchemaName returns the schema name for ModelPublicThoughtLearningLinks
|
||||||
|
func (m ModelPublicThoughtLearningLinks) SchemaName() string {
|
||||||
|
return "public"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetID returns the primary key value
|
||||||
|
func (m ModelPublicThoughtLearningLinks) GetID() int64 {
|
||||||
|
return m.ID.Int64()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDStr returns the primary key as a string
|
||||||
|
func (m ModelPublicThoughtLearningLinks) GetIDStr() string {
|
||||||
|
return m.ID.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetID sets the primary key value
|
||||||
|
func (m ModelPublicThoughtLearningLinks) SetID(newid int64) {
|
||||||
|
m.UpdateID(newid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateID updates the primary key value
|
||||||
|
func (m *ModelPublicThoughtLearningLinks) UpdateID(newid int64) {
|
||||||
|
m.ID.FromString(fmt.Sprintf("%d", newid))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDName returns the name of the primary key column
|
||||||
|
func (m ModelPublicThoughtLearningLinks) GetIDName() string {
|
||||||
|
return "id"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPrefix returns the table prefix
|
||||||
|
func (m ModelPublicThoughtLearningLinks) GetPrefix() string {
|
||||||
|
return "TLL"
|
||||||
|
}
|
||||||
@@ -3,16 +3,16 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicThoughtLinks struct {
|
type ModelPublicThoughtLinks struct {
|
||||||
bun.BaseModel `bun:"table:public.thought_links,alias:thought_links"`
|
bun.BaseModel `bun:"table:public.thought_links,alias:thought_links"`
|
||||||
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
||||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),nullzero," json:"created_at"`
|
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),nullzero," json:"created_at"`
|
||||||
FromID int64 `bun:"from_id,type:bigint,notnull," json:"from_id"`
|
FromID int64 `bun:"from_id,type:bigint,notnull," json:"from_id"`
|
||||||
Relation resolvespec_common.SqlString `bun:"relation,type:text,notnull," json:"relation"`
|
Relation sql_types.SqlString `bun:"relation,type:text,notnull," json:"relation"`
|
||||||
ToID int64 `bun:"to_id,type:bigint,notnull," json:"to_id"`
|
ToID int64 `bun:"to_id,type:bigint,notnull," json:"to_id"`
|
||||||
RelFromID *ModelPublicThoughts `bun:"rel:has-one,join:from_id=id" json:"relfromid,omitempty"` // Has one ModelPublicThoughts
|
RelFromID *ModelPublicThoughts `bun:"rel:has-one,join:from_id=id" json:"relfromid,omitempty"` // Has one ModelPublicThoughts
|
||||||
RelToID *ModelPublicThoughts `bun:"rel:has-one,join:to_id=id" json:"reltoid,omitempty"` // Has one ModelPublicThoughts
|
RelToID *ModelPublicThoughts `bun:"rel:has-one,join:to_id=id" json:"reltoid,omitempty"` // Has one ModelPublicThoughts
|
||||||
|
|||||||
@@ -3,23 +3,26 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicThoughts struct {
|
type ModelPublicThoughts struct {
|
||||||
bun.BaseModel `bun:"table:public.thoughts,alias:thoughts"`
|
bun.BaseModel `bun:"table:public.thoughts,alias:thoughts"`
|
||||||
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
||||||
ArchivedAt resolvespec_common.SqlTimeStamp `bun:"archived_at,type:timestamptz,nullzero," json:"archived_at"`
|
ArchivedAt sql_types.SqlTimeStamp `bun:"archived_at,type:timestamptz,nullzero," json:"archived_at"`
|
||||||
Content resolvespec_common.SqlString `bun:"content,type:text,notnull," json:"content"`
|
Content sql_types.SqlString `bun:"content,type:text,notnull," json:"content"`
|
||||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),nullzero," json:"created_at"`
|
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),nullzero," json:"created_at"`
|
||||||
GUID resolvespec_common.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
|
GUID sql_types.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
|
||||||
Metadata resolvespec_common.SqlJSONB `bun:"metadata,type:jsonb,default:{}::jsonb,nullzero," json:"metadata"`
|
Metadata sql_types.SqlJSONB `bun:"metadata,type:jsonb,default:{}::jsonb,nullzero," json:"metadata"`
|
||||||
ProjectID resolvespec_common.SqlInt64 `bun:"project_id,type:bigint,nullzero," json:"project_id"`
|
ProjectID sql_types.SqlInt64 `bun:"project_id,type:bigint,nullzero," json:"project_id"`
|
||||||
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),nullzero," json:"updated_at"`
|
TenantID sql_types.SqlString `bun:"tenant_id,type:text,nullzero," json:"tenant_id"`
|
||||||
|
UpdatedAt sql_types.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),nullzero," json:"updated_at"`
|
||||||
RelProjectID *ModelPublicProjects `bun:"rel:has-one,join:project_id=id" json:"relprojectid,omitempty"` // Has one ModelPublicProjects
|
RelProjectID *ModelPublicProjects `bun:"rel:has-one,join:project_id=id" json:"relprojectid,omitempty"` // Has one ModelPublicProjects
|
||||||
|
RelTenantID *ModelPublicTenants `bun:"rel:has-one,join:tenant_id=id" json:"reltenantid,omitempty"` // Has one ModelPublicTenants
|
||||||
RelFromIDPublicThoughtLinks []*ModelPublicThoughtLinks `bun:"rel:has-many,join:id=from_id" json:"relfromidpublicthoughtlinks,omitempty"` // Has many ModelPublicThoughtLinks
|
RelFromIDPublicThoughtLinks []*ModelPublicThoughtLinks `bun:"rel:has-many,join:id=from_id" json:"relfromidpublicthoughtlinks,omitempty"` // Has many ModelPublicThoughtLinks
|
||||||
RelToIDPublicThoughtLinks []*ModelPublicThoughtLinks `bun:"rel:has-many,join:id=to_id" json:"reltoidpublicthoughtlinks,omitempty"` // Has many ModelPublicThoughtLinks
|
RelToIDPublicThoughtLinks []*ModelPublicThoughtLinks `bun:"rel:has-many,join:id=to_id" json:"reltoidpublicthoughtlinks,omitempty"` // Has many ModelPublicThoughtLinks
|
||||||
|
RelThoughtIDPublicThoughtLearningLinks []*ModelPublicThoughtLearningLinks `bun:"rel:has-many,join:id=thought_id" json:"relthoughtidpublicthoughtlearninglinks,omitempty"` // Has many ModelPublicThoughtLearningLinks
|
||||||
RelThoughtIDPublicEmbeddings []*ModelPublicEmbeddings `bun:"rel:has-many,join:id=thought_id" json:"relthoughtidpublicembeddings,omitempty"` // Has many ModelPublicEmbeddings
|
RelThoughtIDPublicEmbeddings []*ModelPublicEmbeddings `bun:"rel:has-many,join:id=thought_id" json:"relthoughtidpublicembeddings,omitempty"` // Has many ModelPublicEmbeddings
|
||||||
RelThoughtIDPublicStoredFiles []*ModelPublicStoredFiles `bun:"rel:has-many,join:id=thought_id" json:"relthoughtidpublicstoredfiles,omitempty"` // Has many ModelPublicStoredFiles
|
RelThoughtIDPublicStoredFiles []*ModelPublicStoredFiles `bun:"rel:has-many,join:id=thought_id" json:"relthoughtidpublicstoredfiles,omitempty"` // Has many ModelPublicStoredFiles
|
||||||
RelRelatedThoughtIDPublicLearnings []*ModelPublicLearnings `bun:"rel:has-many,join:id=related_thought_id" json:"relrelatedthoughtidpubliclearnings,omitempty"` // Has many ModelPublicLearnings
|
RelRelatedThoughtIDPublicLearnings []*ModelPublicLearnings `bun:"rel:has-many,join:id=related_thought_id" json:"relrelatedthoughtidpubliclearnings,omitempty"` // Has many ModelPublicLearnings
|
||||||
|
|||||||
@@ -3,17 +3,17 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicToolAnnotations struct {
|
type ModelPublicToolAnnotations struct {
|
||||||
bun.BaseModel `bun:"table:public.tool_annotations,alias:tool_annotations"`
|
bun.BaseModel `bun:"table:public.tool_annotations,alias:tool_annotations"`
|
||||||
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
||||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
Notes resolvespec_common.SqlString `bun:"notes,type:text,default:'',notnull," json:"notes"`
|
Notes sql_types.SqlString `bun:"notes,type:text,default:'',notnull," json:"notes"`
|
||||||
ToolName resolvespec_common.SqlString `bun:"tool_name,type:text,notnull," json:"tool_name"`
|
ToolName sql_types.SqlString `bun:"tool_name,type:text,notnull," json:"tool_name"`
|
||||||
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
UpdatedAt sql_types.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName returns the table name for ModelPublicToolAnnotations
|
// TableName returns the table name for ModelPublicToolAnnotations
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ type ToolSet struct {
|
|||||||
Update *tools.UpdateTool
|
Update *tools.UpdateTool
|
||||||
Delete *tools.DeleteTool
|
Delete *tools.DeleteTool
|
||||||
Archive *tools.ArchiveTool
|
Archive *tools.ArchiveTool
|
||||||
|
DuplicateAudit *tools.DuplicateAuditTool
|
||||||
Projects *tools.ProjectsTool
|
Projects *tools.ProjectsTool
|
||||||
Context *tools.ContextTool
|
Context *tools.ContextTool
|
||||||
Recall *tools.RecallTool
|
Recall *tools.RecallTool
|
||||||
@@ -46,6 +47,7 @@ type ToolSet struct {
|
|||||||
Plans *tools.PlansTool
|
Plans *tools.PlansTool
|
||||||
ProjectPersonas *tools.ProjectPersonasTool
|
ProjectPersonas *tools.ProjectPersonasTool
|
||||||
WorldModel *tools.WorldModelTool
|
WorldModel *tools.WorldModelTool
|
||||||
|
ThoughtLearningLinks *tools.ThoughtLearningLinksTool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handlers groups the HTTP handlers produced for an MCP server instance.
|
// Handlers groups the HTTP handlers produced for an MCP server instance.
|
||||||
@@ -91,6 +93,7 @@ func NewHandlers(cfg config.MCPConfig, logger *slog.Logger, toolSet ToolSet, onS
|
|||||||
registerProjectTools,
|
registerProjectTools,
|
||||||
registerWorldModelTools,
|
registerWorldModelTools,
|
||||||
registerLearningTools,
|
registerLearningTools,
|
||||||
|
registerThoughtLearningLinkTools,
|
||||||
registerPlanTools,
|
registerPlanTools,
|
||||||
registerFileTools,
|
registerFileTools,
|
||||||
registerMaintenanceTools,
|
registerMaintenanceTools,
|
||||||
@@ -225,6 +228,12 @@ func registerThoughtTools(server *mcp.Server, logger *slog.Logger, toolSet ToolS
|
|||||||
}, toolSet.Archive.Handle); err != nil {
|
}, toolSet.Archive.Handle); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := addTool(server, logger, &mcp.Tool{
|
||||||
|
Name: "audit_duplicates",
|
||||||
|
Description: "Dry-run duplicate audit for projects, thoughts, and metadata normalization candidates; performs no writes.",
|
||||||
|
}, toolSet.DuplicateAudit.Handle); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
if err := addTool(server, logger, &mcp.Tool{
|
||||||
Name: "summarize_thoughts",
|
Name: "summarize_thoughts",
|
||||||
Description: "LLM summary of a filtered set of thoughts.",
|
Description: "LLM summary of a filtered set of thoughts.",
|
||||||
@@ -308,6 +317,34 @@ func registerLearningTools(server *mcp.Server, logger *slog.Logger, toolSet Tool
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func registerThoughtLearningLinkTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet) error {
|
||||||
|
if err := addTool(server, logger, &mcp.Tool{
|
||||||
|
Name: "link_thought_learning",
|
||||||
|
Description: "Create or update an explicit association between a raw thought and a curated learning.",
|
||||||
|
}, toolSet.ThoughtLearningLinks.Link); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := addTool(server, logger, &mcp.Tool{
|
||||||
|
Name: "unlink_thought_learning",
|
||||||
|
Description: "Remove an explicit association between a thought and a learning.",
|
||||||
|
}, toolSet.ThoughtLearningLinks.Unlink); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := addTool(server, logger, &mcp.Tool{
|
||||||
|
Name: "get_thought_learnings",
|
||||||
|
Description: "List curated learnings explicitly linked to a thought.",
|
||||||
|
}, toolSet.ThoughtLearningLinks.GetThoughtLearnings); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := addTool(server, logger, &mcp.Tool{
|
||||||
|
Name: "get_learning_thoughts",
|
||||||
|
Description: "List raw thoughts explicitly linked to a learning.",
|
||||||
|
}, toolSet.ThoughtLearningLinks.GetLearningThoughts); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func registerPlanTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet) error {
|
func registerPlanTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet) error {
|
||||||
if err := addTool(server, logger, &mcp.Tool{
|
if err := addTool(server, logger, &mcp.Tool{
|
||||||
Name: "create_plan",
|
Name: "create_plan",
|
||||||
@@ -734,6 +771,7 @@ func BuildToolCatalog() []tools.ToolEntry {
|
|||||||
{Name: "update_thought", Description: "Update thought content or merge metadata.", Category: "thoughts"},
|
{Name: "update_thought", Description: "Update thought content or merge metadata.", Category: "thoughts"},
|
||||||
{Name: "delete_thought", Description: "Hard-delete a thought by id.", Category: "thoughts"},
|
{Name: "delete_thought", Description: "Hard-delete a thought by id.", Category: "thoughts"},
|
||||||
{Name: "archive_thought", Description: "Archive a thought so it is hidden from default search and listing.", Category: "thoughts"},
|
{Name: "archive_thought", Description: "Archive a thought so it is hidden from default search and listing.", Category: "thoughts"},
|
||||||
|
{Name: "audit_duplicates", Description: "Dry-run duplicate audit for exact/normalized project names, thought content, and metadata value variants. Reports candidates and recommendations; performs no writes.", Category: "admin"},
|
||||||
{Name: "summarize_thoughts", Description: "Produce an LLM prose summary of a filtered or searched set of thoughts.", Category: "thoughts"},
|
{Name: "summarize_thoughts", Description: "Produce an LLM prose summary of a filtered or searched set of thoughts.", Category: "thoughts"},
|
||||||
{Name: "recall_context", Description: "Recall semantically relevant and recent context for prompt injection. Combines vector similarity with recency. Falls back to full-text search when no embeddings exist.", Category: "thoughts"},
|
{Name: "recall_context", Description: "Recall semantically relevant and recent context for prompt injection. Combines vector similarity with recency. Falls back to full-text search when no embeddings exist.", Category: "thoughts"},
|
||||||
{Name: "link_thoughts", Description: "Create a typed relationship between two thoughts.", Category: "thoughts"},
|
{Name: "link_thoughts", Description: "Create a typed relationship between two thoughts.", Category: "thoughts"},
|
||||||
@@ -755,6 +793,10 @@ func BuildToolCatalog() []tools.ToolEntry {
|
|||||||
{Name: "add_learning", Description: "Create a curated learning record distinct from raw thoughts.", Category: "projects"},
|
{Name: "add_learning", Description: "Create a curated learning record distinct from raw thoughts.", Category: "projects"},
|
||||||
{Name: "get_learning", Description: "Retrieve a structured learning by id.", Category: "projects"},
|
{Name: "get_learning", Description: "Retrieve a structured learning by id.", Category: "projects"},
|
||||||
{Name: "list_learnings", Description: "List structured learnings with optional project, category, area, status, priority, tag, and text filters.", Category: "projects"},
|
{Name: "list_learnings", Description: "List structured learnings with optional project, category, area, status, priority, tag, and text filters.", Category: "projects"},
|
||||||
|
{Name: "link_thought_learning", Description: "Create or update a lightweight explicit association between a raw thought and a curated learning.", Category: "projects"},
|
||||||
|
{Name: "unlink_thought_learning", Description: "Remove a lightweight explicit association between a thought and a learning.", Category: "projects"},
|
||||||
|
{Name: "get_thought_learnings", Description: "List curated learnings explicitly associated with a thought.", Category: "projects"},
|
||||||
|
{Name: "get_learning_thoughts", Description: "List raw source thoughts explicitly associated with a learning.", Category: "projects"},
|
||||||
|
|
||||||
// plans
|
// plans
|
||||||
{Name: "create_plan", Description: "Create a structured plan with status, priority, owner, due date, and optional project link.", Category: "plans"},
|
{Name: "create_plan", Description: "Create a structured plan with status, priority, owner, due date, and optional project link.", Category: "plans"},
|
||||||
|
|||||||
@@ -214,5 +214,6 @@ func streamableTestToolSet() ToolSet {
|
|||||||
Plans: new(tools.PlansTool),
|
Plans: new(tools.PlansTool),
|
||||||
ProjectPersonas: new(tools.ProjectPersonasTool),
|
ProjectPersonas: new(tools.ProjectPersonasTool),
|
||||||
WorldModel: new(tools.WorldModelTool),
|
WorldModel: new(tools.WorldModelTool),
|
||||||
|
ThoughtLearningLinks: new(tools.ThoughtLearningLinksTool),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ func Normalize(in thoughttypes.ThoughtMetadata, capture config.CaptureConfig) th
|
|||||||
Type: normalizeType(in.Type),
|
Type: normalizeType(in.Type),
|
||||||
Source: normalizeSource(in.Source),
|
Source: normalizeSource(in.Source),
|
||||||
Attachments: normalizeAttachments(in.Attachments),
|
Attachments: normalizeAttachments(in.Attachments),
|
||||||
|
Webhook: normalizeWebhook(in.Webhook),
|
||||||
MetadataStatus: normalizeMetadataStatus(in.MetadataStatus),
|
MetadataStatus: normalizeMetadataStatus(in.MetadataStatus),
|
||||||
MetadataUpdatedAt: strings.TrimSpace(in.MetadataUpdatedAt),
|
MetadataUpdatedAt: strings.TrimSpace(in.MetadataUpdatedAt),
|
||||||
MetadataLastAttemptedAt: strings.TrimSpace(in.MetadataLastAttemptedAt),
|
MetadataLastAttemptedAt: strings.TrimSpace(in.MetadataLastAttemptedAt),
|
||||||
@@ -201,10 +202,31 @@ func Merge(base, patch thoughttypes.ThoughtMetadata, capture config.CaptureConfi
|
|||||||
if len(patch.Attachments) > 0 {
|
if len(patch.Attachments) > 0 {
|
||||||
merged.Attachments = append(append([]thoughttypes.ThoughtAttachment{}, merged.Attachments...), patch.Attachments...)
|
merged.Attachments = append(append([]thoughttypes.ThoughtAttachment{}, merged.Attachments...), patch.Attachments...)
|
||||||
}
|
}
|
||||||
|
if patch.Webhook != nil {
|
||||||
|
merged.Webhook = patch.Webhook
|
||||||
|
}
|
||||||
|
|
||||||
return Normalize(merged, capture)
|
return Normalize(merged, capture)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeWebhook(value *thoughttypes.WebhookMetadata) *thoughttypes.WebhookMetadata {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := &thoughttypes.WebhookMetadata{
|
||||||
|
ReceivedAt: strings.TrimSpace(value.ReceivedAt),
|
||||||
|
IDempotencyKey: strings.TrimSpace(value.IDempotencyKey),
|
||||||
|
ExternalID: strings.TrimSpace(value.ExternalID),
|
||||||
|
}
|
||||||
|
if len(value.SourceMetadata) > 0 {
|
||||||
|
out.SourceMetadata = value.SourceMetadata
|
||||||
|
}
|
||||||
|
if out.ReceivedAt == "" && out.IDempotencyKey == "" && out.ExternalID == "" && len(out.SourceMetadata) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeAttachments(values []thoughttypes.ThoughtAttachment) []thoughttypes.ThoughtAttachment {
|
func normalizeAttachments(values []thoughttypes.ThoughtAttachment) []thoughttypes.ThoughtAttachment {
|
||||||
seen := make(map[string]struct{}, len(values))
|
seen := make(map[string]struct{}, len(values))
|
||||||
result := make([]thoughttypes.ThoughtAttachment, 0, len(values))
|
result := make([]thoughttypes.ThoughtAttachment, 0, len(values))
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ import (
|
|||||||
|
|
||||||
func (db *DB) InsertStoredFile(ctx context.Context, file thoughttypes.StoredFile) (thoughttypes.StoredFile, error) {
|
func (db *DB) InsertStoredFile(ctx context.Context, file thoughttypes.StoredFile) (thoughttypes.StoredFile, error) {
|
||||||
row := db.pool.QueryRow(ctx, `
|
row := db.pool.QueryRow(ctx, `
|
||||||
insert into stored_files (thought_id, project_id, name, media_type, kind, encoding, size_bytes, sha256, content)
|
insert into stored_files (thought_id, project_id, tenant_id, name, media_type, kind, encoding, size_bytes, sha256, content)
|
||||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
returning id, guid, thought_id, project_id, name, media_type, kind, encoding, size_bytes, sha256, created_at, updated_at
|
returning id, guid, thought_id, project_id, name, media_type, kind, encoding, size_bytes, sha256, created_at, updated_at
|
||||||
`, file.ThoughtID, file.ProjectID, file.Name, file.MediaType, file.Kind, file.Encoding, file.SizeBytes, file.SHA256, file.Content)
|
`, file.ThoughtID, file.ProjectID, tenantKeyPtr(ctx), file.Name, file.MediaType, file.Kind, file.Encoding, file.SizeBytes, file.SHA256, file.Content)
|
||||||
|
|
||||||
var model generatedmodels.ModelPublicStoredFiles
|
var model generatedmodels.ModelPublicStoredFiles
|
||||||
if err := row.Scan(
|
if err := row.Scan(
|
||||||
@@ -42,11 +42,11 @@ func (db *DB) InsertStoredFile(ctx context.Context, file thoughttypes.StoredFile
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) GetStoredFile(ctx context.Context, id uuid.UUID) (thoughttypes.StoredFile, error) {
|
func (db *DB) GetStoredFile(ctx context.Context, id uuid.UUID) (thoughttypes.StoredFile, error) {
|
||||||
|
args := []any{id}
|
||||||
row := db.pool.QueryRow(ctx, `
|
row := db.pool.QueryRow(ctx, `
|
||||||
select id, guid, thought_id, project_id, name, media_type, kind, encoding, size_bytes, sha256, content, created_at, updated_at
|
select id, guid, thought_id, project_id, name, media_type, kind, encoding, size_bytes, sha256, content, created_at, updated_at
|
||||||
from stored_files
|
from stored_files
|
||||||
where guid = $1
|
where guid = $1`+tenantSQL(ctx, &args, "tenant_id"), args...)
|
||||||
`, id)
|
|
||||||
|
|
||||||
var model generatedmodels.ModelPublicStoredFiles
|
var model generatedmodels.ModelPublicStoredFiles
|
||||||
if err := row.Scan(
|
if err := row.Scan(
|
||||||
@@ -77,6 +77,7 @@ func (db *DB) ListStoredFiles(ctx context.Context, filter thoughttypes.StoredFil
|
|||||||
args := make([]any, 0, 4)
|
args := make([]any, 0, 4)
|
||||||
conditions := make([]string, 0, 3)
|
conditions := make([]string, 0, 3)
|
||||||
|
|
||||||
|
addTenantCondition(ctx, &args, &conditions, "tenant_id")
|
||||||
if filter.ThoughtID != nil {
|
if filter.ThoughtID != nil {
|
||||||
args = append(args, *filter.ThoughtID)
|
args = append(args, *filter.ThoughtID)
|
||||||
conditions = append(conditions, fmt.Sprintf("thought_id = $%d", len(args)))
|
conditions = append(conditions, fmt.Sprintf("thought_id = $%d", len(args)))
|
||||||
|
|||||||
@@ -475,4 +475,3 @@ func canonicalPlanPair(a, b int64) (int64, int64) {
|
|||||||
}
|
}
|
||||||
return b, a
|
return b, a
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ import (
|
|||||||
|
|
||||||
func (db *DB) CreateProject(ctx context.Context, name, description string) (thoughttypes.Project, error) {
|
func (db *DB) CreateProject(ctx context.Context, name, description string) (thoughttypes.Project, error) {
|
||||||
row := db.pool.QueryRow(ctx, `
|
row := db.pool.QueryRow(ctx, `
|
||||||
insert into projects (name, description)
|
insert into projects (name, description, tenant_id)
|
||||||
values ($1, $2)
|
values ($1, $2, $3)
|
||||||
returning id, guid, name, description, created_at, last_active_at
|
returning id, guid, name, description, created_at, last_active_at
|
||||||
`, name, description)
|
`, name, description, tenantKeyPtr(ctx))
|
||||||
|
|
||||||
var model generatedmodels.ModelPublicProjects
|
var model generatedmodels.ModelPublicProjects
|
||||||
if err := row.Scan(&model.ID, &model.GUID, &model.Name, &model.Description, &model.CreatedAt, &model.LastActiveAt); err != nil {
|
if err := row.Scan(&model.ID, &model.GUID, &model.Name, &model.Description, &model.CreatedAt, &model.LastActiveAt); err != nil {
|
||||||
@@ -45,20 +45,20 @@ func (db *DB) GetProject(ctx context.Context, nameOrID string) (thoughttypes.Pro
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) getProjectByGUID(ctx context.Context, id uuid.UUID) (thoughttypes.Project, error) {
|
func (db *DB) getProjectByGUID(ctx context.Context, id uuid.UUID) (thoughttypes.Project, error) {
|
||||||
|
args := []any{id}
|
||||||
row := db.pool.QueryRow(ctx, `
|
row := db.pool.QueryRow(ctx, `
|
||||||
select id, guid, name, description, created_at, last_active_at
|
select id, guid, name, description, created_at, last_active_at
|
||||||
from projects
|
from projects
|
||||||
where guid = $1
|
where guid = $1`+tenantSQL(ctx, &args, "tenant_id"), args...)
|
||||||
`, id)
|
|
||||||
return scanProject(row)
|
return scanProject(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) getProjectByName(ctx context.Context, name string) (thoughttypes.Project, error) {
|
func (db *DB) getProjectByName(ctx context.Context, name string) (thoughttypes.Project, error) {
|
||||||
|
args := []any{name}
|
||||||
row := db.pool.QueryRow(ctx, `
|
row := db.pool.QueryRow(ctx, `
|
||||||
select id, guid, name, description, created_at, last_active_at
|
select id, guid, name, description, created_at, last_active_at
|
||||||
from projects
|
from projects
|
||||||
where name = $1
|
where name = $1`+tenantSQL(ctx, &args, "tenant_id"), args...)
|
||||||
`, name)
|
|
||||||
return scanProject(row)
|
return scanProject(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,13 +74,20 @@ func scanProject(row pgx.Row) (thoughttypes.Project, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) ListProjects(ctx context.Context) ([]thoughttypes.ProjectSummary, error) {
|
func (db *DB) ListProjects(ctx context.Context) ([]thoughttypes.ProjectSummary, error) {
|
||||||
|
args := []any{}
|
||||||
|
where := ""
|
||||||
|
if key, ok := tenantKey(ctx); ok {
|
||||||
|
args = append(args, key)
|
||||||
|
where = "where p.tenant_id = $1"
|
||||||
|
}
|
||||||
rows, err := db.pool.Query(ctx, `
|
rows, err := db.pool.Query(ctx, `
|
||||||
select p.id, p.guid, p.name, p.description, p.created_at, p.last_active_at, count(t.id) as thought_count
|
select p.id, p.guid, p.name, p.description, p.created_at, p.last_active_at, count(t.id) as thought_count
|
||||||
from projects p
|
from projects p
|
||||||
left join thoughts t on t.project_id = p.id and t.archived_at is null
|
left join thoughts t on t.project_id = p.id and t.archived_at is null
|
||||||
|
`+where+`
|
||||||
group by p.id, p.guid, p.name, p.description, p.created_at, p.last_active_at
|
group by p.id, p.guid, p.name, p.description, p.created_at, p.last_active_at
|
||||||
order by p.last_active_at desc, p.created_at desc
|
order by p.last_active_at desc, p.created_at desc
|
||||||
`)
|
`, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list projects: %w", err)
|
return nil, fmt.Errorf("list projects: %w", err)
|
||||||
}
|
}
|
||||||
@@ -105,7 +112,8 @@ func (db *DB) ListProjects(ctx context.Context) ([]thoughttypes.ProjectSummary,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) TouchProject(ctx context.Context, id int64) error {
|
func (db *DB) TouchProject(ctx context.Context, id int64) error {
|
||||||
tag, err := db.pool.Exec(ctx, `update projects set last_active_at = now() where id = $1`, id)
|
args := []any{id}
|
||||||
|
tag, err := db.pool.Exec(ctx, `update projects set last_active_at = now() where id = $1`+tenantSQL(ctx, &args, "tenant_id"), args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("touch project: %w", err)
|
return fmt.Errorf("touch project: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-15
@@ -28,10 +28,10 @@ func (db *DB) AddSkill(ctx context.Context, skill ext.AgentSkill) (ext.AgentSkil
|
|||||||
skill.DomainTags = []string{}
|
skill.DomainTags = []string{}
|
||||||
}
|
}
|
||||||
row := db.pool.QueryRow(ctx, `
|
row := db.pool.QueryRow(ctx, `
|
||||||
insert into agent_skills (name, description, content, tags, language_tags, library_tags, framework_tags, domain_tags)
|
insert into agent_skills (name, description, content, tenant_id, tags, language_tags, library_tags, framework_tags, domain_tags)
|
||||||
values ($1, $2, $3, $4, $5, $6, $7, $8)
|
values ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
returning id, guid, created_at, updated_at
|
returning id, guid, created_at, updated_at
|
||||||
`, skill.Name, skill.Description, skill.Content, skill.Tags,
|
`, skill.Name, skill.Description, skill.Content, tenantKeyPtr(ctx), skill.Tags,
|
||||||
skill.LanguageTags, skill.LibraryTags, skill.FrameworkTags, skill.DomainTags)
|
skill.LanguageTags, skill.LibraryTags, skill.FrameworkTags, skill.DomainTags)
|
||||||
|
|
||||||
created := skill
|
created := skill
|
||||||
@@ -47,7 +47,8 @@ func (db *DB) AddSkill(ctx context.Context, skill ext.AgentSkill) (ext.AgentSkil
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) RemoveSkill(ctx context.Context, id int64) error {
|
func (db *DB) RemoveSkill(ctx context.Context, id int64) error {
|
||||||
tag, err := db.pool.Exec(ctx, `delete from agent_skills where id = $1`, id)
|
args := []any{id}
|
||||||
|
tag, err := db.pool.Exec(ctx, `delete from agent_skills where id = $1`+tenantSQL(ctx, &args, "tenant_id"), args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("delete agent skill: %w", err)
|
return fmt.Errorf("delete agent skill: %w", err)
|
||||||
}
|
}
|
||||||
@@ -60,9 +61,14 @@ func (db *DB) RemoveSkill(ctx context.Context, id int64) error {
|
|||||||
func (db *DB) ListSkills(ctx context.Context, tag string) ([]ext.AgentSkill, error) {
|
func (db *DB) ListSkills(ctx context.Context, tag string) ([]ext.AgentSkill, error) {
|
||||||
q := `select id, name, description, content, tags::text[], language_tags::text[], library_tags::text[], framework_tags::text[], domain_tags::text[], created_at, updated_at from agent_skills`
|
q := `select id, name, description, content, tags::text[], language_tags::text[], library_tags::text[], framework_tags::text[], domain_tags::text[], created_at, updated_at from agent_skills`
|
||||||
args := []any{}
|
args := []any{}
|
||||||
|
conditions := []string{}
|
||||||
if t := strings.TrimSpace(tag); t != "" {
|
if t := strings.TrimSpace(tag); t != "" {
|
||||||
args = append(args, t)
|
args = append(args, t)
|
||||||
q += fmt.Sprintf(" where $%d = any(tags) or $%d = any(language_tags) or $%d = any(library_tags) or $%d = any(framework_tags) or $%d = any(domain_tags)", len(args), len(args), len(args), len(args), len(args))
|
conditions = append(conditions, fmt.Sprintf("($%d = any(tags) or $%d = any(language_tags) or $%d = any(library_tags) or $%d = any(framework_tags) or $%d = any(domain_tags))", len(args), len(args), len(args), len(args), len(args)))
|
||||||
|
}
|
||||||
|
addTenantCondition(ctx, &args, &conditions, "tenant_id")
|
||||||
|
if len(conditions) > 0 {
|
||||||
|
q += " where " + strings.Join(conditions, " and ")
|
||||||
}
|
}
|
||||||
q += " order by name"
|
q += " order by name"
|
||||||
|
|
||||||
@@ -135,7 +141,8 @@ func normalizeSkillSlices(skill *ext.AgentSkill) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) GetSkill(ctx context.Context, id int64) (ext.AgentSkill, error) {
|
func (db *DB) GetSkill(ctx context.Context, id int64) (ext.AgentSkill, error) {
|
||||||
row := db.pool.QueryRow(ctx, `select `+skillSelectCols+` from agent_skills where id = $1`, id)
|
args := []any{id}
|
||||||
|
row := db.pool.QueryRow(ctx, `select `+skillSelectCols+` from agent_skills where id = $1`+tenantSQL(ctx, &args, "tenant_id"), args...)
|
||||||
s, err := scanSkill(row)
|
s, err := scanSkill(row)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ext.AgentSkill{}, fmt.Errorf("get agent skill: %w", err)
|
return ext.AgentSkill{}, fmt.Errorf("get agent skill: %w", err)
|
||||||
@@ -144,7 +151,8 @@ func (db *DB) GetSkill(ctx context.Context, id int64) (ext.AgentSkill, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) GetSkillByName(ctx context.Context, name string) (ext.AgentSkill, error) {
|
func (db *DB) GetSkillByName(ctx context.Context, name string) (ext.AgentSkill, error) {
|
||||||
row := db.pool.QueryRow(ctx, `select `+skillSelectCols+` from agent_skills where name = $1`, name)
|
args := []any{name}
|
||||||
|
row := db.pool.QueryRow(ctx, `select `+skillSelectCols+` from agent_skills where name = $1`+tenantSQL(ctx, &args, "tenant_id"), args...)
|
||||||
s, err := scanSkill(row)
|
s, err := scanSkill(row)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ext.AgentSkill{}, fmt.Errorf("get agent skill by name: %w", err)
|
return ext.AgentSkill{}, fmt.Errorf("get agent skill by name: %w", err)
|
||||||
@@ -153,10 +161,10 @@ func (db *DB) GetSkillByName(ctx context.Context, name string) (ext.AgentSkill,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) GetGuardrailByName(ctx context.Context, name string) (ext.AgentGuardrail, error) {
|
func (db *DB) GetGuardrailByName(ctx context.Context, name string) (ext.AgentGuardrail, error) {
|
||||||
|
args := []any{name}
|
||||||
row := db.pool.QueryRow(ctx, `
|
row := db.pool.QueryRow(ctx, `
|
||||||
select id, name, description, content, severity, tags::text[], created_at, updated_at
|
select id, name, description, content, severity, tags::text[], created_at, updated_at
|
||||||
from agent_guardrails where name = $1
|
from agent_guardrails where name = $1`+tenantSQL(ctx, &args, "tenant_id"), args...)
|
||||||
`, name)
|
|
||||||
|
|
||||||
var model generatedmodels.ModelPublicAgentGuardrails
|
var model generatedmodels.ModelPublicAgentGuardrails
|
||||||
var tags []string
|
var tags []string
|
||||||
@@ -189,10 +197,10 @@ func (db *DB) AddGuardrail(ctx context.Context, g ext.AgentGuardrail) (ext.Agent
|
|||||||
g.Severity = "medium"
|
g.Severity = "medium"
|
||||||
}
|
}
|
||||||
row := db.pool.QueryRow(ctx, `
|
row := db.pool.QueryRow(ctx, `
|
||||||
insert into agent_guardrails (name, description, content, severity, tags)
|
insert into agent_guardrails (name, description, content, severity, tenant_id, tags)
|
||||||
values ($1, $2, $3, $4, $5)
|
values ($1, $2, $3, $4, $5, $6)
|
||||||
returning id, guid, created_at, updated_at
|
returning id, guid, created_at, updated_at
|
||||||
`, g.Name, g.Description, g.Content, g.Severity, g.Tags)
|
`, g.Name, g.Description, g.Content, g.Severity, tenantKeyPtr(ctx), g.Tags)
|
||||||
|
|
||||||
created := g
|
created := g
|
||||||
var model generatedmodels.ModelPublicAgentGuardrails
|
var model generatedmodels.ModelPublicAgentGuardrails
|
||||||
@@ -207,7 +215,8 @@ func (db *DB) AddGuardrail(ctx context.Context, g ext.AgentGuardrail) (ext.Agent
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) RemoveGuardrail(ctx context.Context, id int64) error {
|
func (db *DB) RemoveGuardrail(ctx context.Context, id int64) error {
|
||||||
tag, err := db.pool.Exec(ctx, `delete from agent_guardrails where id = $1`, id)
|
args := []any{id}
|
||||||
|
tag, err := db.pool.Exec(ctx, `delete from agent_guardrails where id = $1`+tenantSQL(ctx, &args, "tenant_id"), args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("delete agent guardrail: %w", err)
|
return fmt.Errorf("delete agent guardrail: %w", err)
|
||||||
}
|
}
|
||||||
@@ -229,6 +238,7 @@ func (db *DB) ListGuardrails(ctx context.Context, tag, severity string) ([]ext.A
|
|||||||
args = append(args, s)
|
args = append(args, s)
|
||||||
conditions = append(conditions, fmt.Sprintf("severity = $%d", len(args)))
|
conditions = append(conditions, fmt.Sprintf("severity = $%d", len(args)))
|
||||||
}
|
}
|
||||||
|
addTenantCondition(ctx, &args, &conditions, "tenant_id")
|
||||||
|
|
||||||
q := `select id, name, description, content, severity, tags::text[], created_at, updated_at from agent_guardrails`
|
q := `select id, name, description, content, severity, tags::text[], created_at, updated_at from agent_guardrails`
|
||||||
if len(conditions) > 0 {
|
if len(conditions) > 0 {
|
||||||
@@ -268,10 +278,10 @@ func (db *DB) ListGuardrails(ctx context.Context, tag, severity string) ([]ext.A
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) GetGuardrail(ctx context.Context, id int64) (ext.AgentGuardrail, error) {
|
func (db *DB) GetGuardrail(ctx context.Context, id int64) (ext.AgentGuardrail, error) {
|
||||||
|
args := []any{id}
|
||||||
row := db.pool.QueryRow(ctx, `
|
row := db.pool.QueryRow(ctx, `
|
||||||
select id, name, description, content, severity, tags::text[], created_at, updated_at
|
select id, name, description, content, severity, tags::text[], created_at, updated_at
|
||||||
from agent_guardrails where id = $1
|
from agent_guardrails where id = $1`+tenantSQL(ctx, &args, "tenant_id"), args...)
|
||||||
`, id)
|
|
||||||
|
|
||||||
var model generatedmodels.ModelPublicAgentGuardrails
|
var model generatedmodels.ModelPublicAgentGuardrails
|
||||||
var tags []string
|
var tags []string
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/tenancy"
|
||||||
|
)
|
||||||
|
|
||||||
|
func tenantKeyPtr(ctx context.Context) *string {
|
||||||
|
if key, ok := tenancy.KeyFromContext(ctx); ok {
|
||||||
|
return &key
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func tenantKey(ctx context.Context) (string, bool) {
|
||||||
|
return tenancy.KeyFromContext(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func addTenantCondition(ctx context.Context, args *[]any, conditions *[]string, column string) {
|
||||||
|
if key, ok := tenancy.KeyFromContext(ctx); ok {
|
||||||
|
*args = append(*args, key)
|
||||||
|
*conditions = append(*conditions, fmt.Sprintf("%s = $%d", column, len(*args)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tenantSQL(ctx context.Context, args *[]any, column string) string {
|
||||||
|
if key, ok := tenancy.KeyFromContext(ctx); ok {
|
||||||
|
*args = append(*args, key)
|
||||||
|
return fmt.Sprintf(" and %s = $%d", column, len(*args))
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/generatedmodels"
|
||||||
|
thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LinkThoughtLearning creates or updates an association between a thought (by GUID) and a learning (by numeric ID).
|
||||||
|
// If the pair already exists, the relation label is updated.
|
||||||
|
func (db *DB) LinkThoughtLearning(ctx context.Context, thoughtGUID uuid.UUID, learningID int64, relation string) error {
|
||||||
|
_, err := db.pool.Exec(ctx, `
|
||||||
|
insert into thought_learning_links (thought_id, learning_id, relation)
|
||||||
|
select t.id, $2, $3
|
||||||
|
from thoughts t
|
||||||
|
where t.guid = $1
|
||||||
|
on conflict (thought_id, learning_id) do update set relation = excluded.relation
|
||||||
|
`, thoughtGUID, learningID, relation)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("link thought learning: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnlinkThoughtLearning removes the association between a thought (by GUID) and a learning (by numeric ID).
|
||||||
|
func (db *DB) UnlinkThoughtLearning(ctx context.Context, thoughtGUID uuid.UUID, learningID int64) error {
|
||||||
|
_, err := db.pool.Exec(ctx, `
|
||||||
|
delete from thought_learning_links
|
||||||
|
where thought_id = (select id from thoughts where guid = $1)
|
||||||
|
and learning_id = $2
|
||||||
|
`, thoughtGUID, learningID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unlink thought learning: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLinkedLearnings returns all learnings explicitly associated with a thought (by GUID).
|
||||||
|
func (db *DB) GetLinkedLearnings(ctx context.Context, thoughtGUID uuid.UUID) ([]thoughttypes.LinkedLearning, error) {
|
||||||
|
rows, err := db.pool.Query(ctx, `
|
||||||
|
select l.id, l.guid, l.summary, l.details, l.category, l.area, l.status, l.priority, l.confidence,
|
||||||
|
l.action_required, l.source_type, l.source_ref, l.project_id, l.related_thought_id,
|
||||||
|
l.related_skill_id, l.reviewed_by, l.reviewed_at, l.duplicate_of_learning_id,
|
||||||
|
l.supersedes_learning_id, l.tags::text[], l.created_at, l.updated_at,
|
||||||
|
tll.relation, tll.created_at as linked_at
|
||||||
|
from thought_learning_links tll
|
||||||
|
join learnings l on l.id = tll.learning_id
|
||||||
|
where tll.thought_id = (select id from thoughts where guid = $1)
|
||||||
|
order by tll.created_at desc
|
||||||
|
`, thoughtGUID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query linked learnings: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
items := make([]thoughttypes.LinkedLearning, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var tags []string
|
||||||
|
var linked thoughttypes.LinkedLearning
|
||||||
|
var m generatedmodels.ModelPublicLearnings
|
||||||
|
|
||||||
|
if err := rows.Scan(
|
||||||
|
&m.ID,
|
||||||
|
&m.GUID,
|
||||||
|
&m.Summary,
|
||||||
|
&m.Details,
|
||||||
|
&m.Category,
|
||||||
|
&m.Area,
|
||||||
|
&m.Status,
|
||||||
|
&m.Priority,
|
||||||
|
&m.Confidence,
|
||||||
|
&m.ActionRequired,
|
||||||
|
&m.SourceType,
|
||||||
|
&m.SourceRef,
|
||||||
|
&m.ProjectID,
|
||||||
|
&m.RelatedThoughtID,
|
||||||
|
&m.RelatedSkillID,
|
||||||
|
&m.ReviewedBy,
|
||||||
|
&m.ReviewedAt,
|
||||||
|
&m.DuplicateOfLearningID,
|
||||||
|
&m.SupersedesLearningID,
|
||||||
|
&tags,
|
||||||
|
&m.CreatedAt,
|
||||||
|
&m.UpdatedAt,
|
||||||
|
&linked.Relation,
|
||||||
|
&linked.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan linked learning: %w", err)
|
||||||
|
}
|
||||||
|
linked.Learning = learningFromModel(m, tags)
|
||||||
|
items = append(items, linked)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("iterate linked learnings: %w", err)
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLinkedThoughtsForLearning returns all thoughts explicitly associated with a learning (by numeric ID).
|
||||||
|
func (db *DB) GetLinkedThoughtsForLearning(ctx context.Context, learningID int64) ([]thoughttypes.LinkedLearningThought, error) {
|
||||||
|
rows, err := db.pool.Query(ctx, `
|
||||||
|
select t.id, t.guid, t.content, t.metadata, t.project_id, t.archived_at, t.created_at, t.updated_at,
|
||||||
|
tll.relation, tll.created_at as linked_at
|
||||||
|
from thought_learning_links tll
|
||||||
|
join thoughts t on t.id = tll.thought_id
|
||||||
|
where tll.learning_id = $1
|
||||||
|
order by tll.created_at desc
|
||||||
|
`, learningID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query linked thoughts for learning: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
items := make([]thoughttypes.LinkedLearningThought, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var linked thoughttypes.LinkedLearningThought
|
||||||
|
var m generatedmodels.ModelPublicThoughts
|
||||||
|
|
||||||
|
if err := rows.Scan(
|
||||||
|
&m.ID,
|
||||||
|
&m.GUID,
|
||||||
|
&m.Content,
|
||||||
|
&m.Metadata,
|
||||||
|
&m.ProjectID,
|
||||||
|
&m.ArchivedAt,
|
||||||
|
&m.CreatedAt,
|
||||||
|
&m.UpdatedAt,
|
||||||
|
&linked.Relation,
|
||||||
|
&linked.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan linked thought for learning: %w", err)
|
||||||
|
}
|
||||||
|
thought, err := thoughtFromModel(m)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("map linked thought for learning: %w", err)
|
||||||
|
}
|
||||||
|
linked.Thought = thought
|
||||||
|
items = append(items, linked)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("iterate linked thoughts for learning: %w", err)
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
+42
-15
@@ -31,10 +31,10 @@ func (db *DB) InsertThought(ctx context.Context, thought thoughttypes.Thought, e
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
row := tx.QueryRow(ctx, `
|
row := tx.QueryRow(ctx, `
|
||||||
insert into thoughts (content, metadata, project_id)
|
insert into thoughts (content, metadata, project_id, tenant_id)
|
||||||
values ($1, $2::jsonb, $3)
|
values ($1, $2::jsonb, $3, $4)
|
||||||
returning id, guid, created_at, updated_at
|
returning id, guid, created_at, updated_at
|
||||||
`, thought.Content, metadata, thought.ProjectID)
|
`, thought.Content, metadata, thought.ProjectID, tenantKeyPtr(ctx))
|
||||||
|
|
||||||
created := thought
|
created := thought
|
||||||
created.Embedding = nil
|
created.Embedding = nil
|
||||||
@@ -68,6 +68,22 @@ func (db *DB) InsertThought(ctx context.Context, thought thoughttypes.Thought, e
|
|||||||
return created, nil
|
return created, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *DB) GetThoughtByWebhookIDempotencyKey(ctx context.Context, key string) (thoughttypes.Thought, error) {
|
||||||
|
row := db.pool.QueryRow(ctx, `
|
||||||
|
select id, guid, content, metadata, project_id, archived_at, created_at, updated_at
|
||||||
|
from thoughts
|
||||||
|
where metadata->'webhook'->>'idempotency_key' = $1
|
||||||
|
order by created_at desc
|
||||||
|
limit 1
|
||||||
|
`, strings.TrimSpace(key))
|
||||||
|
|
||||||
|
var model generatedmodels.ModelPublicThoughts
|
||||||
|
if err := row.Scan(&model.ID, &model.GUID, &model.Content, &model.Metadata, &model.ProjectID, &model.ArchivedAt, &model.CreatedAt, &model.UpdatedAt); err != nil {
|
||||||
|
return thoughttypes.Thought{}, err
|
||||||
|
}
|
||||||
|
return thoughtFromModel(model)
|
||||||
|
}
|
||||||
|
|
||||||
func (db *DB) SearchThoughts(ctx context.Context, embedding []float32, embeddingModel string, threshold float64, limit int, filter map[string]any) ([]thoughttypes.SearchResult, error) {
|
func (db *DB) SearchThoughts(ctx context.Context, embedding []float32, embeddingModel string, threshold float64, limit int, filter map[string]any) ([]thoughttypes.SearchResult, error) {
|
||||||
filterJSON, err := json.Marshal(filter)
|
filterJSON, err := json.Marshal(filter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -107,6 +123,7 @@ func (db *DB) ListThoughts(ctx context.Context, filter thoughttypes.ListFilter)
|
|||||||
args := make([]any, 0, 6)
|
args := make([]any, 0, 6)
|
||||||
conditions := []string{}
|
conditions := []string{}
|
||||||
|
|
||||||
|
addTenantCondition(ctx, &args, &conditions, "tenant_id")
|
||||||
if !filter.IncludeArchived {
|
if !filter.IncludeArchived {
|
||||||
conditions = append(conditions, "archived_at is null")
|
conditions = append(conditions, "archived_at is null")
|
||||||
}
|
}
|
||||||
@@ -170,11 +187,14 @@ func (db *DB) ListThoughts(ctx context.Context, filter thoughttypes.ListFilter)
|
|||||||
|
|
||||||
func (db *DB) Stats(ctx context.Context) (thoughttypes.ThoughtStats, error) {
|
func (db *DB) Stats(ctx context.Context) (thoughttypes.ThoughtStats, error) {
|
||||||
var total int
|
var total int
|
||||||
if err := db.pool.QueryRow(ctx, `select count(*) from thoughts where archived_at is null`).Scan(&total); err != nil {
|
statsArgs := []any{}
|
||||||
|
statsConditions := []string{"archived_at is null"}
|
||||||
|
addTenantCondition(ctx, &statsArgs, &statsConditions, "tenant_id")
|
||||||
|
if err := db.pool.QueryRow(ctx, `select count(*) from thoughts where `+strings.Join(statsConditions, " and "), statsArgs...).Scan(&total); err != nil {
|
||||||
return thoughttypes.ThoughtStats{}, fmt.Errorf("count thoughts: %w", err)
|
return thoughttypes.ThoughtStats{}, fmt.Errorf("count thoughts: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := db.pool.Query(ctx, `select metadata from thoughts where archived_at is null`)
|
rows, err := db.pool.Query(ctx, `select metadata from thoughts where `+strings.Join(statsConditions, " and "), statsArgs...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return thoughttypes.ThoughtStats{}, fmt.Errorf("query stats metadata: %w", err)
|
return thoughttypes.ThoughtStats{}, fmt.Errorf("query stats metadata: %w", err)
|
||||||
}
|
}
|
||||||
@@ -217,11 +237,11 @@ func (db *DB) Stats(ctx context.Context) (thoughttypes.ThoughtStats, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) GetThought(ctx context.Context, id uuid.UUID) (thoughttypes.Thought, error) {
|
func (db *DB) GetThought(ctx context.Context, id uuid.UUID) (thoughttypes.Thought, error) {
|
||||||
|
args := []any{id}
|
||||||
row := db.pool.QueryRow(ctx, `
|
row := db.pool.QueryRow(ctx, `
|
||||||
select id, guid, content, metadata, project_id, archived_at, created_at, updated_at
|
select id, guid, content, metadata, project_id, archived_at, created_at, updated_at
|
||||||
from thoughts
|
from thoughts
|
||||||
where guid = $1
|
where guid = $1`+tenantSQL(ctx, &args, "tenant_id"), args...)
|
||||||
`, id)
|
|
||||||
|
|
||||||
var model generatedmodels.ModelPublicThoughts
|
var model generatedmodels.ModelPublicThoughts
|
||||||
if err := row.Scan(&model.ID, &model.GUID, &model.Content, &model.Metadata, &model.ProjectID, &model.ArchivedAt, &model.CreatedAt, &model.UpdatedAt); err != nil {
|
if err := row.Scan(&model.ID, &model.GUID, &model.Content, &model.Metadata, &model.ProjectID, &model.ArchivedAt, &model.CreatedAt, &model.UpdatedAt); err != nil {
|
||||||
@@ -240,11 +260,11 @@ func (db *DB) GetThought(ctx context.Context, id uuid.UUID) (thoughttypes.Though
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) GetThoughtByID(ctx context.Context, id int64) (thoughttypes.Thought, error) {
|
func (db *DB) GetThoughtByID(ctx context.Context, id int64) (thoughttypes.Thought, error) {
|
||||||
|
args := []any{id}
|
||||||
row := db.pool.QueryRow(ctx, `
|
row := db.pool.QueryRow(ctx, `
|
||||||
select id, guid, content, metadata, project_id, archived_at, created_at, updated_at
|
select id, guid, content, metadata, project_id, archived_at, created_at, updated_at
|
||||||
from thoughts
|
from thoughts
|
||||||
where id = $1
|
where id = $1`+tenantSQL(ctx, &args, "tenant_id"), args...)
|
||||||
`, id)
|
|
||||||
|
|
||||||
var model generatedmodels.ModelPublicThoughts
|
var model generatedmodels.ModelPublicThoughts
|
||||||
if err := row.Scan(&model.ID, &model.GUID, &model.Content, &model.Metadata, &model.ProjectID, &model.ArchivedAt, &model.CreatedAt, &model.UpdatedAt); err != nil {
|
if err := row.Scan(&model.ID, &model.GUID, &model.Content, &model.Metadata, &model.ProjectID, &model.ArchivedAt, &model.CreatedAt, &model.UpdatedAt); err != nil {
|
||||||
@@ -276,14 +296,14 @@ func (db *DB) UpdateThought(ctx context.Context, id uuid.UUID, content string, e
|
|||||||
_ = tx.Rollback(ctx)
|
_ = tx.Rollback(ctx)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
args := []any{id, content, metadataBytes, projectID}
|
||||||
tag, err := tx.Exec(ctx, `
|
tag, err := tx.Exec(ctx, `
|
||||||
update thoughts
|
update thoughts
|
||||||
set content = $2,
|
set content = $2,
|
||||||
metadata = $3::jsonb,
|
metadata = $3::jsonb,
|
||||||
project_id = $4,
|
project_id = $4,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
where guid = $1
|
where guid = $1`+tenantSQL(ctx, &args, "tenant_id"), args...)
|
||||||
`, id, content, metadataBytes, projectID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return thoughttypes.Thought{}, fmt.Errorf("update thought: %w", err)
|
return thoughttypes.Thought{}, fmt.Errorf("update thought: %w", err)
|
||||||
}
|
}
|
||||||
@@ -317,12 +337,12 @@ func (db *DB) UpdateThoughtMetadata(ctx context.Context, id int64, metadata thou
|
|||||||
return thoughttypes.Thought{}, fmt.Errorf("marshal updated metadata: %w", err)
|
return thoughttypes.Thought{}, fmt.Errorf("marshal updated metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
args := []any{id, metadataBytes}
|
||||||
tag, err := db.pool.Exec(ctx, `
|
tag, err := db.pool.Exec(ctx, `
|
||||||
update thoughts
|
update thoughts
|
||||||
set metadata = $2::jsonb,
|
set metadata = $2::jsonb,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
where id = $1
|
where id = $1`+tenantSQL(ctx, &args, "tenant_id"), args...)
|
||||||
`, id, metadataBytes)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return thoughttypes.Thought{}, fmt.Errorf("update thought metadata: %w", err)
|
return thoughttypes.Thought{}, fmt.Errorf("update thought metadata: %w", err)
|
||||||
}
|
}
|
||||||
@@ -334,7 +354,8 @@ func (db *DB) UpdateThoughtMetadata(ctx context.Context, id int64, metadata thou
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) DeleteThought(ctx context.Context, id uuid.UUID) error {
|
func (db *DB) DeleteThought(ctx context.Context, id uuid.UUID) error {
|
||||||
tag, err := db.pool.Exec(ctx, `delete from thoughts where guid = $1`, id)
|
args := []any{id}
|
||||||
|
tag, err := db.pool.Exec(ctx, `delete from thoughts where guid = $1`+tenantSQL(ctx, &args, "tenant_id"), args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("delete thought: %w", err)
|
return fmt.Errorf("delete thought: %w", err)
|
||||||
}
|
}
|
||||||
@@ -345,7 +366,8 @@ func (db *DB) DeleteThought(ctx context.Context, id uuid.UUID) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) ArchiveThought(ctx context.Context, id uuid.UUID) error {
|
func (db *DB) ArchiveThought(ctx context.Context, id uuid.UUID) error {
|
||||||
tag, err := db.pool.Exec(ctx, `update thoughts set archived_at = now(), updated_at = now() where guid = $1`, id)
|
args := []any{id}
|
||||||
|
tag, err := db.pool.Exec(ctx, `update thoughts set archived_at = now(), updated_at = now() where guid = $1`+tenantSQL(ctx, &args, "tenant_id"), args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("archive thought: %w", err)
|
return fmt.Errorf("archive thought: %w", err)
|
||||||
}
|
}
|
||||||
@@ -424,6 +446,7 @@ func (db *DB) SearchSimilarThoughts(ctx context.Context, embedding []float32, em
|
|||||||
"1 - (e.embedding <=> $1) > $2",
|
"1 - (e.embedding <=> $1) > $2",
|
||||||
"e.model = $3",
|
"e.model = $3",
|
||||||
}
|
}
|
||||||
|
addTenantCondition(ctx, &args, &conditions, "t.tenant_id")
|
||||||
if projectID != nil {
|
if projectID != nil {
|
||||||
args = append(args, *projectID)
|
args = append(args, *projectID)
|
||||||
conditions = append(conditions, fmt.Sprintf("t.project_id = $%d", len(args)))
|
conditions = append(conditions, fmt.Sprintf("t.project_id = $%d", len(args)))
|
||||||
@@ -472,6 +495,7 @@ func (db *DB) HasEmbeddingsForModel(ctx context.Context, model string, projectID
|
|||||||
"e.model = $1",
|
"e.model = $1",
|
||||||
"t.archived_at is null",
|
"t.archived_at is null",
|
||||||
}
|
}
|
||||||
|
addTenantCondition(ctx, &args, &conditions, "t.tenant_id")
|
||||||
if projectID != nil {
|
if projectID != nil {
|
||||||
args = append(args, *projectID)
|
args = append(args, *projectID)
|
||||||
conditions = append(conditions, fmt.Sprintf("t.project_id = $%d", len(args)))
|
conditions = append(conditions, fmt.Sprintf("t.project_id = $%d", len(args)))
|
||||||
@@ -490,6 +514,7 @@ func (db *DB) HasEmbeddingsForModel(ctx context.Context, model string, projectID
|
|||||||
func (db *DB) ListThoughtsMissingEmbedding(ctx context.Context, model string, limit int, projectID *int64, includeArchived bool, olderThanDays int) ([]thoughttypes.Thought, error) {
|
func (db *DB) ListThoughtsMissingEmbedding(ctx context.Context, model string, limit int, projectID *int64, includeArchived bool, olderThanDays int) ([]thoughttypes.Thought, error) {
|
||||||
args := []any{model}
|
args := []any{model}
|
||||||
conditions := []string{"e.id is null"}
|
conditions := []string{"e.id is null"}
|
||||||
|
addTenantCondition(ctx, &args, &conditions, "t.tenant_id")
|
||||||
|
|
||||||
if !includeArchived {
|
if !includeArchived {
|
||||||
conditions = append(conditions, "t.archived_at is null")
|
conditions = append(conditions, "t.archived_at is null")
|
||||||
@@ -539,6 +564,7 @@ func (db *DB) ListThoughtsMissingEmbedding(ctx context.Context, model string, li
|
|||||||
func (db *DB) ListThoughtsForMetadataReparse(ctx context.Context, limit int, projectID *int64, includeArchived bool, olderThanDays int) ([]thoughttypes.Thought, error) {
|
func (db *DB) ListThoughtsForMetadataReparse(ctx context.Context, limit int, projectID *int64, includeArchived bool, olderThanDays int) ([]thoughttypes.Thought, error) {
|
||||||
args := make([]any, 0, 3)
|
args := make([]any, 0, 3)
|
||||||
conditions := make([]string, 0, 4)
|
conditions := make([]string, 0, 4)
|
||||||
|
addTenantCondition(ctx, &args, &conditions, "tenant_id")
|
||||||
|
|
||||||
if !includeArchived {
|
if !includeArchived {
|
||||||
conditions = append(conditions, "archived_at is null")
|
conditions = append(conditions, "archived_at is null")
|
||||||
@@ -608,6 +634,7 @@ func (db *DB) SearchThoughtsText(ctx context.Context, query string, limit int, p
|
|||||||
"t.archived_at is null",
|
"t.archived_at is null",
|
||||||
"(to_tsvector('simple', t.content) || to_tsvector('simple', coalesce(p.name, ''))) @@ websearch_to_tsquery('simple', $1)",
|
"(to_tsvector('simple', t.content) || to_tsvector('simple', coalesce(p.name, ''))) @@ websearch_to_tsquery('simple', $1)",
|
||||||
}
|
}
|
||||||
|
addTenantCondition(ctx, &args, &conditions, "t.tenant_id")
|
||||||
if projectID != nil {
|
if projectID != nil {
|
||||||
args = append(args, *projectID)
|
args = append(args, *projectID)
|
||||||
conditions = append(conditions, fmt.Sprintf("t.project_id = $%d", len(args)))
|
conditions = append(conditions, fmt.Sprintf("t.project_id = $%d", len(args)))
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package tenancy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type contextKey string
|
||||||
|
|
||||||
|
const tenantKeyContextKey contextKey = "tenancy.tenant_key"
|
||||||
|
|
||||||
|
// WithTenantKey returns a context scoped to the authenticated tenant boundary.
|
||||||
|
// The tenant key is intentionally opaque; today it is the authenticated API key
|
||||||
|
// or OAuth client id, and callers should not interpret it as a human username.
|
||||||
|
func WithTenantKey(ctx context.Context, tenantKey string) context.Context {
|
||||||
|
tenantKey = strings.TrimSpace(tenantKey)
|
||||||
|
if tenantKey == "" {
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
return context.WithValue(ctx, tenantKeyContextKey, tenantKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func KeyFromContext(ctx context.Context) (string, bool) {
|
||||||
|
if ctx == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
value, ok := ctx.Value(tenantKeyContextKey).(string)
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
return value, ok && value != ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/config"
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/session"
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/store"
|
||||||
|
thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultDuplicateAuditLimit = 50
|
||||||
|
|
||||||
|
var duplicateWhitespace = regexp.MustCompile(`\s+`)
|
||||||
|
|
||||||
|
type DuplicateAuditTool struct {
|
||||||
|
store *store.DB
|
||||||
|
search config.SearchConfig
|
||||||
|
sessions *session.ActiveProjects
|
||||||
|
}
|
||||||
|
|
||||||
|
type DuplicateAuditInput struct {
|
||||||
|
Project string `json:"project,omitempty" jsonschema:"optional project name or id to scope thought duplicate scanning"`
|
||||||
|
Limit int `json:"limit,omitempty" jsonschema:"maximum candidate groups to return"`
|
||||||
|
ThoughtLimit int `json:"thought_limit,omitempty" jsonschema:"maximum thoughts to scan for duplicate content; defaults to search limit"`
|
||||||
|
IncludeArchived bool `json:"include_archived,omitempty" jsonschema:"include archived thoughts in the scan"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DuplicateAuditOutput struct {
|
||||||
|
Report DuplicateAuditReport `json:"report"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DuplicateAuditReport struct {
|
||||||
|
Summary DuplicateAuditSummary `json:"summary"`
|
||||||
|
ProjectCandidates []DuplicateProjectCandidate `json:"project_candidates"`
|
||||||
|
ThoughtCandidates []DuplicateThoughtCandidate `json:"thought_candidates"`
|
||||||
|
MetadataCandidates []MetadataCleanupCandidate `json:"metadata_candidates"`
|
||||||
|
RecommendedNextStep string `json:"recommended_next_step"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DuplicateAuditSummary struct {
|
||||||
|
DryRun bool `json:"dry_run"`
|
||||||
|
ScannedProjects int `json:"scanned_projects"`
|
||||||
|
ScannedThoughts int `json:"scanned_thoughts"`
|
||||||
|
ProjectCandidateGroups int `json:"project_candidate_groups"`
|
||||||
|
ThoughtCandidateGroups int `json:"thought_candidate_groups"`
|
||||||
|
MetadataCandidateGroups int `json:"metadata_candidate_groups"`
|
||||||
|
Truncated bool `json:"truncated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DuplicateProjectCandidate struct {
|
||||||
|
MatchType string `json:"match_type"`
|
||||||
|
NormalizedValue string `json:"normalized_value"`
|
||||||
|
Confidence float64 `json:"confidence"`
|
||||||
|
RecommendedCanonicalID uuid.UUID `json:"recommended_canonical_id"`
|
||||||
|
Projects []thoughttypes.ProjectSummary `json:"projects"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DuplicateThoughtCandidate struct {
|
||||||
|
MatchType string `json:"match_type"`
|
||||||
|
NormalizedValue string `json:"normalized_value"`
|
||||||
|
Confidence float64 `json:"confidence"`
|
||||||
|
RecommendedCanonicalID uuid.UUID `json:"recommended_canonical_id"`
|
||||||
|
Thoughts []thoughttypes.Thought `json:"thoughts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MetadataCleanupCandidate struct {
|
||||||
|
Field string `json:"field"`
|
||||||
|
NormalizedValue string `json:"normalized_value"`
|
||||||
|
Values []string `json:"values"`
|
||||||
|
Occurrences int `json:"occurrences"`
|
||||||
|
RecommendedValue string `json:"recommended_value"`
|
||||||
|
RecommendedAction string `json:"recommended_action"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDuplicateAuditTool(db *store.DB, search config.SearchConfig, sessions *session.ActiveProjects) *DuplicateAuditTool {
|
||||||
|
return &DuplicateAuditTool{store: db, search: search, sessions: sessions}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *DuplicateAuditTool) Handle(ctx context.Context, req *mcp.CallToolRequest, in DuplicateAuditInput) (*mcp.CallToolResult, DuplicateAuditOutput, error) {
|
||||||
|
project, err := resolveProject(ctx, t.store, t.sessions, req, in.Project, false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, DuplicateAuditOutput{}, err
|
||||||
|
}
|
||||||
|
var projectID *int64
|
||||||
|
if project != nil {
|
||||||
|
projectID = &project.NumericID
|
||||||
|
}
|
||||||
|
|
||||||
|
projects, err := t.store.ListProjects(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, DuplicateAuditOutput{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
thoughtLimit := normalizeLimit(in.ThoughtLimit, t.search)
|
||||||
|
if in.ThoughtLimit <= 0 {
|
||||||
|
thoughtLimit = normalizeLimit(0, t.search)
|
||||||
|
}
|
||||||
|
thoughts, err := t.store.ListThoughts(ctx, thoughttypes.ListFilter{Limit: thoughtLimit, ProjectID: projectID, IncludeArchived: in.IncludeArchived})
|
||||||
|
if err != nil {
|
||||||
|
return nil, DuplicateAuditOutput{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if project != nil {
|
||||||
|
_ = t.store.TouchProject(ctx, project.NumericID)
|
||||||
|
}
|
||||||
|
return nil, DuplicateAuditOutput{Report: buildDuplicateAuditReport(projects, thoughts, in)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildDuplicateAuditReport(projects []thoughttypes.ProjectSummary, thoughts []thoughttypes.Thought, in DuplicateAuditInput) DuplicateAuditReport {
|
||||||
|
limit := in.Limit
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = defaultDuplicateAuditLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
projectCandidates, projectTruncated := duplicateProjectCandidates(projects, limit)
|
||||||
|
thoughtCandidates, thoughtTruncated := duplicateThoughtCandidates(thoughts, limit)
|
||||||
|
metadataCandidates, metadataTruncated := metadataCleanupCandidates(thoughts, limit)
|
||||||
|
|
||||||
|
return DuplicateAuditReport{
|
||||||
|
Summary: DuplicateAuditSummary{
|
||||||
|
DryRun: true,
|
||||||
|
ScannedProjects: len(projects),
|
||||||
|
ScannedThoughts: len(thoughts),
|
||||||
|
ProjectCandidateGroups: len(projectCandidates),
|
||||||
|
ThoughtCandidateGroups: len(thoughtCandidates),
|
||||||
|
MetadataCandidateGroups: len(metadataCandidates),
|
||||||
|
Truncated: projectTruncated || thoughtTruncated || metadataTruncated,
|
||||||
|
},
|
||||||
|
ProjectCandidates: projectCandidates,
|
||||||
|
ThoughtCandidates: thoughtCandidates,
|
||||||
|
MetadataCandidates: metadataCandidates,
|
||||||
|
RecommendedNextStep: "Review candidates manually, then use explicit archive/update/merge tools; this audit performs no writes.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func duplicateProjectCandidates(projects []thoughttypes.ProjectSummary, limit int) ([]DuplicateProjectCandidate, bool) {
|
||||||
|
groups := map[string][]thoughttypes.ProjectSummary{}
|
||||||
|
for _, project := range projects {
|
||||||
|
key := normalizeDuplicateText(project.Name)
|
||||||
|
if key != "" {
|
||||||
|
groups[key] = append(groups[key], project)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keys := sortedDuplicateKeys(groups)
|
||||||
|
out := make([]DuplicateProjectCandidate, 0, min(len(keys), limit))
|
||||||
|
for _, key := range keys {
|
||||||
|
items := groups[key]
|
||||||
|
sort.SliceStable(items, func(i, j int) bool {
|
||||||
|
if items[i].ThoughtCount != items[j].ThoughtCount {
|
||||||
|
return items[i].ThoughtCount > items[j].ThoughtCount
|
||||||
|
}
|
||||||
|
return items[i].CreatedAt.Before(items[j].CreatedAt)
|
||||||
|
})
|
||||||
|
matchType := "normalized_name"
|
||||||
|
if allProjectNamesExact(items) {
|
||||||
|
matchType = "exact_name"
|
||||||
|
}
|
||||||
|
out = append(out, DuplicateProjectCandidate{MatchType: matchType, NormalizedValue: key, Confidence: 1.0, RecommendedCanonicalID: items[0].ID, Projects: items})
|
||||||
|
if len(out) == limit {
|
||||||
|
return out, len(keys) > limit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func duplicateThoughtCandidates(thoughts []thoughttypes.Thought, limit int) ([]DuplicateThoughtCandidate, bool) {
|
||||||
|
groups := map[string][]thoughttypes.Thought{}
|
||||||
|
for _, thought := range thoughts {
|
||||||
|
key := normalizeDuplicateText(thought.Content)
|
||||||
|
if key != "" {
|
||||||
|
groups[key] = append(groups[key], thought)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keys := sortedDuplicateKeys(groups)
|
||||||
|
out := make([]DuplicateThoughtCandidate, 0, min(len(keys), limit))
|
||||||
|
for _, key := range keys {
|
||||||
|
items := groups[key]
|
||||||
|
sort.SliceStable(items, func(i, j int) bool { return items[i].CreatedAt.Before(items[j].CreatedAt) })
|
||||||
|
matchType := "normalized_content"
|
||||||
|
if allThoughtContentExact(items) {
|
||||||
|
matchType = "exact_content"
|
||||||
|
}
|
||||||
|
out = append(out, DuplicateThoughtCandidate{MatchType: matchType, NormalizedValue: key, Confidence: 1.0, RecommendedCanonicalID: items[0].GUID, Thoughts: items})
|
||||||
|
if len(out) == limit {
|
||||||
|
return out, len(keys) > limit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func metadataCleanupCandidates(thoughts []thoughttypes.Thought, limit int) ([]MetadataCleanupCandidate, bool) {
|
||||||
|
groups := map[string]map[string]int{}
|
||||||
|
add := func(field, value string) {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
key := normalizeDuplicateText(trimmed)
|
||||||
|
if key == "" || trimmed == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bucketKey := field + "\x00" + key
|
||||||
|
if groups[bucketKey] == nil {
|
||||||
|
groups[bucketKey] = map[string]int{}
|
||||||
|
}
|
||||||
|
groups[bucketKey][trimmed]++
|
||||||
|
}
|
||||||
|
for _, thought := range thoughts {
|
||||||
|
add("type", thought.Metadata.Type)
|
||||||
|
add("source", thought.Metadata.Source)
|
||||||
|
for _, topic := range thought.Metadata.Topics {
|
||||||
|
add("topics", topic)
|
||||||
|
}
|
||||||
|
for _, person := range thought.Metadata.People {
|
||||||
|
add("people", person)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keys := make([]string, 0, len(groups))
|
||||||
|
for key, values := range groups {
|
||||||
|
if len(values) > 1 {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
|
||||||
|
out := make([]MetadataCleanupCandidate, 0, min(len(keys), limit))
|
||||||
|
for _, key := range keys {
|
||||||
|
parts := strings.SplitN(key, "\x00", 2)
|
||||||
|
values, occurrences, recommended := valuesByCount(groups[key])
|
||||||
|
out = append(out, MetadataCleanupCandidate{
|
||||||
|
Field: parts[0],
|
||||||
|
NormalizedValue: parts[1],
|
||||||
|
Values: values,
|
||||||
|
Occurrences: occurrences,
|
||||||
|
RecommendedValue: recommended,
|
||||||
|
RecommendedAction: "preview bulk metadata remap before applying; no changes made by audit",
|
||||||
|
})
|
||||||
|
if len(out) == limit {
|
||||||
|
return out, len(keys) > limit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedDuplicateKeys[T any](groups map[string][]T) []string {
|
||||||
|
keys := make([]string, 0, len(groups))
|
||||||
|
for key, items := range groups {
|
||||||
|
if len(items) > 1 {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
func valuesByCount(counts map[string]int) ([]string, int, string) {
|
||||||
|
values := make([]string, 0, len(counts))
|
||||||
|
occurrences := 0
|
||||||
|
for value, count := range counts {
|
||||||
|
values = append(values, value)
|
||||||
|
occurrences += count
|
||||||
|
}
|
||||||
|
sort.Slice(values, func(i, j int) bool {
|
||||||
|
if counts[values[i]] != counts[values[j]] {
|
||||||
|
return counts[values[i]] > counts[values[j]]
|
||||||
|
}
|
||||||
|
return values[i] < values[j]
|
||||||
|
})
|
||||||
|
return values, occurrences, values[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeDuplicateText(value string) string {
|
||||||
|
return duplicateWhitespace.ReplaceAllString(strings.ToLower(strings.TrimSpace(value)), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func allProjectNamesExact(projects []thoughttypes.ProjectSummary) bool {
|
||||||
|
if len(projects) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
first := strings.TrimSpace(projects[0].Name)
|
||||||
|
for _, project := range projects[1:] {
|
||||||
|
if strings.TrimSpace(project.Name) != first {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func allThoughtContentExact(thoughts []thoughttypes.Thought) bool {
|
||||||
|
if len(thoughts) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
first := strings.TrimSpace(thoughts[0].Content)
|
||||||
|
for _, thought := range thoughts[1:] {
|
||||||
|
if strings.TrimSpace(thought.Content) != first {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildDuplicateAuditGroupsProjectsAndThoughtsSafely(t *testing.T) {
|
||||||
|
now := time.Date(2026, 4, 12, 20, 5, 0, 0, time.UTC)
|
||||||
|
projects := []thoughttypes.ProjectSummary{
|
||||||
|
{Project: thoughttypes.Project{ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), NumericID: 1, Name: "AMCS", CreatedAt: now}, ThoughtCount: 3},
|
||||||
|
{Project: thoughttypes.Project{ID: uuid.MustParse("22222222-2222-2222-2222-222222222222"), NumericID: 2, Name: "amcs ", CreatedAt: now.Add(time.Hour)}, ThoughtCount: 1},
|
||||||
|
{Project: thoughttypes.Project{ID: uuid.MustParse("33333333-3333-3333-3333-333333333333"), NumericID: 3, Name: "other", CreatedAt: now}, ThoughtCount: 1},
|
||||||
|
}
|
||||||
|
thoughts := []thoughttypes.Thought{
|
||||||
|
{ID: 10, GUID: uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), Content: "Same text", Metadata: thoughttypes.ThoughtMetadata{Type: "Note", Topics: []string{"Go", "go"}, People: []string{"Alice"}}, CreatedAt: now},
|
||||||
|
{ID: 11, GUID: uuid.MustParse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), Content: " same text ", Metadata: thoughttypes.ThoughtMetadata{Type: "note", Topics: []string{"go"}, People: []string{"alice"}}, CreatedAt: now.Add(time.Hour)},
|
||||||
|
{ID: 12, GUID: uuid.MustParse("cccccccc-cccc-cccc-cccc-cccccccccccc"), Content: "unique", Metadata: thoughttypes.ThoughtMetadata{Type: "Task", Topics: []string{"Tasks"}}, CreatedAt: now},
|
||||||
|
}
|
||||||
|
|
||||||
|
report := buildDuplicateAuditReport(projects, thoughts, DuplicateAuditInput{})
|
||||||
|
|
||||||
|
if len(report.ProjectCandidates) != 1 {
|
||||||
|
t.Fatalf("project candidates = %d, want 1: %#v", len(report.ProjectCandidates), report.ProjectCandidates)
|
||||||
|
}
|
||||||
|
projectCandidate := report.ProjectCandidates[0]
|
||||||
|
if projectCandidate.MatchType != "normalized_name" || projectCandidate.NormalizedValue != "amcs" || len(projectCandidate.Projects) != 2 {
|
||||||
|
t.Fatalf("project candidate mismatch: %#v", projectCandidate)
|
||||||
|
}
|
||||||
|
if projectCandidate.RecommendedCanonicalID != projects[0].ID {
|
||||||
|
t.Fatalf("recommended project = %s, want %s", projectCandidate.RecommendedCanonicalID, projects[0].ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(report.ThoughtCandidates) != 1 {
|
||||||
|
t.Fatalf("thought candidates = %d, want 1: %#v", len(report.ThoughtCandidates), report.ThoughtCandidates)
|
||||||
|
}
|
||||||
|
thoughtCandidate := report.ThoughtCandidates[0]
|
||||||
|
if thoughtCandidate.MatchType != "normalized_content" || thoughtCandidate.NormalizedValue != "same text" || len(thoughtCandidate.Thoughts) != 2 {
|
||||||
|
t.Fatalf("thought candidate mismatch: %#v", thoughtCandidate)
|
||||||
|
}
|
||||||
|
if thoughtCandidate.RecommendedCanonicalID != thoughts[0].GUID {
|
||||||
|
t.Fatalf("recommended thought = %s, want %s", thoughtCandidate.RecommendedCanonicalID, thoughts[0].GUID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(report.MetadataCandidates) != 3 {
|
||||||
|
t.Fatalf("metadata candidates = %d, want 3: %#v", len(report.MetadataCandidates), report.MetadataCandidates)
|
||||||
|
}
|
||||||
|
if report.Summary.ProjectCandidateGroups != 1 || report.Summary.ThoughtCandidateGroups != 1 || report.Summary.MetadataCandidateGroups != 3 {
|
||||||
|
t.Fatalf("summary mismatch: %#v", report.Summary)
|
||||||
|
}
|
||||||
|
if report.Summary.DryRun != true {
|
||||||
|
t.Fatalf("dry run = false, want true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildDuplicateAuditRespectsLimit(t *testing.T) {
|
||||||
|
projects := []thoughttypes.ProjectSummary{
|
||||||
|
{Project: thoughttypes.Project{ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), Name: "Alpha"}},
|
||||||
|
{Project: thoughttypes.Project{ID: uuid.MustParse("22222222-2222-2222-2222-222222222222"), Name: "alpha"}},
|
||||||
|
{Project: thoughttypes.Project{ID: uuid.MustParse("33333333-3333-3333-3333-333333333333"), Name: "Beta"}},
|
||||||
|
{Project: thoughttypes.Project{ID: uuid.MustParse("44444444-4444-4444-4444-444444444444"), Name: "beta"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
report := buildDuplicateAuditReport(projects, nil, DuplicateAuditInput{Limit: 1})
|
||||||
|
|
||||||
|
if len(report.ProjectCandidates) != 1 {
|
||||||
|
t.Fatalf("project candidates = %d, want 1", len(report.ProjectCandidates))
|
||||||
|
}
|
||||||
|
if report.Summary.Truncated != true {
|
||||||
|
t.Fatalf("truncated = false, want true")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/store"
|
||||||
|
thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ThoughtLearningLinksTool struct {
|
||||||
|
store *store.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
type LinkThoughtLearningInput struct {
|
||||||
|
ThoughtID string `json:"thought_id" jsonschema:"UUID of the thought to link"`
|
||||||
|
LearningID int64 `json:"learning_id" jsonschema:"numeric id of the learning to link"`
|
||||||
|
Relation string `json:"relation,omitempty" jsonschema:"relationship label, e.g. source, derived_from, related; defaults to source"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LinkThoughtLearningOutput struct {
|
||||||
|
Linked bool `json:"linked"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UnlinkThoughtLearningInput struct {
|
||||||
|
ThoughtID string `json:"thought_id" jsonschema:"UUID of the thought"`
|
||||||
|
LearningID int64 `json:"learning_id" jsonschema:"numeric id of the learning"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UnlinkThoughtLearningOutput struct {
|
||||||
|
Unlinked bool `json:"unlinked"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetThoughtLearningsInput struct {
|
||||||
|
ThoughtID string `json:"thought_id" jsonschema:"UUID of the thought"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetThoughtLearningsOutput struct {
|
||||||
|
Learnings []thoughttypes.LinkedLearning `json:"learnings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetLearningThoughtsInput struct {
|
||||||
|
LearningID int64 `json:"learning_id" jsonschema:"numeric id of the learning"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetLearningThoughtsOutput struct {
|
||||||
|
Thoughts []thoughttypes.LinkedLearningThought `json:"thoughts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewThoughtLearningLinksTool(db *store.DB) *ThoughtLearningLinksTool {
|
||||||
|
return &ThoughtLearningLinksTool{store: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ThoughtLearningLinksTool) Link(ctx context.Context, _ *mcp.CallToolRequest, in LinkThoughtLearningInput) (*mcp.CallToolResult, LinkThoughtLearningOutput, error) {
|
||||||
|
if err := t.ensureConfigured(); err != nil {
|
||||||
|
return nil, LinkThoughtLearningOutput{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
thoughtGUID, err := parseUUID(in.ThoughtID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, LinkThoughtLearningOutput{}, err
|
||||||
|
}
|
||||||
|
if in.LearningID <= 0 {
|
||||||
|
return nil, LinkThoughtLearningOutput{}, errRequiredField("learning_id")
|
||||||
|
}
|
||||||
|
relation := strings.TrimSpace(in.Relation)
|
||||||
|
if relation == "" {
|
||||||
|
relation = "source"
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := t.store.GetThought(ctx, thoughtGUID); err != nil {
|
||||||
|
return nil, LinkThoughtLearningOutput{}, errEntityNotFound("thought", "thought_id", in.ThoughtID)
|
||||||
|
}
|
||||||
|
if _, err := t.store.GetLearning(ctx, in.LearningID); err != nil {
|
||||||
|
return nil, LinkThoughtLearningOutput{}, errEntityNotFound("learning", "learning_id", strconv.FormatInt(in.LearningID, 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := t.store.LinkThoughtLearning(ctx, thoughtGUID, in.LearningID, relation); err != nil {
|
||||||
|
return nil, LinkThoughtLearningOutput{}, err
|
||||||
|
}
|
||||||
|
return nil, LinkThoughtLearningOutput{Linked: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ThoughtLearningLinksTool) Unlink(ctx context.Context, _ *mcp.CallToolRequest, in UnlinkThoughtLearningInput) (*mcp.CallToolResult, UnlinkThoughtLearningOutput, error) {
|
||||||
|
if err := t.ensureConfigured(); err != nil {
|
||||||
|
return nil, UnlinkThoughtLearningOutput{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
thoughtGUID, err := parseUUID(in.ThoughtID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, UnlinkThoughtLearningOutput{}, err
|
||||||
|
}
|
||||||
|
if in.LearningID <= 0 {
|
||||||
|
return nil, UnlinkThoughtLearningOutput{}, errRequiredField("learning_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := t.store.UnlinkThoughtLearning(ctx, thoughtGUID, in.LearningID); err != nil {
|
||||||
|
return nil, UnlinkThoughtLearningOutput{}, err
|
||||||
|
}
|
||||||
|
return nil, UnlinkThoughtLearningOutput{Unlinked: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ThoughtLearningLinksTool) GetThoughtLearnings(ctx context.Context, _ *mcp.CallToolRequest, in GetThoughtLearningsInput) (*mcp.CallToolResult, GetThoughtLearningsOutput, error) {
|
||||||
|
if err := t.ensureConfigured(); err != nil {
|
||||||
|
return nil, GetThoughtLearningsOutput{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
thoughtGUID, err := parseUUID(in.ThoughtID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, GetThoughtLearningsOutput{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := t.store.GetThought(ctx, thoughtGUID); err != nil {
|
||||||
|
return nil, GetThoughtLearningsOutput{}, errEntityNotFound("thought", "thought_id", in.ThoughtID)
|
||||||
|
}
|
||||||
|
|
||||||
|
learnings, err := t.store.GetLinkedLearnings(ctx, thoughtGUID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, GetThoughtLearningsOutput{}, err
|
||||||
|
}
|
||||||
|
return nil, GetThoughtLearningsOutput{Learnings: learnings}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ThoughtLearningLinksTool) GetLearningThoughts(ctx context.Context, _ *mcp.CallToolRequest, in GetLearningThoughtsInput) (*mcp.CallToolResult, GetLearningThoughtsOutput, error) {
|
||||||
|
if err := t.ensureConfigured(); err != nil {
|
||||||
|
return nil, GetLearningThoughtsOutput{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if in.LearningID <= 0 {
|
||||||
|
return nil, GetLearningThoughtsOutput{}, errRequiredField("learning_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := t.store.GetLearning(ctx, in.LearningID); err != nil {
|
||||||
|
return nil, GetLearningThoughtsOutput{}, errEntityNotFound("learning", "learning_id", strconv.FormatInt(in.LearningID, 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
thoughts, err := t.store.GetLinkedThoughtsForLearning(ctx, in.LearningID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, GetLearningThoughtsOutput{}, err
|
||||||
|
}
|
||||||
|
return nil, GetLearningThoughtsOutput{Thoughts: thoughts}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ThoughtLearningLinksTool) ensureConfigured() error {
|
||||||
|
if t == nil || t.store == nil {
|
||||||
|
return errInvalidInput("thought learning links tool is not configured")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -39,3 +39,22 @@ type LinkedThought struct {
|
|||||||
Direction string `json:"direction"`
|
Direction string `json:"direction"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ThoughtLearningLink struct {
|
||||||
|
ThoughtID int64 `json:"thought_id"`
|
||||||
|
LearningID int64 `json:"learning_id"`
|
||||||
|
Relation string `json:"relation"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LinkedLearning struct {
|
||||||
|
Learning Learning `json:"learning"`
|
||||||
|
Relation string `json:"relation"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LinkedLearningThought struct {
|
||||||
|
Thought Thought `json:"thought"`
|
||||||
|
Relation string `json:"relation"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,12 +14,20 @@ type ThoughtMetadata struct {
|
|||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
Attachments []ThoughtAttachment `json:"attachments,omitempty"`
|
Attachments []ThoughtAttachment `json:"attachments,omitempty"`
|
||||||
|
Webhook *WebhookMetadata `json:"webhook,omitempty"`
|
||||||
MetadataStatus string `json:"metadata_status,omitempty"`
|
MetadataStatus string `json:"metadata_status,omitempty"`
|
||||||
MetadataUpdatedAt string `json:"metadata_updated_at,omitempty"`
|
MetadataUpdatedAt string `json:"metadata_updated_at,omitempty"`
|
||||||
MetadataLastAttemptedAt string `json:"metadata_last_attempted_at,omitempty"`
|
MetadataLastAttemptedAt string `json:"metadata_last_attempted_at,omitempty"`
|
||||||
MetadataError string `json:"metadata_error,omitempty"`
|
MetadataError string `json:"metadata_error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WebhookMetadata struct {
|
||||||
|
ReceivedAt string `json:"received_at"`
|
||||||
|
IDempotencyKey string `json:"idempotency_key,omitempty"`
|
||||||
|
ExternalID string `json:"external_id,omitempty"`
|
||||||
|
SourceMetadata map[string]any `json:"source_metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type ThoughtAttachment struct {
|
type ThoughtAttachment struct {
|
||||||
FileID uuid.UUID `json:"file_id"`
|
FileID uuid.UUID `json:"file_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
|||||||
+1836
-90
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
|||||||
|
-- Many-to-many join table between thoughts and learnings.
|
||||||
|
-- Allows a learning to reference multiple source thoughts and a thought to
|
||||||
|
-- expose multiple related learnings, queryable in both directions.
|
||||||
|
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS public.identity_thought_learning_links_id
|
||||||
|
INCREMENT 1
|
||||||
|
MINVALUE 1
|
||||||
|
MAXVALUE 9223372036854775807
|
||||||
|
START 1
|
||||||
|
CACHE 1;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.thought_learning_links (
|
||||||
|
id bigint NOT NULL DEFAULT nextval('public.identity_thought_learning_links_id'),
|
||||||
|
thought_id bigint NOT NULL REFERENCES public.thoughts(id) ON DELETE CASCADE,
|
||||||
|
learning_id bigint NOT NULL REFERENCES public.learnings(id) ON DELETE CASCADE,
|
||||||
|
relation text NOT NULL DEFAULT 'source',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT thought_learning_links_pkey PRIMARY KEY (id),
|
||||||
|
CONSTRAINT thought_learning_links_unique UNIQUE (thought_id, learning_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_thought_learning_links_thought_id
|
||||||
|
ON public.thought_learning_links (thought_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_thought_learning_links_learning_id
|
||||||
|
ON public.thought_learning_links (learning_id);
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
-- Per-user tenancy: authenticate key/client id becomes an opaque tenant boundary.
|
||||||
|
-- Existing rows remain in the legacy unscoped tenant (NULL) for single-tenant installs.
|
||||||
|
|
||||||
|
alter table projects add column if not exists tenant_key text;
|
||||||
|
alter table thoughts add column if not exists tenant_key text;
|
||||||
|
alter table stored_files add column if not exists tenant_key text;
|
||||||
|
alter table learnings add column if not exists tenant_key text;
|
||||||
|
alter table plans add column if not exists tenant_key text;
|
||||||
|
alter table chat_histories add column if not exists tenant_key text;
|
||||||
|
|
||||||
|
-- Project names are now unique inside a tenant rather than globally.
|
||||||
|
alter table projects drop constraint if exists ukey_projects_name;
|
||||||
|
alter table projects drop constraint if exists projects_name_key;
|
||||||
|
drop index if exists projects_name_key;
|
||||||
|
create unique index if not exists projects_tenant_key_name_idx
|
||||||
|
on projects (coalesce(tenant_key, ''), name);
|
||||||
|
|
||||||
|
create index if not exists projects_tenant_key_idx on projects (tenant_key);
|
||||||
|
create index if not exists thoughts_tenant_key_idx on thoughts (tenant_key);
|
||||||
|
create index if not exists thoughts_tenant_key_project_id_idx on thoughts (tenant_key, project_id);
|
||||||
|
create index if not exists stored_files_tenant_key_idx on stored_files (tenant_key);
|
||||||
|
create index if not exists learnings_tenant_key_idx on learnings (tenant_key);
|
||||||
|
create index if not exists plans_tenant_key_idx on plans (tenant_key);
|
||||||
|
create index if not exists chat_histories_tenant_key_idx on chat_histories (tenant_key);
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
create table if not exists tenants (
|
||||||
|
id text primary key,
|
||||||
|
name text not null unique,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists tenant_users (
|
||||||
|
id text primary key,
|
||||||
|
tenant_id text not null references tenants(id),
|
||||||
|
name text not null,
|
||||||
|
email text,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
unique (tenant_id, email)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists tenant_users_tenant_id_idx on tenant_users (tenant_id);
|
||||||
|
|
||||||
|
create table if not exists api_key_assignments (
|
||||||
|
key_id text primary key,
|
||||||
|
tenant_id text not null references tenants(id),
|
||||||
|
user_id text references tenant_users(id),
|
||||||
|
description text not null default '',
|
||||||
|
source text not null check (source in ('configured', 'managed')),
|
||||||
|
enabled boolean not null default true,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists managed_api_keys (
|
||||||
|
key_id text primary key references api_key_assignments(key_id) on delete cascade,
|
||||||
|
secret_hash text not null
|
||||||
|
);
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
-- Convert tenant_key from an opaque authentication value into a real tenant
|
||||||
|
-- relationship. Preserve legacy rows and create placeholders for historical
|
||||||
|
-- key IDs before adding foreign keys.
|
||||||
|
insert into tenants (id, name)
|
||||||
|
select tenant_key, tenant_key
|
||||||
|
from (
|
||||||
|
select tenant_key from projects
|
||||||
|
union select tenant_key from thoughts
|
||||||
|
union select tenant_key from stored_files
|
||||||
|
union select tenant_key from learnings
|
||||||
|
union select tenant_key from plans
|
||||||
|
union select tenant_key from chat_histories
|
||||||
|
) tenant_keys
|
||||||
|
where tenant_key is not null and tenant_key <> ''
|
||||||
|
on conflict (id) do nothing;
|
||||||
|
|
||||||
|
alter table agent_skills add column if not exists tenant_key text references tenants(id);
|
||||||
|
alter table agent_guardrails add column if not exists tenant_key text references tenants(id);
|
||||||
|
alter table agent_personas add column if not exists tenant_key text references tenants(id);
|
||||||
|
alter table agent_parts add column if not exists tenant_key text references tenants(id);
|
||||||
|
alter table agent_traits add column if not exists tenant_key text references tenants(id);
|
||||||
|
alter table character_arcs add column if not exists tenant_key text references tenants(id);
|
||||||
|
|
||||||
|
alter table agent_skills drop constraint if exists ukey_agent_skills_name;
|
||||||
|
alter table agent_guardrails drop constraint if exists ukey_agent_guardrails_name;
|
||||||
|
alter table agent_personas drop constraint if exists ukey_agent_personas_name;
|
||||||
|
alter table agent_parts drop constraint if exists ukey_agent_parts_name;
|
||||||
|
alter table agent_traits drop constraint if exists ukey_agent_traits_name;
|
||||||
|
alter table character_arcs drop constraint if exists ukey_character_arcs_name;
|
||||||
|
|
||||||
|
create unique index if not exists agent_skills_tenant_key_name_idx on agent_skills (coalesce(tenant_key, ''), name);
|
||||||
|
create unique index if not exists agent_guardrails_tenant_key_name_idx on agent_guardrails (coalesce(tenant_key, ''), name);
|
||||||
|
create unique index if not exists agent_personas_tenant_key_name_idx on agent_personas (coalesce(tenant_key, ''), name);
|
||||||
|
create unique index if not exists agent_parts_tenant_key_name_idx on agent_parts (coalesce(tenant_key, ''), name);
|
||||||
|
create unique index if not exists agent_traits_tenant_key_name_idx on agent_traits (coalesce(tenant_key, ''), name);
|
||||||
|
create unique index if not exists character_arcs_tenant_key_name_idx on character_arcs (coalesce(tenant_key, ''), name);
|
||||||
|
|
||||||
|
create index if not exists agent_skills_tenant_key_idx on agent_skills (tenant_key);
|
||||||
|
create index if not exists agent_guardrails_tenant_key_idx on agent_guardrails (tenant_key);
|
||||||
|
create index if not exists agent_personas_tenant_key_idx on agent_personas (tenant_key);
|
||||||
|
create index if not exists agent_parts_tenant_key_idx on agent_parts (tenant_key);
|
||||||
|
create index if not exists agent_traits_tenant_key_idx on agent_traits (tenant_key);
|
||||||
|
create index if not exists character_arcs_tenant_key_idx on character_arcs (tenant_key);
|
||||||
|
|
||||||
|
do $$
|
||||||
|
declare table_name text;
|
||||||
|
begin
|
||||||
|
foreach table_name in array array['projects', 'thoughts', 'stored_files', 'learnings', 'plans', 'chat_histories']
|
||||||
|
loop
|
||||||
|
execute format('alter table %I drop constraint if exists %I', table_name, table_name || '_tenant_key_fkey');
|
||||||
|
execute format('alter table %I add constraint %I foreign key (tenant_key) references tenants(id)', table_name, table_name || '_tenant_key_fkey');
|
||||||
|
end loop;
|
||||||
|
end $$;
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
-- Tenant data is not in use yet, so replace the earlier opaque tenant_key
|
||||||
|
-- column name with the relational tenant_id convention everywhere.
|
||||||
|
do $$
|
||||||
|
declare
|
||||||
|
tbl text;
|
||||||
|
con text;
|
||||||
|
begin
|
||||||
|
foreach tbl in array array[
|
||||||
|
'projects', 'thoughts', 'stored_files', 'learnings', 'plans', 'chat_histories',
|
||||||
|
'agent_skills', 'agent_guardrails', 'agent_personas', 'agent_parts',
|
||||||
|
'agent_traits', 'character_arcs'
|
||||||
|
] loop
|
||||||
|
-- Fresh installs already have tenant_id from the regenerated schema;
|
||||||
|
-- discard that empty column so the historical migration chain converges.
|
||||||
|
if exists (
|
||||||
|
select 1 from information_schema.columns
|
||||||
|
where table_schema = 'public' and table_name = tbl and column_name = 'tenant_id'
|
||||||
|
) and exists (
|
||||||
|
select 1 from information_schema.columns
|
||||||
|
where table_schema = 'public' and table_name = tbl and column_name = 'tenant_key'
|
||||||
|
) then
|
||||||
|
execute format('alter table %I drop column tenant_id', tbl);
|
||||||
|
end if;
|
||||||
|
if exists (
|
||||||
|
select 1 from information_schema.columns
|
||||||
|
where table_schema = 'public' and table_name = tbl and column_name = 'tenant_key'
|
||||||
|
) then
|
||||||
|
execute format('alter table %I rename column tenant_key to tenant_id', tbl);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
for con in
|
||||||
|
select c.conname
|
||||||
|
from pg_constraint c
|
||||||
|
join pg_class r on r.oid = c.conrelid
|
||||||
|
join pg_namespace n on n.oid = r.relnamespace
|
||||||
|
join unnest(c.conkey) as k(attnum) on true
|
||||||
|
join pg_attribute a on a.attrelid = r.oid and a.attnum = k.attnum
|
||||||
|
where n.nspname = 'public' and r.relname = tbl and c.contype = 'f' and a.attname = 'tenant_id'
|
||||||
|
loop
|
||||||
|
execute format('alter table %I drop constraint %I', tbl, con);
|
||||||
|
end loop;
|
||||||
|
execute format('alter table %I add constraint %I foreign key (tenant_id) references tenants(id)', tbl, 'fk_' || tbl || '_tenant_id');
|
||||||
|
end loop;
|
||||||
|
end $$;
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
Table agent_personas {
|
Table agent_personas {
|
||||||
id bigserial [pk]
|
id bigserial [pk]
|
||||||
guid uuid [unique, not null, default: `gen_random_uuid()`]
|
guid uuid [unique, not null, default: `gen_random_uuid()`]
|
||||||
name text [unique, not null]
|
name text [not null]
|
||||||
|
tenant_id text [ref: > tenants.id]
|
||||||
description text [not null, default: '']
|
description text [not null, default: '']
|
||||||
summary text [not null]
|
summary text [not null]
|
||||||
detail text [not null, default: '']
|
detail text [not null, default: '']
|
||||||
@@ -11,12 +12,15 @@ Table agent_personas {
|
|||||||
tags "text[]" [not null, default: `'{}'`]
|
tags "text[]" [not null, default: `'{}'`]
|
||||||
created_at timestamptz [not null, default: `now()`]
|
created_at timestamptz [not null, default: `now()`]
|
||||||
updated_at timestamptz [not null, default: `now()`]
|
updated_at timestamptz [not null, default: `now()`]
|
||||||
|
|
||||||
|
indexes { (tenant_id, name) [unique] tenant_id }
|
||||||
}
|
}
|
||||||
|
|
||||||
Table agent_parts {
|
Table agent_parts {
|
||||||
id bigserial [pk]
|
id bigserial [pk]
|
||||||
guid uuid [unique, not null, default: `gen_random_uuid()`]
|
guid uuid [unique, not null, default: `gen_random_uuid()`]
|
||||||
name text [unique, not null]
|
name text [not null]
|
||||||
|
tenant_id text [ref: > tenants.id]
|
||||||
part_type text [not null]
|
part_type text [not null]
|
||||||
description text [not null, default: '']
|
description text [not null, default: '']
|
||||||
summary text [not null]
|
summary text [not null]
|
||||||
@@ -24,6 +28,8 @@ Table agent_parts {
|
|||||||
tags "text[]" [not null, default: `'{}'`]
|
tags "text[]" [not null, default: `'{}'`]
|
||||||
created_at timestamptz [not null, default: `now()`]
|
created_at timestamptz [not null, default: `now()`]
|
||||||
updated_at timestamptz [not null, default: `now()`]
|
updated_at timestamptz [not null, default: `now()`]
|
||||||
|
|
||||||
|
indexes { (tenant_id, name) [unique] tenant_id }
|
||||||
}
|
}
|
||||||
|
|
||||||
Table agent_persona_parts {
|
Table agent_persona_parts {
|
||||||
@@ -77,13 +83,16 @@ Table agent_persona_guardrails {
|
|||||||
Table agent_traits {
|
Table agent_traits {
|
||||||
id bigserial [pk]
|
id bigserial [pk]
|
||||||
guid uuid [unique, not null, default: `gen_random_uuid()`]
|
guid uuid [unique, not null, default: `gen_random_uuid()`]
|
||||||
name text [unique, not null]
|
name text [not null]
|
||||||
|
tenant_id text [ref: > tenants.id]
|
||||||
trait_type text [not null]
|
trait_type text [not null]
|
||||||
description text [not null, default: '']
|
description text [not null, default: '']
|
||||||
instruction text [not null, default: '']
|
instruction text [not null, default: '']
|
||||||
tags "text[]" [not null, default: `'{}'`]
|
tags "text[]" [not null, default: `'{}'`]
|
||||||
created_at timestamptz [not null, default: `now()`]
|
created_at timestamptz [not null, default: `now()`]
|
||||||
updated_at timestamptz [not null, default: `now()`]
|
updated_at timestamptz [not null, default: `now()`]
|
||||||
|
|
||||||
|
indexes { (tenant_id, name) [unique] tenant_id }
|
||||||
}
|
}
|
||||||
|
|
||||||
Table agent_persona_traits {
|
Table agent_persona_traits {
|
||||||
@@ -98,11 +107,14 @@ Table agent_persona_traits {
|
|||||||
|
|
||||||
Table character_arcs {
|
Table character_arcs {
|
||||||
id bigserial [pk]
|
id bigserial [pk]
|
||||||
name text [unique, not null]
|
name text [not null]
|
||||||
|
tenant_id text [ref: > tenants.id]
|
||||||
description text [not null, default: '']
|
description text [not null, default: '']
|
||||||
summary text [not null, default: '']
|
summary text [not null, default: '']
|
||||||
created_at timestamptz [not null, default: `now()`]
|
created_at timestamptz [not null, default: `now()`]
|
||||||
updated_at timestamptz [not null, default: `now()`]
|
updated_at timestamptz [not null, default: `now()`]
|
||||||
|
|
||||||
|
indexes { (tenant_id, name) [unique] tenant_id }
|
||||||
}
|
}
|
||||||
|
|
||||||
Table arc_stages {
|
Table arc_stages {
|
||||||
@@ -127,7 +139,7 @@ Table arc_stage_parts {
|
|||||||
|
|
||||||
Table persona_arc {
|
Table persona_arc {
|
||||||
id bigserial [pk]
|
id bigserial [pk]
|
||||||
persona_id bigint [pk, ref: > agent_personas.id]
|
persona_id bigint [unique, not null, ref: > agent_personas.id]
|
||||||
arc_id bigint [not null, ref: > character_arcs.id]
|
arc_id bigint [not null, ref: > character_arcs.id]
|
||||||
current_stage_id bigint [not null, ref: > arc_stages.id]
|
current_stage_id bigint [not null, ref: > arc_stages.id]
|
||||||
updated_at timestamptz [not null, default: `now()`]
|
updated_at timestamptz [not null, default: `now()`]
|
||||||
|
|||||||
+27
-1
@@ -6,16 +6,28 @@ Table thoughts {
|
|||||||
created_at timestamptz [default: `now()`]
|
created_at timestamptz [default: `now()`]
|
||||||
updated_at timestamptz [default: `now()`]
|
updated_at timestamptz [default: `now()`]
|
||||||
project_id bigint [ref: > projects.id]
|
project_id bigint [ref: > projects.id]
|
||||||
|
tenant_id text [ref: > tenants.id]
|
||||||
archived_at timestamptz
|
archived_at timestamptz
|
||||||
|
|
||||||
|
indexes {
|
||||||
|
tenant_id
|
||||||
|
(tenant_id, project_id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Table projects {
|
Table projects {
|
||||||
id bigserial [pk]
|
id bigserial [pk]
|
||||||
guid uuid [unique, not null, default: `gen_random_uuid()`]
|
guid uuid [unique, not null, default: `gen_random_uuid()`]
|
||||||
name text [unique, not null]
|
name text [not null]
|
||||||
description text
|
description text
|
||||||
|
tenant_id text [ref: > tenants.id]
|
||||||
created_at timestamptz [default: `now()`]
|
created_at timestamptz [default: `now()`]
|
||||||
last_active_at timestamptz [default: `now()`]
|
last_active_at timestamptz [default: `now()`]
|
||||||
|
|
||||||
|
indexes {
|
||||||
|
(tenant_id, name) [unique]
|
||||||
|
tenant_id
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Table thought_links {
|
Table thought_links {
|
||||||
@@ -32,6 +44,20 @@ Table thought_links {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Table thought_learning_links {
|
||||||
|
id bigserial [pk]
|
||||||
|
thought_id bigint [not null, ref: > thoughts.id]
|
||||||
|
learning_id bigint [not null, ref: > learnings.id]
|
||||||
|
relation text [not null, default: `'source'`]
|
||||||
|
created_at timestamptz [not null, default: `now()`]
|
||||||
|
|
||||||
|
indexes {
|
||||||
|
(thought_id, learning_id) [unique]
|
||||||
|
thought_id
|
||||||
|
learning_id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Table embeddings {
|
Table embeddings {
|
||||||
id bigserial [pk]
|
id bigserial [pk]
|
||||||
guid uuid [unique, not null, default: `gen_random_uuid()`]
|
guid uuid [unique, not null, default: `gen_random_uuid()`]
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ Table stored_files {
|
|||||||
guid uuid [unique, not null, default: `gen_random_uuid()`]
|
guid uuid [unique, not null, default: `gen_random_uuid()`]
|
||||||
thought_id bigint [ref: > thoughts.id]
|
thought_id bigint [ref: > thoughts.id]
|
||||||
project_id bigint [ref: > projects.id]
|
project_id bigint [ref: > projects.id]
|
||||||
|
tenant_id text [ref: > tenants.id]
|
||||||
name text [not null]
|
name text [not null]
|
||||||
media_type text [not null]
|
media_type text [not null]
|
||||||
kind text [not null, default: 'file']
|
kind text [not null, default: 'file']
|
||||||
@@ -16,6 +17,7 @@ Table stored_files {
|
|||||||
indexes {
|
indexes {
|
||||||
thought_id
|
thought_id
|
||||||
project_id
|
project_id
|
||||||
|
tenant_id
|
||||||
sha256
|
sha256
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
Table tenants {
|
||||||
|
id text [pk]
|
||||||
|
name text [not null, unique]
|
||||||
|
created_at timestamptz [not null, default: `now()`]
|
||||||
|
}
|
||||||
|
|
||||||
|
Table tenant_users {
|
||||||
|
id text [pk]
|
||||||
|
tenant_id text [not null, ref: > tenants.id]
|
||||||
|
name text [not null]
|
||||||
|
email text
|
||||||
|
created_at timestamptz [not null, default: `now()`]
|
||||||
|
|
||||||
|
Indexes {
|
||||||
|
tenant_id
|
||||||
|
(tenant_id, email) [unique]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Table api_key_assignments {
|
||||||
|
key_id text [pk]
|
||||||
|
tenant_id text [not null, ref: > tenants.id]
|
||||||
|
user_id text [ref: > tenant_users.id]
|
||||||
|
description text [not null, default: '']
|
||||||
|
source text [not null]
|
||||||
|
enabled boolean [not null, default: true]
|
||||||
|
created_at timestamptz [not null, default: `now()`]
|
||||||
|
}
|
||||||
|
|
||||||
|
Table managed_api_keys {
|
||||||
|
key_id text [pk, ref: > api_key_assignments.key_id]
|
||||||
|
secret_hash text [not null]
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ Table chat_histories {
|
|||||||
channel text
|
channel text
|
||||||
agent_id text
|
agent_id text
|
||||||
project_id bigint [ref: > projects.id]
|
project_id bigint [ref: > projects.id]
|
||||||
|
tenant_id text [ref: > tenants.id]
|
||||||
messages jsonb [not null, default: `'[]'`]
|
messages jsonb [not null, default: `'[]'`]
|
||||||
summary text
|
summary text
|
||||||
metadata jsonb [not null, default: `'{}'`]
|
metadata jsonb [not null, default: `'{}'`]
|
||||||
@@ -15,6 +16,7 @@ Table chat_histories {
|
|||||||
indexes {
|
indexes {
|
||||||
session_id
|
session_id
|
||||||
project_id
|
project_id
|
||||||
|
tenant_id
|
||||||
channel
|
channel
|
||||||
agent_id
|
agent_id
|
||||||
created_at
|
created_at
|
||||||
@@ -46,6 +48,7 @@ Table learnings {
|
|||||||
source_type text
|
source_type text
|
||||||
source_ref text
|
source_ref text
|
||||||
project_id bigint [ref: > projects.id]
|
project_id bigint [ref: > projects.id]
|
||||||
|
tenant_id text [ref: > tenants.id]
|
||||||
related_thought_id bigint [ref: > thoughts.id]
|
related_thought_id bigint [ref: > thoughts.id]
|
||||||
related_skill_id bigint [ref: > agent_skills.id]
|
related_skill_id bigint [ref: > agent_skills.id]
|
||||||
reviewed_by text
|
reviewed_by text
|
||||||
@@ -58,6 +61,7 @@ Table learnings {
|
|||||||
|
|
||||||
indexes {
|
indexes {
|
||||||
project_id
|
project_id
|
||||||
|
tenant_id
|
||||||
category
|
category
|
||||||
area
|
area
|
||||||
status
|
status
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Table plans {
|
|||||||
status text [not null, default: 'draft'] // draft, active, blocked, completed, cancelled, superseded
|
status text [not null, default: 'draft'] // draft, active, blocked, completed, cancelled, superseded
|
||||||
priority text [not null, default: 'medium'] // low, medium, high, critical
|
priority text [not null, default: 'medium'] // low, medium, high, critical
|
||||||
project_id bigint [ref: > projects.id]
|
project_id bigint [ref: > projects.id]
|
||||||
|
tenant_id text [ref: > tenants.id]
|
||||||
owner text
|
owner text
|
||||||
due_date timestamptz
|
due_date timestamptz
|
||||||
completed_at timestamptz
|
completed_at timestamptz
|
||||||
@@ -18,6 +19,7 @@ Table plans {
|
|||||||
|
|
||||||
indexes {
|
indexes {
|
||||||
project_id
|
project_id
|
||||||
|
tenant_id
|
||||||
status
|
status
|
||||||
priority
|
priority
|
||||||
owner
|
owner
|
||||||
|
|||||||
+8
-2
@@ -1,7 +1,8 @@
|
|||||||
Table agent_skills {
|
Table agent_skills {
|
||||||
id bigserial [pk]
|
id bigserial [pk]
|
||||||
guid uuid [unique, not null, default: `gen_random_uuid()`]
|
guid uuid [unique, not null, default: `gen_random_uuid()`]
|
||||||
name text [unique, not null]
|
name text [not null]
|
||||||
|
tenant_id text [ref: > tenants.id]
|
||||||
description text [not null, default: '']
|
description text [not null, default: '']
|
||||||
content text [not null]
|
content text [not null]
|
||||||
tags "text[]" [not null, default: `'{}'`]
|
tags "text[]" [not null, default: `'{}'`]
|
||||||
@@ -11,18 +12,23 @@ Table agent_skills {
|
|||||||
domain_tags "text[]" [not null, default: `'{}'`]
|
domain_tags "text[]" [not null, default: `'{}'`]
|
||||||
created_at timestamptz [not null, default: `now()`]
|
created_at timestamptz [not null, default: `now()`]
|
||||||
updated_at timestamptz [not null, default: `now()`]
|
updated_at timestamptz [not null, default: `now()`]
|
||||||
|
|
||||||
|
indexes { (tenant_id, name) [unique] tenant_id }
|
||||||
}
|
}
|
||||||
|
|
||||||
Table agent_guardrails {
|
Table agent_guardrails {
|
||||||
id bigserial [pk]
|
id bigserial [pk]
|
||||||
guid uuid [unique, not null, default: `gen_random_uuid()`]
|
guid uuid [unique, not null, default: `gen_random_uuid()`]
|
||||||
name text [unique, not null]
|
name text [not null]
|
||||||
|
tenant_id text [ref: > tenants.id]
|
||||||
description text [not null, default: '']
|
description text [not null, default: '']
|
||||||
content text [not null]
|
content text [not null]
|
||||||
severity text [not null, default: 'medium']
|
severity text [not null, default: 'medium']
|
||||||
tags "text[]" [not null, default: `'{}'`]
|
tags "text[]" [not null, default: `'{}'`]
|
||||||
created_at timestamptz [not null, default: `now()`]
|
created_at timestamptz [not null, default: `now()`]
|
||||||
updated_at timestamptz [not null, default: `now()`]
|
updated_at timestamptz [not null, default: `now()`]
|
||||||
|
|
||||||
|
indexes { (tenant_id, name) [unique] tenant_id }
|
||||||
}
|
}
|
||||||
|
|
||||||
Table project_skills {
|
Table project_skills {
|
||||||
|
|||||||
+10
-10
@@ -11,22 +11,22 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
"@sveltejs/vite-plugin-svelte": "^7.2.0",
|
||||||
"@tailwindcss/vite": "^4.2.4",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"@types/node": "^25.6.0",
|
"@types/node": "^26.1.1",
|
||||||
"svelte": "^5.55.5",
|
"svelte": "^5.56.6",
|
||||||
"svelte-check": "^4.4.6",
|
"svelte-check": "^4.7.3",
|
||||||
"tailwindcss": "^4.2.4",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"vite": "^8.0.10"
|
"vite": "^8.1.5"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sentry/svelte": "^10.51.0",
|
"@sentry/svelte": "^10.67.0",
|
||||||
"@skeletonlabs/skeleton": "^4.15.2",
|
"@skeletonlabs/skeleton": "^4.15.2",
|
||||||
"@skeletonlabs/skeleton-svelte": "^4.15.2",
|
"@skeletonlabs/skeleton-svelte": "^4.15.2",
|
||||||
"@tanstack/svelte-virtual": "^3.13.24",
|
"@tanstack/svelte-virtual": "^3.13.33",
|
||||||
"@warkypublic/artemis-kit": "^1.0.10",
|
"@warkypublic/artemis-kit": "^1.0.10",
|
||||||
"@warkypublic/resolvespec-js": "^1.0.1",
|
"@warkypublic/resolvespec-js": "^1.0.1",
|
||||||
"@warkypublic/svelix": "^0.1.40"
|
"@warkypublic/svelix": "^0.2.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Generated
+435
-390
File diff suppressed because it is too large
Load Diff
+84
-3
@@ -1,8 +1,9 @@
|
|||||||
import { GlobalStateStore } from './shellState';
|
import { GlobalStateStore } from './shellState';
|
||||||
|
import { currentTenantID, tenantScopeHeaders } from './tenantScope';
|
||||||
|
|
||||||
function authHeaders(): HeadersInit {
|
function authHeaders(): HeadersInit {
|
||||||
const token = GlobalStateStore.getState().session.authToken;
|
const token = GlobalStateStore.getState().session.authToken;
|
||||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
return { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...tenantScopeHeaders() };
|
||||||
}
|
}
|
||||||
|
|
||||||
type ResolveSpecResponse<T> = {
|
type ResolveSpecResponse<T> = {
|
||||||
@@ -18,6 +19,62 @@ type ResolveSpecFilter = {
|
|||||||
value?: unknown;
|
value?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const TENANT_OWNED_RESOLVE_SPEC_ENTITIES = new Set([
|
||||||
|
'projects',
|
||||||
|
'thoughts',
|
||||||
|
'learnings',
|
||||||
|
'plans',
|
||||||
|
'stored_files',
|
||||||
|
'agent_skills',
|
||||||
|
'agent_guardrails',
|
||||||
|
'agent_personas',
|
||||||
|
'agent_parts',
|
||||||
|
'agent_traits',
|
||||||
|
'character_arcs',
|
||||||
|
'chat_histories'
|
||||||
|
]);
|
||||||
|
|
||||||
|
function resolveSpecEntity(path: string): string | null {
|
||||||
|
const match = path.match(/^\/api\/rs\/public\/([^/?]+)/);
|
||||||
|
return match?.[1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tenantScopedResolveSpecPayload(
|
||||||
|
path: string,
|
||||||
|
operation: 'read' | 'create' | 'update' | 'delete',
|
||||||
|
payload?: { data?: unknown; options?: unknown }
|
||||||
|
): { data?: unknown; options?: unknown } | undefined {
|
||||||
|
const entity = resolveSpecEntity(path);
|
||||||
|
const tenantID = currentTenantID();
|
||||||
|
if (!entity || !tenantID || !TENANT_OWNED_RESOLVE_SPEC_ENTITIES.has(entity)) return payload;
|
||||||
|
|
||||||
|
if (operation === 'create') {
|
||||||
|
const data = payload?.data;
|
||||||
|
return {
|
||||||
|
...payload,
|
||||||
|
// A selected tenant is authoritative; do not permit a form value to
|
||||||
|
// accidentally create data in a different tenant.
|
||||||
|
data: data && typeof data === 'object' && !Array.isArray(data) ? { ...data, tenant_id: tenantID } : data
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingOptions = payload?.options && typeof payload.options === 'object' && !Array.isArray(payload.options)
|
||||||
|
? payload.options as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
const filters = Array.isArray(existingOptions.filters)
|
||||||
|
? existingOptions.filters.filter((filter): filter is ResolveSpecFilter =>
|
||||||
|
Boolean(filter) && typeof filter === 'object' && (filter as ResolveSpecFilter).column !== 'tenant_id')
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
...payload,
|
||||||
|
options: {
|
||||||
|
...existingOptions,
|
||||||
|
filters: [...filters, { column: 'tenant_id', operator: 'eq', value: tenantID }]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeTags(value: unknown): string[] {
|
function normalizeTags(value: unknown): string[] {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
return value.map((tag) => String(tag).trim()).filter(Boolean);
|
return value.map((tag) => String(tag).trim()).filter(Boolean);
|
||||||
@@ -70,18 +127,29 @@ async function del(path: string): Promise<void> {
|
|||||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function patch<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
async function rsCall<T>(
|
async function rsCall<T>(
|
||||||
path: string,
|
path: string,
|
||||||
operation: 'read' | 'create' | 'update' | 'delete',
|
operation: 'read' | 'create' | 'update' | 'delete',
|
||||||
payload?: { data?: unknown; options?: unknown }
|
payload?: { data?: unknown; options?: unknown }
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
|
const scopedPayload = tenantScopedResolveSpecPayload(path, operation, payload);
|
||||||
const res = await fetch(path, {
|
const res = await fetch(path, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
operation,
|
operation,
|
||||||
...(payload?.data !== undefined ? { data: payload.data } : {}),
|
...(scopedPayload?.data !== undefined ? { data: scopedPayload.data } : {}),
|
||||||
...(payload?.options !== undefined ? { options: payload.options } : {})
|
...(scopedPayload?.options !== undefined ? { options: scopedPayload.options } : {})
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||||
@@ -333,6 +401,19 @@ export const api = {
|
|||||||
dry_run: input?.dry_run ?? false
|
dry_run: input?.dry_run ?? false
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
identity: {
|
||||||
|
get: () => get<import('./types').IdentityData>('/api/admin/identity'),
|
||||||
|
createTenant: (name: string) =>
|
||||||
|
post<import('./types').Tenant>('/api/admin/identity/tenants', { name }),
|
||||||
|
adoptLegacy: (id: string) =>
|
||||||
|
post<unknown>(`/api/admin/identity/tenants/${id}/adopt-legacy`, {}),
|
||||||
|
createUser: (data: { tenant_id: string; name: string; email: string }) =>
|
||||||
|
post<import('./types').TenantUser>('/api/admin/identity/users', data),
|
||||||
|
createKey: (data: { tenant_id: string; user_id?: string; description: string }) =>
|
||||||
|
post<{ key: import('./types').IdentityKey; secret: string }>('/api/admin/identity/keys', data),
|
||||||
|
updateKey: (id: string, data: { tenant_id: string; user_id?: string | null; description?: string; enabled?: boolean }) =>
|
||||||
|
patch<import('./types').IdentityKey>(`/api/admin/identity/keys/${id}`, data)
|
||||||
|
},
|
||||||
plans: {
|
plans: {
|
||||||
list: async (params?: { status?: string; priority?: string; project_id?: string; limit?: number }) => {
|
list: async (params?: { status?: string; priority?: string; project_id?: string; limit?: number }) => {
|
||||||
const filters: ResolveSpecFilter[] = [];
|
const filters: ResolveSpecFilter[] = [];
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { api } from '../../api';
|
||||||
|
import type { IdentityData, IdentityKey, Tenant, TenantUser } from '../../types';
|
||||||
|
import BooleanStatusBadge from '../shared/BooleanStatusBadge.svelte';
|
||||||
|
|
||||||
|
let identity = $state<IdentityData>({ tenants: [], users: [], keys: [] });
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state('');
|
||||||
|
let message = $state('');
|
||||||
|
let busy = $state(false);
|
||||||
|
let tenantName = $state('');
|
||||||
|
let userTenantID = $state('');
|
||||||
|
let userName = $state('');
|
||||||
|
let userEmail = $state('');
|
||||||
|
let keyTenantID = $state('');
|
||||||
|
let keyUserID = $state('');
|
||||||
|
let keyDescription = $state('');
|
||||||
|
let revealedSecret = $state<string | null>(null);
|
||||||
|
|
||||||
|
const usersForTenant = (tenantID: string): TenantUser[] =>
|
||||||
|
identity.users.filter((user) => user.tenant_id === tenantID);
|
||||||
|
|
||||||
|
function tenantNameFor(id: string): string {
|
||||||
|
return identity.tenants.find((tenant) => tenant.id === id)?.name ?? id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function userNameFor(id?: string): string {
|
||||||
|
if (!id) return 'Unassigned';
|
||||||
|
const user = identity.users.find((candidate) => candidate.id === id);
|
||||||
|
return user ? `${user.name} (${user.email})` : id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string): string {
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? '—' : date.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
identity = await api.identity.get();
|
||||||
|
if (!userTenantID && identity.tenants[0]) userTenantID = identity.tenants[0].id;
|
||||||
|
if (!keyTenantID && identity.tenants[0]) keyTenantID = identity.tenants[0].id;
|
||||||
|
} catch (cause) {
|
||||||
|
error = cause instanceof Error ? cause.message : 'Failed to load identity data.';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTenant() {
|
||||||
|
if (!tenantName.trim()) return;
|
||||||
|
busy = true; error = ''; message = '';
|
||||||
|
try {
|
||||||
|
const tenant = await api.identity.createTenant(tenantName.trim());
|
||||||
|
identity.tenants = [...identity.tenants, tenant];
|
||||||
|
userTenantID ||= tenant.id;
|
||||||
|
keyTenantID ||= tenant.id;
|
||||||
|
tenantName = '';
|
||||||
|
message = `Created tenant ${tenant.name}.`;
|
||||||
|
} catch (cause) {
|
||||||
|
error = cause instanceof Error ? cause.message : 'Failed to create tenant.';
|
||||||
|
} finally { busy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createUser() {
|
||||||
|
if (!userTenantID || !userName.trim() || !userEmail.trim()) return;
|
||||||
|
busy = true; error = ''; message = '';
|
||||||
|
try {
|
||||||
|
const user = await api.identity.createUser({ tenant_id: userTenantID, name: userName.trim(), email: userEmail.trim() });
|
||||||
|
identity.users = [...identity.users, user];
|
||||||
|
userName = ''; userEmail = '';
|
||||||
|
message = `Created user ${user.name}.`;
|
||||||
|
} catch (cause) {
|
||||||
|
error = cause instanceof Error ? cause.message : 'Failed to create user.';
|
||||||
|
} finally { busy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createKey() {
|
||||||
|
if (!keyTenantID || !keyDescription.trim()) return;
|
||||||
|
busy = true; error = ''; message = ''; revealedSecret = null;
|
||||||
|
try {
|
||||||
|
const result = await api.identity.createKey({
|
||||||
|
tenant_id: keyTenantID,
|
||||||
|
...(keyUserID ? { user_id: keyUserID } : {}),
|
||||||
|
description: keyDescription.trim()
|
||||||
|
});
|
||||||
|
identity.keys = [...identity.keys, result.key];
|
||||||
|
keyDescription = '';
|
||||||
|
revealedSecret = result.secret;
|
||||||
|
message = `Created key ${result.key.id}. Copy the secret now; it will not be shown again.`;
|
||||||
|
} catch (cause) {
|
||||||
|
error = cause instanceof Error ? cause.message : 'Failed to create key.';
|
||||||
|
} finally { busy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adoptLegacy(tenant: Tenant) {
|
||||||
|
if (!window.confirm(`Move all unassigned legacy data to ${tenant.name}? This cannot be undone from the UI.`)) return;
|
||||||
|
busy = true; error = ''; message = '';
|
||||||
|
try {
|
||||||
|
await api.identity.adoptLegacy(tenant.id);
|
||||||
|
message = `Moved legacy data to ${tenant.name}.`;
|
||||||
|
} catch (cause) {
|
||||||
|
error = cause instanceof Error ? cause.message : 'Failed to adopt legacy data.';
|
||||||
|
} finally { busy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateKey(key: IdentityKey, field: 'tenant' | 'user' | 'description' | 'enabled', value: string | boolean) {
|
||||||
|
busy = true; error = ''; message = '';
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
tenant_id: field === 'tenant' ? String(value) : key.tenant_id,
|
||||||
|
...(field === 'user' ? { user_id: value ? String(value) : null } : { user_id: key.user_id }),
|
||||||
|
...(field === 'description' ? { description: String(value) } : {}),
|
||||||
|
...(field === 'enabled' ? { enabled: Boolean(value) } : {})
|
||||||
|
};
|
||||||
|
const updated = await api.identity.updateKey(key.id, data);
|
||||||
|
identity.keys = identity.keys.map((candidate) => candidate.id === updated.id ? updated : candidate);
|
||||||
|
message = `Updated key ${updated.id}.`;
|
||||||
|
} catch (cause) {
|
||||||
|
error = cause instanceof Error ? cause.message : 'Failed to update key.';
|
||||||
|
} finally { busy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-semibold text-white">Identity</h2>
|
||||||
|
<p class="mt-1 text-sm text-slate-400">Group multiple API keys under a tenant, optionally assigning each key to a user.</p>
|
||||||
|
</div>
|
||||||
|
<button class="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-200 hover:bg-white/10" onclick={load} disabled={loading || busy}>Refresh</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}<div class="rounded-xl border border-rose-400/30 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{error}</div>{/if}
|
||||||
|
{#if message}<div class="rounded-xl border border-emerald-400/30 bg-emerald-400/10 px-4 py-3 text-sm text-emerald-100">{message}</div>{/if}
|
||||||
|
{#if revealedSecret}
|
||||||
|
<section class="rounded-2xl border border-amber-300/30 bg-amber-400/10 p-4">
|
||||||
|
<h3 class="font-semibold text-amber-100">New API key secret</h3>
|
||||||
|
<p class="mt-1 text-sm text-amber-50/80">Copy this value now. It is shown only once.</p>
|
||||||
|
<code class="mt-3 block overflow-x-auto rounded-lg bg-slate-950/80 p-3 text-sm text-amber-100">{revealedSecret}</code>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="grid gap-4 xl:grid-cols-3">
|
||||||
|
<form class="rounded-2xl border border-white/10 bg-slate-900/70 p-4" onsubmit={(event) => { event.preventDefault(); void createTenant(); }}>
|
||||||
|
<h3 class="font-semibold text-white">New tenant</h3>
|
||||||
|
<label class="mt-3 block text-sm text-slate-300" for="tenant-name">Tenant name</label>
|
||||||
|
<input id="tenant-name" class="mt-1 w-full rounded-lg border border-white/10 bg-slate-950 px-3 py-2 text-white" bind:value={tenantName} required />
|
||||||
|
<button class="mt-3 rounded-xl border border-cyan-300/30 bg-cyan-400/10 px-4 py-2 text-sm text-cyan-100 disabled:opacity-50" disabled={busy}>Create tenant</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form class="rounded-2xl border border-white/10 bg-slate-900/70 p-4" onsubmit={(event) => { event.preventDefault(); void createUser(); }}>
|
||||||
|
<h3 class="font-semibold text-white">New user</h3>
|
||||||
|
<label class="mt-3 block text-sm text-slate-300" for="user-tenant">Tenant</label>
|
||||||
|
<select id="user-tenant" class="mt-1 w-full rounded-lg border border-white/10 bg-slate-950 px-3 py-2 text-white" bind:value={userTenantID} required>
|
||||||
|
<option value="" disabled>Select a tenant</option>{#each identity.tenants as tenant}<option value={tenant.id}>{tenant.name}</option>{/each}
|
||||||
|
</select>
|
||||||
|
<label class="mt-3 block text-sm text-slate-300" for="user-name">Name</label><input id="user-name" class="mt-1 w-full rounded-lg border border-white/10 bg-slate-950 px-3 py-2 text-white" bind:value={userName} required />
|
||||||
|
<label class="mt-3 block text-sm text-slate-300" for="user-email">Email</label><input id="user-email" type="email" class="mt-1 w-full rounded-lg border border-white/10 bg-slate-950 px-3 py-2 text-white" bind:value={userEmail} required />
|
||||||
|
<button class="mt-3 rounded-xl border border-cyan-300/30 bg-cyan-400/10 px-4 py-2 text-sm text-cyan-100 disabled:opacity-50" disabled={busy || identity.tenants.length === 0}>Create user</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form class="rounded-2xl border border-white/10 bg-slate-900/70 p-4" onsubmit={(event) => { event.preventDefault(); void createKey(); }}>
|
||||||
|
<h3 class="font-semibold text-white">New managed key</h3>
|
||||||
|
<label class="mt-3 block text-sm text-slate-300" for="key-tenant">Tenant</label>
|
||||||
|
<select id="key-tenant" class="mt-1 w-full rounded-lg border border-white/10 bg-slate-950 px-3 py-2 text-white" bind:value={keyTenantID} onchange={() => { keyUserID = ''; }} required>
|
||||||
|
<option value="" disabled>Select a tenant</option>{#each identity.tenants as tenant}<option value={tenant.id}>{tenant.name}</option>{/each}
|
||||||
|
</select>
|
||||||
|
<label class="mt-3 block text-sm text-slate-300" for="key-user">User (optional)</label>
|
||||||
|
<select id="key-user" class="mt-1 w-full rounded-lg border border-white/10 bg-slate-950 px-3 py-2 text-white" bind:value={keyUserID}><option value="">Unassigned</option>{#each usersForTenant(keyTenantID) as user}<option value={user.id}>{user.name} ({user.email})</option>{/each}</select>
|
||||||
|
<label class="mt-3 block text-sm text-slate-300" for="key-description">Description</label><input id="key-description" class="mt-1 w-full rounded-lg border border-white/10 bg-slate-950 px-3 py-2 text-white" bind:value={keyDescription} placeholder="e.g. production deployer" required />
|
||||||
|
<button class="mt-3 rounded-xl border border-cyan-300/30 bg-cyan-400/10 px-4 py-2 text-sm text-cyan-100 disabled:opacity-50" disabled={busy || identity.tenants.length === 0}>Create key</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="rounded-2xl border border-dashed border-white/10 py-12 text-center text-slate-400">Loading identity data…</div>
|
||||||
|
{:else}
|
||||||
|
<section class="rounded-2xl border border-white/10 bg-slate-900/70 p-4">
|
||||||
|
<h3 class="font-semibold text-white">Tenants ({identity.tenants.length})</h3>
|
||||||
|
<p class="mt-1 text-sm text-slate-400">Adopting legacy data is explicit: it assigns every unassigned pre-tenancy record to one tenant.</p>
|
||||||
|
<div class="mt-3 overflow-x-auto"><table class="min-w-full text-sm"><thead class="text-left text-xs uppercase tracking-wider text-slate-500"><tr><th class="pb-2 pr-4">Name</th><th class="pb-2 pr-4">Users</th><th class="pb-2 pr-4">Created</th><th class="pb-2"></th></tr></thead><tbody class="divide-y divide-white/5">{#each identity.tenants as tenant}<tr><td class="py-3 pr-4 font-medium text-white">{tenant.name}<div class="mt-1 font-mono text-xs text-slate-500">{tenant.id}</div></td><td class="py-3 pr-4 text-slate-300">{usersForTenant(tenant.id).length}</td><td class="py-3 pr-4 text-slate-400">{formatDate(tenant.created_at)}</td><td class="py-3 text-right"><button class="rounded-lg border border-amber-300/30 bg-amber-400/10 px-3 py-1.5 text-xs text-amber-100 hover:bg-amber-400/20 disabled:opacity-50" onclick={() => void adoptLegacy(tenant)} disabled={busy}>Adopt legacy data</button></td></tr>{:else}<tr><td class="py-5 text-slate-500" colspan="4">No tenants yet.</td></tr>{/each}</tbody></table></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="rounded-2xl border border-white/10 bg-slate-900/70 p-4">
|
||||||
|
<h3 class="font-semibold text-white">API keys ({identity.keys.length})</h3>
|
||||||
|
<p class="mt-1 text-sm text-slate-400">Configured keys can be reassigned here; their secret values are never displayed.</p>
|
||||||
|
<div class="mt-3 overflow-x-auto"><table class="min-w-[860px] w-full text-sm"><thead class="text-left text-xs uppercase tracking-wider text-slate-500"><tr><th class="pb-2 pr-4">Key</th><th class="pb-2 pr-4">Tenant</th><th class="pb-2 pr-4">User</th><th class="pb-2 pr-4">Description</th><th class="pb-2 pr-4">Status</th><th class="pb-2">Created</th></tr></thead><tbody class="divide-y divide-white/5">{#each identity.keys as key}<tr><td class="py-3 pr-4"><code class="text-xs text-cyan-100">{key.id}</code><div class="mt-1 text-xs text-slate-500">{key.source}</div></td><td class="py-3 pr-4"><select aria-label={`Tenant for ${key.id}`} class="w-full rounded-lg border border-white/10 bg-slate-950 px-2 py-1.5 text-slate-200" value={key.tenant_id} onchange={(event) => void updateKey(key, 'tenant', event.currentTarget.value)} disabled={busy}>{#each identity.tenants as tenant}<option value={tenant.id}>{tenant.name}</option>{/each}</select></td><td class="py-3 pr-4"><select aria-label={`User for ${key.id}`} class="w-full rounded-lg border border-white/10 bg-slate-950 px-2 py-1.5 text-slate-200" value={key.user_id ?? ''} onchange={(event) => void updateKey(key, 'user', event.currentTarget.value)} disabled={busy}><option value="">Unassigned</option>{#each usersForTenant(key.tenant_id) as user}<option value={user.id}>{user.name}</option>{/each}</select></td><td class="py-3 pr-4"><input aria-label={`Description for ${key.id}`} class="w-full rounded-lg border border-white/10 bg-slate-950 px-2 py-1.5 text-slate-200" value={key.description} onchange={(event) => void updateKey(key, 'description', event.currentTarget.value)} disabled={busy} /></td><td class="py-3 pr-4"><label class="flex items-center gap-2 text-slate-300"><input type="checkbox" class="accent-cyan-400" checked={key.enabled} onchange={(event) => void updateKey(key, 'enabled', event.currentTarget.checked)} disabled={busy} />{#if key.enabled}Enabled{:else}<BooleanStatusBadge value={key.enabled} falseLabel="Disabled" />{/if}</label></td><td class="py-3 text-slate-400">{formatDate(key.created_at)}</td></tr>{:else}<tr><td class="py-5 text-slate-500" colspan="6">No keys available.</td></tr>{/each}</tbody></table></div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
type Props = {
|
||||||
|
value: boolean;
|
||||||
|
falseLabel: 'Disabled' | 'Inactive';
|
||||||
|
};
|
||||||
|
|
||||||
|
let { value, falseLabel }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if !value}
|
||||||
|
<span class="inline-flex rounded-full bg-slate-700 px-2 py-0.5 text-xs font-medium text-slate-200">
|
||||||
|
{falseLabel}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
@@ -10,7 +10,10 @@
|
|||||||
import ProjectsPage from '../projects/ProjectsPage.svelte';
|
import ProjectsPage from '../projects/ProjectsPage.svelte';
|
||||||
import SkillsPage from '../skills/SkillsPage.svelte';
|
import SkillsPage from '../skills/SkillsPage.svelte';
|
||||||
import ThoughtsPage from '../thoughts/ThoughtsPage.svelte';
|
import ThoughtsPage from '../thoughts/ThoughtsPage.svelte';
|
||||||
|
import IdentityPage from '../identity/IdentityPage.svelte';
|
||||||
import AppSidebar from './AppSidebar.svelte';
|
import AppSidebar from './AppSidebar.svelte';
|
||||||
|
import { fromStore } from 'svelte/store';
|
||||||
|
import { selectedTenantID } from '../../tenantScope';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
currentPage,
|
currentPage,
|
||||||
@@ -29,14 +32,19 @@
|
|||||||
onnavigate: (page: ShellPage) => void;
|
onnavigate: (page: ShellPage) => void;
|
||||||
onrefresh: () => void;
|
onrefresh: () => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
const tenantScope = fromStore(selectedTenantID);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="grid min-h-screen lg:grid-cols-[17rem_1fr]">
|
<div class="grid min-h-screen lg:grid-cols-[17rem_1fr]">
|
||||||
<AppSidebar {currentPage} {onnavigate} {onlogout} />
|
<AppSidebar {currentPage} {onnavigate} {onlogout} />
|
||||||
|
|
||||||
<main class="px-4 py-6 sm:px-6 lg:px-8">
|
<main class="px-4 py-6 sm:px-6 lg:px-8">
|
||||||
|
{#key tenantScope.current}
|
||||||
{#if currentPage === 'dashboard'}
|
{#if currentPage === 'dashboard'}
|
||||||
<DashboardPage {data} {loading} {error} {onrefresh} />
|
<DashboardPage {data} {loading} {error} {onrefresh} />
|
||||||
|
{:else if currentPage === 'identity'}
|
||||||
|
<IdentityPage />
|
||||||
{:else if currentPage === 'projects'}
|
{:else if currentPage === 'projects'}
|
||||||
<ProjectsPage />
|
<ProjectsPage />
|
||||||
{:else if currentPage === 'thoughts'}
|
{:else if currentPage === 'thoughts'}
|
||||||
@@ -56,5 +64,6 @@
|
|||||||
{:else if currentPage === 'maintenance'}
|
{:else if currentPage === 'maintenance'}
|
||||||
<MaintenancePage />
|
<MaintenancePage />
|
||||||
{/if}
|
{/if}
|
||||||
|
{/key}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import type { NavItem, ShellPage } from '../../types';
|
import type { NavItem, ShellPage } from '../../types';
|
||||||
|
import { api } from '../../api';
|
||||||
|
import { selectedTenantID, setSelectedTenantID } from '../../tenantScope';
|
||||||
|
|
||||||
|
let tenants = $state<{ id: string; name: string }[]>([]);
|
||||||
|
let tenantError = $state('');
|
||||||
|
let tenantLoading = $state(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
currentPage,
|
currentPage,
|
||||||
@@ -13,6 +20,7 @@
|
|||||||
|
|
||||||
const navItems: NavItem[] = [
|
const navItems: NavItem[] = [
|
||||||
{ id: 'dashboard', label: 'Dashboard', description: 'System overview and status.' },
|
{ id: 'dashboard', label: 'Dashboard', description: 'System overview and status.' },
|
||||||
|
{ id: 'identity', label: 'Identity', description: 'Tenants, users, and API keys.' },
|
||||||
{ id: 'projects', label: 'Projects', description: 'Browse and manage projects.' },
|
{ id: 'projects', label: 'Projects', description: 'Browse and manage projects.' },
|
||||||
{ id: 'thoughts', label: 'Thoughts', description: 'Search and inspect thoughts.' },
|
{ id: 'thoughts', label: 'Thoughts', description: 'Search and inspect thoughts.' },
|
||||||
{ id: 'learnings', label: 'Learnings', description: 'Curated insights and outcomes.' },
|
{ id: 'learnings', label: 'Learnings', description: 'Curated insights and outcomes.' },
|
||||||
@@ -23,6 +31,25 @@
|
|||||||
{ id: 'files', label: 'Files', description: 'Stored file inventory.' },
|
{ id: 'files', label: 'Files', description: 'Stored file inventory.' },
|
||||||
{ id: 'maintenance', label: 'Maintenance', description: 'Task state and upkeep actions.' }
|
{ id: 'maintenance', label: 'Maintenance', description: 'Task state and upkeep actions.' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
async function loadTenants(): Promise<void> {
|
||||||
|
tenantLoading = true;
|
||||||
|
tenantError = '';
|
||||||
|
try {
|
||||||
|
tenants = (await api.identity.get()).tenants;
|
||||||
|
if ($selectedTenantID && !tenants.some((tenant) => tenant.id === $selectedTenantID)) {
|
||||||
|
setSelectedTenantID('');
|
||||||
|
}
|
||||||
|
} catch (cause) {
|
||||||
|
tenantError = cause instanceof Error ? cause.message : 'Failed to load tenants.';
|
||||||
|
} finally {
|
||||||
|
tenantLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
void loadTenants();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<aside class="border-r border-white/10 bg-slate-900/90 p-6">
|
<aside class="border-r border-white/10 bg-slate-900/90 p-6">
|
||||||
@@ -32,6 +59,26 @@
|
|||||||
<p class="mt-2 text-sm text-slate-400">Memory server control panel.</p>
|
<p class="mt-2 text-sm text-slate-400">Memory server control panel.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 rounded-2xl border border-white/10 bg-slate-950/40 p-3">
|
||||||
|
<label class="block text-xs font-semibold uppercase tracking-wider text-slate-400" for="tenant-selector">Tenant scope</label>
|
||||||
|
<select
|
||||||
|
id="tenant-selector"
|
||||||
|
class="mt-2 w-full rounded-lg border border-white/10 bg-slate-950 px-3 py-2 text-sm text-slate-100 disabled:opacity-50"
|
||||||
|
value={$selectedTenantID}
|
||||||
|
onchange={(event) => setSelectedTenantID(event.currentTarget.value)}
|
||||||
|
disabled={tenantLoading}
|
||||||
|
>
|
||||||
|
<option value="">Default key tenant</option>
|
||||||
|
{#each tenants as tenant}
|
||||||
|
<option value={tenant.id}>{tenant.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<p class="mt-2 text-xs text-slate-500">Applies to tenant data in this admin session.</p>
|
||||||
|
{#if tenantError}
|
||||||
|
<p class="mt-2 text-xs text-rose-300">{tenantError}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<nav class="mt-8 space-y-1">
|
<nav class="mt-8 space-y-1">
|
||||||
{#each navItems as item}
|
{#each navItems as item}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -576,7 +576,6 @@
|
|||||||
adapter={projectBoxerAdapter}
|
adapter={projectBoxerAdapter}
|
||||||
value={state.values?.project_id ?? null}
|
value={state.values?.project_id ?? null}
|
||||||
clearable
|
clearable
|
||||||
searchable
|
|
||||||
onChange={(v) => state.setState('values', { ...state.values, project_id: v || undefined })}
|
onChange={(v) => state.setState('values', { ...state.values, project_id: v || undefined })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import './app.css';
|
import './app.css';
|
||||||
import App from './App.svelte';
|
import App from './App.svelte';
|
||||||
import { mount } from 'svelte';
|
import { mount } from 'svelte';
|
||||||
|
import { installTenantScopedFetch } from './tenantScope';
|
||||||
|
|
||||||
|
installTenantScopedFetch();
|
||||||
|
|
||||||
const app = mount(App, {
|
const app = mount(App, {
|
||||||
target: document.getElementById('app')!
|
target: document.getElementById('app')!
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { get, writable } from 'svelte/store';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'amcs.admin.selected-tenant-id';
|
||||||
|
const TENANT_HEADER = 'X-AMCS-Tenant-ID';
|
||||||
|
let tenantScopedFetchInstalled = false;
|
||||||
|
|
||||||
|
function initialTenantID(): string {
|
||||||
|
if (typeof window === 'undefined') return '';
|
||||||
|
return window.localStorage.getItem(STORAGE_KEY)?.trim() ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const selectedTenantID = writable<string>(initialTenantID());
|
||||||
|
|
||||||
|
export function setSelectedTenantID(tenantID: string): void {
|
||||||
|
const normalized = tenantID.trim();
|
||||||
|
selectedTenantID.set(normalized);
|
||||||
|
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
if (normalized) {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, normalized);
|
||||||
|
} else {
|
||||||
|
window.localStorage.removeItem(STORAGE_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tenantScopeHeaders(): Record<string, string> {
|
||||||
|
const tenantID = currentTenantID();
|
||||||
|
return tenantID ? { [TENANT_HEADER]: tenantID } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function currentTenantID(): string {
|
||||||
|
return get(selectedTenantID).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldScopeRequest(input: RequestInfo | URL): boolean {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
|
||||||
|
const rawURL = input instanceof Request ? input.url : input.toString();
|
||||||
|
const url = new URL(rawURL, window.location.origin);
|
||||||
|
return url.origin === window.location.origin && (url.pathname.startsWith('/api/rs') || url.pathname.startsWith('/api/admin'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gridler and Former perform their own fetches. Scope those requests here so
|
||||||
|
// their ResolveSpec calls use the same tenant as the rest of the admin UI.
|
||||||
|
export function installTenantScopedFetch(): void {
|
||||||
|
if (typeof window === 'undefined' || tenantScopedFetchInstalled) return;
|
||||||
|
|
||||||
|
const baseFetch = window.fetch.bind(window);
|
||||||
|
|
||||||
|
async function scopedFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||||
|
const headersToAdd = tenantScopeHeaders();
|
||||||
|
if (!Object.keys(headersToAdd).length || !shouldScopeRequest(input)) {
|
||||||
|
return baseFetch(input, init);
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = new Headers(input instanceof Request ? input.headers : undefined);
|
||||||
|
if (init?.headers) {
|
||||||
|
new Headers(init.headers).forEach((value, name) => headers.set(name, value));
|
||||||
|
}
|
||||||
|
Object.entries(headersToAdd).forEach(([name, value]) => headers.set(name, value));
|
||||||
|
return baseFetch(input, { ...init, headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
window.fetch = scopedFetch;
|
||||||
|
tenantScopedFetchInstalled = true;
|
||||||
|
}
|
||||||
+31
-1
@@ -66,7 +66,7 @@ export type NavItem = {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ShellPage = 'dashboard' | 'projects' | 'thoughts' | 'learnings' | 'plans' | 'skills' | 'guardrails' | 'personas' | 'files' | 'maintenance';
|
export type ShellPage = 'dashboard' | 'identity' | 'projects' | 'thoughts' | 'learnings' | 'plans' | 'skills' | 'guardrails' | 'personas' | 'files' | 'maintenance';
|
||||||
|
|
||||||
export type Project = {
|
export type Project = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -295,3 +295,33 @@ export type MetadataRetryResult = {
|
|||||||
failed: number;
|
failed: number;
|
||||||
dry_run: boolean;
|
dry_run: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type Tenant = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TenantUser = {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IdentityKey = {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
user_id?: string;
|
||||||
|
description: string;
|
||||||
|
source: 'configured' | 'managed';
|
||||||
|
enabled: boolean;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IdentityData = {
|
||||||
|
tenants: Tenant[];
|
||||||
|
users: TenantUser[];
|
||||||
|
keys: IdentityKey[];
|
||||||
|
};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user