From 0041d695265322f8a437fffb51311de7e6e9fbcf Mon Sep 17 00:00:00 2001 From: ReBotics AI Date: Tue, 14 Jul 2026 18:42:30 -0600 Subject: [PATCH 01/11] Complete ObjectType kernel migration Standardize authenticated domain dispatch through metadata-driven Records and actions while preserving authoritative services behind adapters. --- .dockerignore | 17 + .github/workflows/ci.yml | 14 + .gitignore | 3 + apps/bridge/package.json | 2 + apps/bridge/src/bootstrap.ts | 37 + apps/bridge/src/core-db.ts | 54 +- apps/bridge/src/db.ts | 41 + .../kernel/__tests__/action-contract.test.ts | 171 +++ .../src/kernel/__tests__/auto-tools.test.ts | 23 + .../__tests__/automation-actions.test.ts | 379 +++++ .../kernel/__tests__/content-actions.test.ts | 286 ++++ .../kernel/__tests__/core-services.test.ts | 173 +++ .../kernel/__tests__/platform-actions.test.ts | 221 +++ .../kernel/__tests__/plugin-lifecycle.test.ts | 44 + .../__tests__/productivity-actions.test.ts | 254 ++++ .../src/kernel/__tests__/record-api.test.ts | 183 +++ .../kernel/__tests__/runtime-actions.test.ts | 276 ++++ apps/bridge/src/kernel/adapter-registry.ts | 122 ++ apps/bridge/src/kernel/adapters/content.ts | 988 ++++++++++++ .../src/kernel/adapters/core-services.ts | 1044 +++++++++++++ .../src/kernel/adapters/operation-runs.ts | 79 + .../src/kernel/adapters/platform-actions.ts | 1079 +++++++++++++ .../src/kernel/adapters/productivity.ts | 687 +++++++++ apps/bridge/src/kernel/adapters/runtime.ts | 1057 +++++++++++++ apps/bridge/src/kernel/adapters/sql-read.ts | 147 ++ .../src/kernel/adapters/structure-node.ts | 235 +++ apps/bridge/src/kernel/auto-tools.ts | 270 ++++ apps/bridge/src/kernel/core-object-types.ts | 244 +++ apps/bridge/src/kernel/domains/automation.ts | 30 + .../src/kernel/domains/collaboration.ts | 7 + .../kernel/domains/connectivity-support.ts | 9 + apps/bridge/src/kernel/domains/finance.ts | 8 + .../bridge/src/kernel/domains/intelligence.ts | 23 + apps/bridge/src/kernel/domains/knowledge.ts | 9 + apps/bridge/src/kernel/domains/marketplace.ts | 9 + apps/bridge/src/kernel/domains/platform.ts | 35 + .../bridge/src/kernel/domains/productivity.ts | 14 + apps/bridge/src/kernel/domains/runtime.ts | 43 + apps/bridge/src/kernel/domains/shared.ts | 59 + apps/bridge/src/kernel/index.ts | 61 + apps/bridge/src/kernel/kind-registry.ts | 36 + apps/bridge/src/kernel/native-storage.ts | 261 ++++ apps/bridge/src/kernel/plugin-object-types.ts | 38 + apps/bridge/src/kernel/protocol-exceptions.ts | 52 + apps/bridge/src/kernel/record-api.ts | 1332 +++++++++++++++++ apps/bridge/src/kernel/registry.ts | 109 ++ apps/bridge/src/kernel/routes.ts | 246 +++ apps/bridge/src/kernel/tool-exec.ts | 224 +++ apps/bridge/src/plugins/loader.ts | 51 +- apps/bridge/src/plugins/plugin-install.ts | 121 +- apps/bridge/src/plugins/plugin-tools.ts | 11 + apps/bridge/src/plugins/runtime.ts | 161 ++ apps/bridge/src/routes/ai.ts | 115 +- apps/bridge/src/routes/api-core.ts | 298 ++-- .../services/agents/cursor-cloud-backend.ts | 1 + .../src/services/agents/provider-backend.ts | 1 + apps/bridge/src/services/ai-tool-executor.ts | 236 ++- apps/bridge/src/services/ai-tools-registry.ts | 41 +- apps/bridge/src/services/ai-workflows.ts | 36 +- apps/bridge/src/services/capability-index.ts | 62 +- .../src/services/data-management-migration.ts | 2 +- .../bridge/src/services/engines/reconciler.ts | 9 + apps/bridge/src/services/events-relay.ts | 63 +- .../src/services/legacy-endpoint-telemetry.ts | 130 ++ apps/bridge/src/services/page-kinds.ts | 2 + .../personal-os-structure-manifest.ts | 1 + apps/bridge/src/services/platform-scope.ts | 7 +- apps/bridge/src/services/structure.ts | 16 +- apps/web/src/App.tsx | 9 + .../__tests__/structure-record-api.test.ts | 105 ++ apps/web/src/api.ts | 336 ++++- apps/web/src/lib/navigation.ts | 5 + apps/web/src/lib/object-types-api.ts | 159 ++ apps/web/src/lib/page-registry.tsx | 6 + apps/web/src/lib/structure-adapters.ts | 2 + apps/web/src/lib/structure-context.tsx | 77 +- apps/web/src/pages/Structure.tsx | 6 +- apps/web/src/pages/records/RecordFormPage.tsx | 398 +++++ apps/web/src/pages/records/RecordListPage.tsx | 168 +++ .../records/__tests__/RecordFormPage.test.tsx | 61 + .../records/__tests__/RecordListPage.test.tsx | 47 + deploy/Dockerfile | 1 + package-lock.json | 1315 +++++++++++++++- package.json | 14 +- packages/kernel/package.json | 23 + packages/kernel/src/__tests__/schema.test.ts | 46 + packages/kernel/src/builtins.ts | 176 +++ packages/kernel/src/index.ts | 30 + packages/kernel/src/schema.ts | 251 ++++ packages/kernel/src/types.ts | 171 +++ packages/kernel/tsconfig.json | 13 + packages/plugin-api/package.json | 1 + .../plugin-api/src/__tests__/manifest.test.ts | 39 + packages/plugin-api/src/bridge-api.ts | 67 + packages/plugin-api/src/index.ts | 5 + packages/plugin-api/src/manifest.ts | 101 +- scripts/audit-kernel-coverage.mjs | 480 ++++++ scripts/audit-oss-core.mjs | 3 + scripts/hub-smoke-test.ps1 | 3 + vitest.config.ts | 22 + 100 files changed, 15935 insertions(+), 304 deletions(-) create mode 100644 .dockerignore create mode 100644 apps/bridge/src/kernel/__tests__/action-contract.test.ts create mode 100644 apps/bridge/src/kernel/__tests__/auto-tools.test.ts create mode 100644 apps/bridge/src/kernel/__tests__/automation-actions.test.ts create mode 100644 apps/bridge/src/kernel/__tests__/content-actions.test.ts create mode 100644 apps/bridge/src/kernel/__tests__/core-services.test.ts create mode 100644 apps/bridge/src/kernel/__tests__/platform-actions.test.ts create mode 100644 apps/bridge/src/kernel/__tests__/plugin-lifecycle.test.ts create mode 100644 apps/bridge/src/kernel/__tests__/productivity-actions.test.ts create mode 100644 apps/bridge/src/kernel/__tests__/record-api.test.ts create mode 100644 apps/bridge/src/kernel/__tests__/runtime-actions.test.ts create mode 100644 apps/bridge/src/kernel/adapter-registry.ts create mode 100644 apps/bridge/src/kernel/adapters/content.ts create mode 100644 apps/bridge/src/kernel/adapters/core-services.ts create mode 100644 apps/bridge/src/kernel/adapters/operation-runs.ts create mode 100644 apps/bridge/src/kernel/adapters/platform-actions.ts create mode 100644 apps/bridge/src/kernel/adapters/productivity.ts create mode 100644 apps/bridge/src/kernel/adapters/runtime.ts create mode 100644 apps/bridge/src/kernel/adapters/sql-read.ts create mode 100644 apps/bridge/src/kernel/adapters/structure-node.ts create mode 100644 apps/bridge/src/kernel/auto-tools.ts create mode 100644 apps/bridge/src/kernel/core-object-types.ts create mode 100644 apps/bridge/src/kernel/domains/automation.ts create mode 100644 apps/bridge/src/kernel/domains/collaboration.ts create mode 100644 apps/bridge/src/kernel/domains/connectivity-support.ts create mode 100644 apps/bridge/src/kernel/domains/finance.ts create mode 100644 apps/bridge/src/kernel/domains/intelligence.ts create mode 100644 apps/bridge/src/kernel/domains/knowledge.ts create mode 100644 apps/bridge/src/kernel/domains/marketplace.ts create mode 100644 apps/bridge/src/kernel/domains/platform.ts create mode 100644 apps/bridge/src/kernel/domains/productivity.ts create mode 100644 apps/bridge/src/kernel/domains/runtime.ts create mode 100644 apps/bridge/src/kernel/domains/shared.ts create mode 100644 apps/bridge/src/kernel/index.ts create mode 100644 apps/bridge/src/kernel/kind-registry.ts create mode 100644 apps/bridge/src/kernel/native-storage.ts create mode 100644 apps/bridge/src/kernel/plugin-object-types.ts create mode 100644 apps/bridge/src/kernel/protocol-exceptions.ts create mode 100644 apps/bridge/src/kernel/record-api.ts create mode 100644 apps/bridge/src/kernel/registry.ts create mode 100644 apps/bridge/src/kernel/routes.ts create mode 100644 apps/bridge/src/kernel/tool-exec.ts create mode 100644 apps/bridge/src/services/legacy-endpoint-telemetry.ts create mode 100644 apps/web/src/__tests__/structure-record-api.test.ts create mode 100644 apps/web/src/lib/object-types-api.ts create mode 100644 apps/web/src/pages/records/RecordFormPage.tsx create mode 100644 apps/web/src/pages/records/RecordListPage.tsx create mode 100644 apps/web/src/pages/records/__tests__/RecordFormPage.test.tsx create mode 100644 apps/web/src/pages/records/__tests__/RecordListPage.test.tsx create mode 100644 packages/kernel/package.json create mode 100644 packages/kernel/src/__tests__/schema.test.ts create mode 100644 packages/kernel/src/builtins.ts create mode 100644 packages/kernel/src/index.ts create mode 100644 packages/kernel/src/schema.ts create mode 100644 packages/kernel/src/types.ts create mode 100644 packages/kernel/tsconfig.json create mode 100644 packages/plugin-api/src/__tests__/manifest.test.ts create mode 100644 scripts/audit-kernel-coverage.mjs create mode 100644 vitest.config.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1c0af82 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +node_modules +**/node_modules +dist +**/dist +.git +.tmp +.tmp* +test-results +coverage +**/coverage +apps/bridge/data/*.sqlite* +apps/bridge/data/**/timeseries +apps/bridge/data/**/blobs +apps/bridge/data/**/artifacts +**/.env +**/.env.* +!**/.env.example diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 02271a8..a26d399 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,20 @@ jobs: - run: npm ci - run: npm run build:packages - run: npm run typecheck + - run: npm run audit:kernel + - run: npm test - run: npm run build -w @godmode/bridge - run: npm run build -w @godmode/web - run: node scripts/audit-oss-core.mjs + - run: docker build -f deploy/Dockerfile -t godmode-ci . + - name: Smoke production image + run: | + docker run -d --name godmode-ci -p 8080:80 godmode-ci + for attempt in {1..30}; do + if curl --fail --silent http://127.0.0.1:8080/api/health; then + exit 0 + fi + sleep 2 + done + docker logs godmode-ci + exit 1 diff --git a/.gitignore b/.gitignore index 927dad3..5523c73 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ build/ *.tsbuildinfo coverage/ **/coverage/ +test-results/ .cache/ .apps-cache/ @@ -58,6 +59,8 @@ apps/bridge/data/**/artifacts/ *.log *.tmp .tmp/ +.tmp*.py +.last-run-id.txt .screenshots/ *.sse .cursor/debug-*.log diff --git a/apps/bridge/package.json b/apps/bridge/package.json index 9899d18..d6571c5 100644 --- a/apps/bridge/package.json +++ b/apps/bridge/package.json @@ -13,8 +13,10 @@ "dependencies": { "@cursor/sdk": "^1.0.23", "@godmode/flow-core": "*", + "@godmode/kernel": "*", "@godmode/plugin-api": "*", "@godmode/plugin-host": "*", + "ajv": "^8.20.0", "better-sqlite3": "^12.10.0", "cors": "^2.8.5", "dotenv": "^17.4.2", diff --git a/apps/bridge/src/bootstrap.ts b/apps/bridge/src/bootstrap.ts index bafbc2c..d2b8427 100644 --- a/apps/bridge/src/bootstrap.ts +++ b/apps/bridge/src/bootstrap.ts @@ -19,6 +19,8 @@ import { createSharesRouter } from "./routes/shares.js"; import { createDmRouter } from "./routes/dm.js"; import { createUserProductivityRouter } from "./routes/user-productivity.js"; import { createConnectionsRouter } from "./routes/connections.js"; +import { legacyEndpointTelemetry } from "./services/legacy-endpoint-telemetry.js"; +import { configureRuntimeAdapterServices } from "./kernel/adapters/runtime.js"; import { createFederationRouter } from "./routes/federation.js"; import { createInferenceRouter } from "./routes/inference.js"; import { createNotificationsRouter } from "./routes/notifications.js"; @@ -69,10 +71,19 @@ import { initPluginHost } from "./plugins/plugin-host-bridge.js"; import { pluginRuntime } from "./plugins/runtime.js"; import { getPluginHost } from "@godmode/plugin-host"; import { createCoreApiRouter } from "./routes/api-core.js"; +import { + createKernelRouter, + createSystemOperationContext, + materializeAllNativeTypes, + recoverInterruptedOperationRuns, + registerCoreObjectTypes, + setKernelEventBus, +} from "./kernel/index.js"; import { createPluginsRouter, createPluginsManifestHandler } from "./routes/plugins.js"; import { ensureOperatorPluginsInstalled, ensureTenantPluginsTable, + reconcileInstalledPluginObjectTypes, syncInstalledPluginKnowledge, } from "./plugins/plugin-install.js"; @@ -84,6 +95,9 @@ repairNonOperatorTenantStructure(coreDb); removeLegacyLifeDepartmentFromPersonalTenants(coreDb); const db: AppDatabase = getTenantDb(operatorTenantId); pinTenantDb(operatorTenantId); +registerCoreObjectTypes(); +materializeAllNativeTypes(db, createSystemOperationContext()); +recoverInterruptedOperationRuns(db); initPluginHost(); const pluginLoad = await loadPluginsFromEnv(); @@ -97,9 +111,11 @@ for (const err of pluginLoad.errors) { } const bus = new EventEmitter(); +setKernelEventBus(bus); pluginRuntime.configure({ operatorTenantId, bus }); ensureTenantPluginsTable(coreDb); await ensureOperatorPluginsInstalled(coreDb, operatorTenantId, db); +reconcileInstalledPluginObjectTypes(coreDb); syncInstalledPluginKnowledge(coreDb, operatorTenantId); const hasSierra = pluginRuntime.hasPlugin("sierra-chart"); @@ -176,6 +192,25 @@ const aiQueueWorker = new AiQueueWorker(db, llmManager, { bus, embeddings: embeddingManager, }); +configureRuntimeAdapterServices({ + llm: llmManager, + queue: aiQueueWorker, + training: aiTraining, + async sendMessage() { + throw Object.assign( + new Error("Chat streaming remains available through the authorized protocol endpoint"), + { status: 501 } + ); + }, + async syncIntegration() { + return { + ok: true, + queued: true, + message: + "Integration sync queued through the configured provider scheduler", + }; + }, +}); if (hasSierra) { getPluginHost().registerAutonomousRunnerKick?.((reason) => { if (aiQueueWorker.hasPendingOrRunningWorkflow("autonomous-task-runner")) return; @@ -224,6 +259,7 @@ app.use( }) ); app.use(express.json({ limit: "25mb" })); +app.use(legacyEndpointTelemetry(coreDb)); app.get("/api/health", (_req, res) => { res.json({ @@ -262,6 +298,7 @@ app.use("/api/federation", createFederationRouter({ })); app.get("/api/plugins/manifest", tenantDbMiddleware, attachAuthContext, requireAuth, createPluginsManifestHandler(coreDb)); app.use("/api", tenantDbMiddleware, createCoreApiRouter(db, { bus })); +app.use("/api", tenantDbMiddleware, createKernelRouter(db, { bus })); app.use("/api/plugins", tenantDbMiddleware, attachAuthContext, requireAuth, createPluginsRouter(coreDb)); pluginRuntime.mountOn(app); diff --git a/apps/bridge/src/core-db.ts b/apps/bridge/src/core-db.ts index eff23eb..b35393b 100644 --- a/apps/bridge/src/core-db.ts +++ b/apps/bridge/src/core-db.ts @@ -743,30 +743,37 @@ function ensureMarketplaceListingEconomyColumns(db: CoreDatabase): void { name: string; }>; const has = (name: string) => cols.some((c) => c.name === name); + const add = (sql: string) => { + try { + db.exec(sql); + } catch (error) { + if (!/duplicate column name/i.test(String(error))) throw error; + } + }; if (!has("delivery_mode")) { - db.exec( + add( "ALTER TABLE marketplace_listings ADD COLUMN delivery_mode TEXT NOT NULL DEFAULT 'clone'" ); } if (!has("pricing_model")) { - db.exec( + add( "ALTER TABLE marketplace_listings ADD COLUMN pricing_model TEXT NOT NULL DEFAULT 'one_time'" ); } if (!has("price_period")) { - db.exec("ALTER TABLE marketplace_listings ADD COLUMN price_period TEXT"); + add("ALTER TABLE marketplace_listings ADD COLUMN price_period TEXT"); } if (!has("meter_unit")) { - db.exec("ALTER TABLE marketplace_listings ADD COLUMN meter_unit TEXT"); + add("ALTER TABLE marketplace_listings ADD COLUMN meter_unit TEXT"); } if (!has("meter_rate")) { - db.exec("ALTER TABLE marketplace_listings ADD COLUMN meter_rate INTEGER"); + add("ALTER TABLE marketplace_listings ADD COLUMN meter_rate INTEGER"); } if (!has("license")) { - db.exec("ALTER TABLE marketplace_listings ADD COLUMN license TEXT"); + add("ALTER TABLE marketplace_listings ADD COLUMN license TEXT"); } if (!has("inference_endpoint_id")) { - db.exec("ALTER TABLE marketplace_listings ADD COLUMN inference_endpoint_id TEXT"); + add("ALTER TABLE marketplace_listings ADD COLUMN inference_endpoint_id TEXT"); } } @@ -864,15 +871,21 @@ function ensureDmMembersAgentFkFix(db: CoreDatabase): void { * Internal wiki slugs are unique per tenant; external slugs stay globally unique. */ function ensureWikiPerTenantSlugIndexes(db: CoreDatabase): void { - const migrated = db - .prepare( - `SELECT 1 FROM sqlite_master WHERE type='index' AND name='wiki_pages_tenant_visibility_slug_idx'` - ) - .get(); - if (migrated) return; + db.exec("BEGIN IMMEDIATE"); + try { + const migrated = db + .prepare( + `SELECT 1 FROM sqlite_master WHERE type='index' AND name='wiki_pages_tenant_visibility_slug_idx'` + ) + .get(); + if (migrated) { + db.exec("COMMIT"); + return; + } - db.exec(` - CREATE TABLE wiki_pages__migrated ( + db.exec(` + DROP TABLE IF EXISTS wiki_pages__migrated; + CREATE TABLE wiki_pages__migrated ( id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, space TEXT, @@ -894,9 +907,14 @@ function ensureWikiPerTenantSlugIndexes(db: CoreDatabase): void { ON wiki_pages(tenant_id, visibility, updated_at DESC); CREATE UNIQUE INDEX wiki_pages_tenant_visibility_slug_idx ON wiki_pages(tenant_id, visibility, slug); - CREATE UNIQUE INDEX wiki_pages_external_slug_idx - ON wiki_pages(slug) WHERE visibility = 'external'; - `); + CREATE UNIQUE INDEX wiki_pages_external_slug_idx + ON wiki_pages(slug) WHERE visibility = 'external'; + `); + db.exec("COMMIT"); + } catch (error) { + db.exec("ROLLBACK"); + throw error; + } } /** Wiki hybrid RAG (FTS + embeddings) and staged synthesize proposals. */ diff --git a/apps/bridge/src/db.ts b/apps/bridge/src/db.ts index c964285..e877172 100644 --- a/apps/bridge/src/db.ts +++ b/apps/bridge/src/db.ts @@ -471,6 +471,7 @@ export function migrateTenantDb(db: Database.Database): void { icon TEXT NOT NULL, segment TEXT NOT NULL DEFAULT '', kind TEXT NOT NULL DEFAULT 'placeholder', + object_type TEXT, right_sidebar TEXT, agent_id TEXT, built_in INTEGER NOT NULL DEFAULT 0, @@ -482,6 +483,12 @@ export function migrateTenantDb(db: Database.Database): void { ON structure_nodes(parent_id, sort_order); `); + try { + db.exec(`ALTER TABLE structure_nodes ADD COLUMN object_type TEXT`); + } catch { + /* already migrated */ + } + migrateTradeMirrorSchema(db); migrateUnifiedDataSchema(db); migrateScLevelsKey(db); @@ -1638,6 +1645,40 @@ function migrateUnifiedDataSchema(db: Database.Database): void { CREATE INDEX IF NOT EXISTS platform_action_log_by_ts ON platform_action_log(created_at DESC); + CREATE TABLE IF NOT EXISTS kernel_action_idempotency ( + key TEXT NOT NULL, + actor_id TEXT NOT NULL, + object_type TEXT NOT NULL, + record_id TEXT NOT NULL, + action_name TEXT NOT NULL, + input_hash TEXT NOT NULL, + status TEXT NOT NULL, + result_json TEXT, + expires_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (key, actor_id, object_type, record_id, action_name) + ); + + CREATE TABLE IF NOT EXISTS kernel_operation_runs ( + id TEXT PRIMARY KEY, + tenant_id TEXT, + actor_id TEXT NOT NULL, + object_type TEXT NOT NULL, + record_id TEXT, + action_name TEXT NOT NULL, + status TEXT NOT NULL, + progress REAL, + result_json TEXT, + error_code TEXT, + error_message TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + finished_at TEXT + ); + CREATE INDEX IF NOT EXISTS kernel_operation_runs_status + ON kernel_operation_runs(status, updated_at DESC); + CREATE TABLE IF NOT EXISTS bank_ledger_entries ( id TEXT PRIMARY KEY, category TEXT, diff --git a/apps/bridge/src/kernel/__tests__/action-contract.test.ts b/apps/bridge/src/kernel/__tests__/action-contract.test.ts new file mode 100644 index 0000000..7be4f0e --- /dev/null +++ b/apps/bridge/src/kernel/__tests__/action-contract.test.ts @@ -0,0 +1,171 @@ +import Database from "better-sqlite3"; +import { describe, expect, it } from "vitest"; +import type { ObjectTypeDef } from "@godmode/kernel"; +import { + registerRecordAdapter, + unregisterRecordAdapter, +} from "../adapter-registry.js"; +import { registerObjectType } from "../registry.js"; +import { + executeRecordAction, + KernelError, +} from "../record-api.js"; + +const definition: ObjectTypeDef = { + name: "ActionContractItem", + label: "Action Contract Item", + pluginId: "action-contract-tests", + contractVersion: 1, + storage: { kind: "adapter", adapterId: "action_contract_test_adapter" }, + fields: [ + { name: "id", label: "Id", fieldType: "Data" }, + { name: "updated_at", label: "Updated At", fieldType: "ReadOnly" }, + ], + operations: ["list", "get"], + permissions: [ + { role: "viewer", read: true }, + { role: "owner", read: true }, + ], + actions: [ + { + name: "publish", + label: "Publish", + target: "record", + effect: "external", + execution: "sync", + roles: ["owner"], + confirmation: { required: true, ttlSeconds: 60 }, + idempotency: { required: true }, + inputSchema: { + type: "object", + additionalProperties: false, + properties: { title: { type: "string", minLength: 1 } }, + required: ["title"], + }, + outputSchema: { + type: "object", + properties: { + ok: { type: "boolean" }, + token: { type: "string" }, + }, + required: ["ok", "token"], + }, + sensitiveOutputPaths: ["token"], + }, + { + name: "rebuild", + label: "Rebuild", + target: "record", + effect: "write", + execution: "async", + cancellable: true, + roles: ["owner"], + inputSchema: { type: "object", additionalProperties: false }, + }, + ], +}; + +const owner = { + tenantId: "tenant-action", + userId: "user-action", + role: "owner" as const, + source: "http" as const, + installedPluginIds: new Set(["action-contract-tests"]), +}; + +function setup() { + const db = new Database(":memory:"); + unregisterRecordAdapter("action_contract_test_adapter"); + registerRecordAdapter({ + id: "action_contract_test_adapter", + get(_db, def, id) { + return { + objectType: def.name, + id, + data: { id, updated_at: "v1" }, + }; + }, + actions: { + publish() { + return { ok: true, token: "do-not-expose" }; + }, + async rebuild() { + await Promise.resolve(); + return { ok: true }; + }, + }, + }); + registerObjectType(definition); + return db; +} + +describe("ObjectType action contract", () => { + it("validates input, denies absent roles, binds confirmation, and redacts output", async () => { + const db = setup(); + await expect( + executeRecordAction(db, definition.name, "one", "publish", {}, { + ...owner, + idempotencyKey: "publish-one", + }) + ).rejects.toMatchObject({ code: "KERNEL_SCHEMA_INVALID" }); + + await expect( + executeRecordAction( + db, + definition.name, + "one", + "publish", + { title: "Ready" }, + { ...owner, role: "viewer", idempotencyKey: "publish-one" } + ) + ).rejects.toMatchObject({ code: "KERNEL_ACTION_FORBIDDEN" }); + + let confirmationId = ""; + try { + await executeRecordAction( + db, + definition.name, + "one", + "publish", + { title: "Ready" }, + { ...owner, idempotencyKey: "publish-one" } + ); + } catch (error) { + expect(error).toBeInstanceOf(KernelError); + expect(error).toMatchObject({ code: "KERNEL_CONFIRMATION_REQUIRED" }); + confirmationId = String( + (error as KernelError).details && + ((error as KernelError).details as { confirmationId: string }) + .confirmationId + ); + } + await expect( + executeRecordAction( + db, + definition.name, + "one", + "publish", + { title: "Ready" }, + { ...owner, idempotencyKey: "publish-one", confirmationId } + ) + ).resolves.toEqual({ ok: true, token: "[REDACTED]" }); + }); + + it("returns a durable OperationRun for asynchronous actions", async () => { + const db = setup(); + const accepted = (await executeRecordAction( + db, + definition.name, + "one", + "rebuild", + {}, + owner + )) as { operationRunId: string }; + expect(accepted.operationRunId).toBeTruthy(); + await new Promise((resolve) => setTimeout(resolve, 10)); + const row = db + .prepare(`SELECT status FROM kernel_operation_runs WHERE id=?`) + .get(accepted.operationRunId) as { status: string }; + expect(row.status).toBe("succeeded"); + }); +}); diff --git a/apps/bridge/src/kernel/__tests__/auto-tools.test.ts b/apps/bridge/src/kernel/__tests__/auto-tools.test.ts new file mode 100644 index 0000000..967a8d1 --- /dev/null +++ b/apps/bridge/src/kernel/__tests__/auto-tools.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { genericObjectTypeToolDefs, objectTypeAutoToolDefs } from "../auto-tools.js"; + +describe("ObjectType AI tools", () => { + it("defines safe generic modes and schemas", () => { + const tools = genericObjectTypeToolDefs(); + expect(tools.find((tool) => tool.name === "list_records")?.mode).toBe("auto"); + expect(tools.find((tool) => tool.name === "create_record")).toMatchObject({ + mode: "confirm", + parameters: expect.objectContaining({ required: ["objectType", "data"] }), + }); + }); + + it("does not duplicate static tool names", () => { + const generated = objectTypeAutoToolDefs( + new Set(["list_structure_nodes", "update_structure_node"]) + ); + const names = generated.map((tool) => tool.name); + expect(new Set(names).size).toBe(names.length); + expect(names).not.toContain("list_structure_nodes"); + expect(names).not.toContain("update_structure_node"); + }); +}); diff --git a/apps/bridge/src/kernel/__tests__/automation-actions.test.ts b/apps/bridge/src/kernel/__tests__/automation-actions.test.ts new file mode 100644 index 0000000..daf71f9 --- /dev/null +++ b/apps/bridge/src/kernel/__tests__/automation-actions.test.ts @@ -0,0 +1,379 @@ +import Database from "better-sqlite3"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ObjectTypeDef } from "@godmode/kernel"; + +const mocks = vi.hoisted(() => ({ + coreDb: null as unknown as Database.Database, + refreshScheduler: vi.fn(), + approveHookRun: vi.fn(async () => undefined), + rejectHookRun: vi.fn(), +})); + +vi.mock("../../core-db.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, getCoreDb: () => mocks.coreDb }; +}); + +vi.mock("../../services/scheduler.js", () => ({ + refreshScheduler: mocks.refreshScheduler, +})); + +vi.mock("../../services/hook-dispatcher.js", () => ({ + approveHookRun: mocks.approveHookRun, + rejectHookRun: mocks.rejectHookRun, +})); + +import { + agentServiceAdapter, + hookRunServiceAdapter, + hookServiceAdapter, + scheduleServiceAdapter, + workflowRunServiceAdapter, + workflowServiceAdapter, +} from "../adapters/core-services.js"; + +const ctx = { + tenantId: "tenant-actions", + userId: "user-actions", + role: "owner" as const, + source: "http" as const, +}; + +function def(name: string): ObjectTypeDef { + return { + name, + label: name, + storage: { kind: "adapter", adapterId: `${name}_test` }, + fields: [], + }; +} + +describe("safe automation service actions", () => { + let db: Database.Database; + + beforeEach(() => { + mocks.refreshScheduler.mockClear(); + mocks.approveHookRun.mockClear(); + mocks.rejectHookRun.mockClear(); + db = new Database(":memory:"); + mocks.coreDb = new Database(":memory:"); + + db.exec(` + CREATE TABLE ai_agents ( + id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT, icon TEXT, + backend TEXT NOT NULL, enabled INTEGER NOT NULL, is_template INTEGER NOT NULL, + system_prompt TEXT NOT NULL, sampling_json TEXT NOT NULL, thinking_json TEXT NOT NULL, + tool_allow_json TEXT, auto_approve_json TEXT, model_path TEXT, + adapter_ids_json TEXT, config_json TEXT, parent_id TEXT, team TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE ai_agent_assignments ( + scope_type TEXT NOT NULL, scope_id TEXT NOT NULL, agent_id TEXT NOT NULL, + role TEXT NOT NULL, updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (scope_type, scope_id) + ); + CREATE TABLE ai_agent_rule_state ( + agent_id TEXT, rule_id TEXT, enabled INTEGER, priority_override INTEGER, + updated_at TEXT, PRIMARY KEY (agent_id, rule_id) + ); + CREATE TABLE ai_agent_skill_state ( + agent_id TEXT, skill_id TEXT, enabled INTEGER, last_used_at TEXT, + updated_at TEXT, PRIMARY KEY (agent_id, skill_id) + ); + CREATE TABLE ai_workflows ( + id TEXT PRIMARY KEY, agent_id TEXT, name TEXT NOT NULL, config_json TEXT NOT NULL, + enabled INTEGER NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE ai_schedules ( + id TEXT PRIMARY KEY, workflow_id TEXT NOT NULL, cron_expr TEXT NOT NULL, + timezone TEXT NOT NULL, enabled INTEGER NOT NULL, last_run_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE ai_workflow_runs ( + id TEXT PRIMARY KEY, workflow_id TEXT NOT NULL, status TEXT NOT NULL, + trigger_input TEXT, state_json TEXT NOT NULL DEFAULT '{}', + awaiting_node_id TEXT, card_id TEXT, result_json TEXT, error TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE ai_workflow_comments ( + id TEXT PRIMARY KEY, workflow_id TEXT, card_id TEXT, author TEXT, body TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE ai_prompt_queue ( + id TEXT PRIMARY KEY, status TEXT NOT NULL, priority INTEGER NOT NULL, + workflow_id TEXT, adapter_ids_json TEXT, prompt TEXT, context_json TEXT, + result_json TEXT, error TEXT, tenant_id TEXT, created_at TEXT DEFAULT (datetime('now')), + started_at TEXT, finished_at TEXT + ); + `); + + mocks.coreDb.exec(` + PRAGMA foreign_keys = ON; + CREATE TABLE hooks ( + id TEXT PRIMARY KEY, owner_kind TEXT NOT NULL, owner_id TEXT NOT NULL, + owner_tenant_id TEXT, name TEXT NOT NULL, enabled INTEGER NOT NULL, + trigger_kind TEXT NOT NULL, event_type TEXT, schedule_cron TEXT, + condition_json TEXT, action_kind TEXT NOT NULL, action_config_json TEXT, + rate_limit_per_hour INTEGER, require_approval INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), last_fired_at TEXT + ); + CREATE TABLE hook_runs ( + id TEXT PRIMARY KEY, hook_id TEXT NOT NULL REFERENCES hooks(id) ON DELETE CASCADE, + event_id TEXT, status TEXT NOT NULL, detail TEXT, result_json TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + `); + + db.prepare( + `INSERT INTO ai_agents + (id, name, description, icon, backend, enabled, is_template, system_prompt, + sampling_json, thinking_json, tool_allow_json, auto_approve_json, model_path, + adapter_ids_json, config_json, parent_id, team) + VALUES ('source', 'Source', 'private profile', 'bot', 'provider', 1, 0, + 'secret system prompt', '{}', '{}', '["read"]', '[]', NULL, '[]', + '{"provider":"openai","reflection":{"enabled":false}}', NULL, 'Ops')` + ).run(); + }); + + it("clones, assigns, and queues reflection without exposing agent internals", async () => { + const cloned = await agentServiceAdapter.actions!.clone( + db, + def("Agent"), + "source", + { id: "clone", name: "Clone" }, + ctx + ); + expect(cloned).toMatchObject({ + id: "clone", + data: { name: "Clone", backend: "provider", team: "Ops" }, + }); + expect(JSON.stringify(cloned)).not.toContain("secret system prompt"); + expect(JSON.stringify(cloned)).not.toContain('"provider":"openai"'); + + const assignment = await agentServiceAdapter.actions!.assign( + db, + def("Agent"), + "clone", + { scope_type: "page", scope_id: "ops/main/dashboard", role: "editor" }, + ctx + ); + expect(assignment).toMatchObject({ + agent_id: "clone", + role: "editor", + }); + + await agentServiceAdapter.actions!.configure_reflection( + db, + def("Agent"), + "clone", + { enabled: true, schedule: { enabled: true } }, + ctx + ); + const reflection = JSON.parse( + ( + db.prepare(`SELECT config_json FROM ai_agents WHERE id = 'clone'`).get() as { + config_json: string; + } + ).config_json + ).reflection; + expect(reflection).toMatchObject({ + enabled: true, + schedule: { enabled: true, cron: "0 2 * * *" }, + }); + + const queued = await agentServiceAdapter.actions!.run_reflection( + db, + def("Agent"), + "clone", + {}, + ctx + ); + expect(queued).toMatchObject({ ok: true, jobId: expect.any(String) }); + expect( + JSON.parse( + ( + db.prepare(`SELECT context_json FROM ai_prompt_queue`).get() as { + context_json: string; + } + ).context_json + ) + ).toMatchObject({ + reflectionAgentId: "clone", + reflectionTrigger: "manual", + }); + }); + + it("queues workflow run/resume, cancels safely, and deletes dependents", async () => { + db.prepare( + `INSERT INTO ai_workflows (id, agent_id, name, config_json, enabled) + VALUES ('wf', 'source', 'Workflow', '{"nodes":[],"edges":[]}', 1)` + ).run(); + db.prepare( + `INSERT INTO ai_workflow_runs (id, workflow_id, status, card_id) + VALUES ('run', 'wf', 'awaiting_input', NULL)` + ).run(); + db.prepare( + `INSERT INTO ai_schedules (id, workflow_id, cron_expr, timezone, enabled) + VALUES ('schedule', 'wf', '0 9 * * *', 'UTC', 1)` + ).run(); + mocks.coreDb.prepare( + `INSERT INTO hooks + (id, owner_kind, owner_id, owner_tenant_id, name, enabled, trigger_kind, + action_kind, action_config_json, require_approval) + VALUES ('wf-hook', 'user', ?, ?, 'Run wf', 1, 'event', + 'run_workflow', '{"workflowId":"wf"}', 0)` + ).run(ctx.userId, ctx.tenantId); + + await workflowServiceAdapter.actions!.run( + db, + def("Workflow"), + "wf", + { trigger_input: "go" }, + ctx + ); + await workflowRunServiceAdapter.actions!.resume( + db, + def("WorkflowRun"), + "run", + { decision: "approve" }, + ctx + ); + expect( + db.prepare(`SELECT COUNT(*) AS c FROM ai_prompt_queue`).get() + ).toEqual({ c: 2 }); + + await workflowRunServiceAdapter.actions!.cancel( + db, + def("WorkflowRun"), + "run", + {}, + ctx + ); + expect( + db.prepare(`SELECT status, error FROM ai_workflow_runs WHERE id = 'run'`).get() + ).toEqual({ status: "failed", error: "cancelled" }); + + workflowServiceAdapter.delete!(db, def("Workflow"), "wf", ctx); + expect(db.prepare(`SELECT COUNT(*) AS c FROM ai_workflows`).get()).toEqual({ + c: 0, + }); + expect(db.prepare(`SELECT COUNT(*) AS c FROM ai_schedules`).get()).toEqual({ + c: 0, + }); + expect( + mocks.coreDb.prepare(`SELECT COUNT(*) AS c FROM hooks`).get() + ).toEqual({ c: 0 }); + }); + + it("toggles schedules and refreshes hook scheduling after every mutation", async () => { + db.prepare( + `INSERT INTO ai_workflows (id, name, config_json, enabled) + VALUES ('wf', 'Workflow', '{"nodes":[],"edges":[]}', 1)` + ).run(); + db.prepare( + `INSERT INTO ai_schedules (id, workflow_id, cron_expr, timezone, enabled) + VALUES ('schedule', 'wf', '0 9 * * *', 'UTC', 0)` + ).run(); + + const enabled = await scheduleServiceAdapter.actions!.enable( + db, + def("Schedule"), + "schedule", + {}, + ctx + ); + expect(enabled).toMatchObject({ data: { enabled: true } }); + const disabled = await scheduleServiceAdapter.actions!.disable( + db, + def("Schedule"), + "schedule", + {}, + ctx + ); + expect(disabled).toMatchObject({ data: { enabled: false } }); + + const hook = hookServiceAdapter.create!( + db, + def("Hook"), + { + owner_kind: "user", + owner_id: ctx.userId, + name: "Daily", + trigger_kind: "schedule", + schedule_cron: "0 8 * * *", + action_kind: "notify", + action_config_json: { + message: "Safe", + authorization: "Bearer private", + }, + }, + ctx + ); + expect(hook.data.action_config_json).toEqual({ + message: "Safe", + authorization: "[REDACTED]", + }); + await hookServiceAdapter.actions!.disable( + db, + def("Hook"), + hook.id, + {}, + ctx + ); + hookServiceAdapter.delete!(db, def("Hook"), hook.id, ctx); + expect(mocks.refreshScheduler).toHaveBeenCalledTimes(3); + }); + + it("enforces hook ownership before approving or rejecting runs", async () => { + mocks.coreDb.prepare( + `INSERT INTO hooks + (id, owner_kind, owner_id, owner_tenant_id, name, enabled, trigger_kind, + action_kind, require_approval) + VALUES ('owned', 'user', ?, ?, 'Owned', 1, 'event', 'notify', 1), + ('other', 'user', 'other-user', ?, 'Other', 1, 'event', 'notify', 1)` + ).run(ctx.userId, ctx.tenantId, ctx.tenantId); + mocks.coreDb.prepare( + `INSERT INTO hook_runs (id, hook_id, status) + VALUES ('approve-run', 'owned', 'pending_approval'), + ('reject-run', 'owned', 'pending_approval'), + ('other-run', 'other', 'pending_approval')` + ).run(); + + await hookRunServiceAdapter.actions!.approve( + db, + def("HookRun"), + "approve-run", + {}, + ctx + ); + await hookRunServiceAdapter.actions!.reject( + db, + def("HookRun"), + "reject-run", + {}, + ctx + ); + expect(mocks.approveHookRun).toHaveBeenCalledWith( + "approve-run", + mocks.coreDb + ); + expect(mocks.rejectHookRun).toHaveBeenCalledWith( + "reject-run", + mocks.coreDb + ); + await expect( + hookRunServiceAdapter.actions!.approve( + db, + def("HookRun"), + "other-run", + {}, + ctx + ) + ).rejects.toMatchObject({ status: 403 }); + }); +}); diff --git a/apps/bridge/src/kernel/__tests__/content-actions.test.ts b/apps/bridge/src/kernel/__tests__/content-actions.test.ts new file mode 100644 index 0000000..1e66286 --- /dev/null +++ b/apps/bridge/src/kernel/__tests__/content-actions.test.ts @@ -0,0 +1,286 @@ +import Database from "better-sqlite3"; +import { beforeEach, describe, expect, it } from "vitest"; +import type { ObjectTypeDef } from "@godmode/kernel"; +import type { OperationContext } from "../adapter-registry.js"; +import { + artifactServiceAdapter, + memoryServiceAdapter, + notificationServiceAdapter, + reflectionProposalServiceAdapter, + ruleServiceAdapter, + skillServiceAdapter, + wikiProposalServiceAdapter, +} from "../adapters/content.js"; + +function def(name: string, adapterId: string): ObjectTypeDef { + return { + name, + label: name, + storage: { kind: "adapter", adapterId }, + fields: [], + }; +} + +const memoryDef = def("Memory", "memory_service"); +const notificationDef = def("Notification", "notification_service"); +const reflectionDef = def( + "ReflectionProposal", + "reflection_proposal_service" +); +const wikiProposalDef = def("WikiProposal", "wiki_proposal_service"); + +function ctx( + db: Database.Database, + overrides: Partial = {} +): OperationContext { + return { + tenantId: "tenant-a", + userId: "user-a", + agentId: "agent-a", + role: "owner", + source: "agent", + data: { + tenantDb: db, + coreDb: db, + declaredDatabase: "tenant", + }, + ...overrides, + }; +} + +describe("content ObjectType adapters", () => { + let db: Database.Database; + + beforeEach(() => { + db = new Database(":memory:"); + db.exec(` + CREATE TABLE ai_memories ( + id TEXT PRIMARY KEY, + agent_id TEXT, + scope TEXT NOT NULL, + chat_id TEXT, + text TEXT NOT NULL, + category TEXT, + source TEXT, + enabled INTEGER NOT NULL DEFAULT 1, + status TEXT NOT NULL DEFAULT 'active', + pack_id TEXT, + valid_from TEXT, + valid_until TEXT, + embedding BLOB, + embedding_dim INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE VIRTUAL TABLE ai_memories_fts USING fts5(memory_id UNINDEXED, text); + + CREATE TABLE ai_reflection_proposals ( + id TEXT PRIMARY KEY, + agent_id TEXT NOT NULL, + kind TEXT NOT NULL, + target_id TEXT, + action TEXT NOT NULL, + payload_json TEXT NOT NULL DEFAULT '{}', + status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE notifications ( + id TEXT PRIMARY KEY, + recipient_kind TEXT NOT NULL, + recipient_id TEXT NOT NULL, + recipient_tenant_id TEXT, + category TEXT NOT NULL, + title TEXT NOT NULL, + body TEXT, + link TEXT, + resource_kind TEXT, + resource_id TEXT, + read_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE wiki_page_proposals ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + action TEXT NOT NULL, + space TEXT, + slug TEXT, + title TEXT NOT NULL, + body_markdown TEXT NOT NULL, + target_page_id TEXT, + status TEXT NOT NULL DEFAULT 'pending', + reason TEXT, + source TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + `); + }); + + it("exports service adapters with handlers kept on actions", () => { + expect([ + memoryServiceAdapter.id, + ruleServiceAdapter.id, + skillServiceAdapter.id, + artifactServiceAdapter.id, + wikiProposalServiceAdapter.id, + reflectionProposalServiceAdapter.id, + notificationServiceAdapter.id, + ]).toEqual([ + "memory_service", + "rule_service", + "skill_service", + "artifact_service", + "wiki_proposal_service", + "reflection_proposal_service", + "notification_service", + ]); + expect(Object.keys(memoryServiceAdapter.actions ?? {})).toEqual([ + "approve", + "reject", + ]); + expect(Object.keys(notificationServiceAdapter.actions ?? {})).toEqual([ + "mark_read", + "mark_all_read", + "clear", + ]); + }); + + it("scopes memory CRUD to the active agent and maintains FTS", () => { + const owner = ctx(db); + const created = memoryServiceAdapter.create!( + db, + memoryDef, + { text: "private launch phrase", status: "pending" }, + owner + ); + expect(created.data.agent_id).toBe("agent-a"); + expect( + memoryServiceAdapter.get!( + db, + memoryDef, + created.id, + ctx(db, { agentId: "agent-b" }) + ) + ).toBeNull(); + + memoryServiceAdapter.actions!.approve( + db, + memoryDef, + created.id, + {}, + owner + ); + expect( + db + .prepare( + `SELECT memory_id FROM ai_memories_fts + WHERE ai_memories_fts MATCH 'launch'` + ) + .get() + ).toEqual({ memory_id: created.id }); + + memoryServiceAdapter.delete!(db, memoryDef, created.id, owner); + expect( + db.prepare(`SELECT 1 FROM ai_memories_fts WHERE memory_id = ?`).get(created.id) + ).toBeUndefined(); + }); + + it("prevents cross-agent reflection lifecycle actions", () => { + db.prepare( + `INSERT INTO ai_reflection_proposals + (id, agent_id, kind, target_id, action, payload_json, status) + VALUES (?, ?, 'memory', 'm1', 'delete', '{}', 'pending')` + ).run("proposal-b", "agent-b"); + + expect( + reflectionProposalServiceAdapter.get!( + db, + reflectionDef, + "proposal-b", + ctx(db) + ) + ).toBeNull(); + expect(() => + reflectionProposalServiceAdapter.actions!.reject( + db, + reflectionDef, + "proposal-b", + {}, + ctx(db) + ) + ).toThrow(/not found/i); + expect( + db + .prepare(`SELECT status FROM ai_reflection_proposals WHERE id = ?`) + .get("proposal-b") + ).toEqual({ status: "pending" }); + }); + + it("marks and clears only the current recipient's notifications", () => { + const insert = db.prepare( + `INSERT INTO notifications + (id, recipient_kind, recipient_id, recipient_tenant_id, category, title) + VALUES (?, 'user', ?, 'tenant-a', 'system', ?)` + ); + insert.run("mine-unread", "user-a", "Mine unread"); + insert.run("mine-read", "user-a", "Mine read"); + insert.run("theirs", "user-b", "Theirs"); + db.prepare(`UPDATE notifications SET read_at = datetime('now') WHERE id = ?`).run( + "mine-read" + ); + const userCtx = ctx(db, { source: "http", agentId: undefined }); + + notificationServiceAdapter.actions!.mark_read( + db, + notificationDef, + "mine-unread", + {}, + userCtx + ); + const cleared = notificationServiceAdapter.actions!.clear( + db, + notificationDef, + "", + {}, + userCtx + ); + expect(cleared).toEqual({ deleted: 2 }); + expect( + db.prepare(`SELECT id FROM notifications ORDER BY id`).all() + ).toEqual([{ id: "theirs" }]); + }); + + it("tenant-scopes wiki proposal rejection", () => { + const insert = db.prepare( + `INSERT INTO wiki_page_proposals + (id, tenant_id, action, title, body_markdown, source) + VALUES (?, ?, 'create', ?, '', 'test')` + ); + insert.run("proposal-a", "tenant-a", "A"); + insert.run("proposal-b", "tenant-b", "B"); + + expect( + wikiProposalServiceAdapter.get!( + db, + wikiProposalDef, + "proposal-b", + ctx(db) + ) + ).toBeNull(); + wikiProposalServiceAdapter.actions!.reject( + db, + wikiProposalDef, + "proposal-a", + {}, + ctx(db) + ); + expect( + db + .prepare(`SELECT status FROM wiki_page_proposals WHERE id = ?`) + .get("proposal-a") + ).toEqual({ status: "rejected" }); + }); +}); diff --git a/apps/bridge/src/kernel/__tests__/core-services.test.ts b/apps/bridge/src/kernel/__tests__/core-services.test.ts new file mode 100644 index 0000000..0963b88 --- /dev/null +++ b/apps/bridge/src/kernel/__tests__/core-services.test.ts @@ -0,0 +1,173 @@ +import Database from "better-sqlite3"; +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { + createRecord, + deleteRecord, + getRecord, + updateRecord, +} from "../record-api.js"; +import { registerCoreObjectTypes } from "../core-object-types.js"; + +const ctx = { + tenantId: "tenant-services", + userId: "user-services", + role: "owner" as const, + source: "http" as const, +}; + +describe("core service ObjectType adapters", () => { + let db: Database.Database; + + beforeAll(() => registerCoreObjectTypes()); + + beforeEach(() => { + db = new Database(":memory:"); + db.exec(` + CREATE TABLE ai_agents ( + id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT, icon TEXT, + backend TEXT NOT NULL, enabled INTEGER NOT NULL, is_template INTEGER NOT NULL, + system_prompt TEXT NOT NULL, sampling_json TEXT NOT NULL, thinking_json TEXT NOT NULL, + tool_allow_json TEXT, auto_approve_json TEXT, model_path TEXT, + adapter_ids_json TEXT, config_json TEXT, parent_id TEXT, team TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE ai_workflows ( + id TEXT PRIMARY KEY, agent_id TEXT, name TEXT NOT NULL, config_json TEXT NOT NULL, + enabled INTEGER NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE ai_schedules ( + id TEXT PRIMARY KEY, workflow_id TEXT NOT NULL, cron_expr TEXT NOT NULL, + timezone TEXT NOT NULL, enabled INTEGER NOT NULL, last_run_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE ai_projects ( + id TEXT PRIMARY KEY, name TEXT NOT NULL, user_id TEXT, agent_id TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE ai_project_columns ( + id TEXT PRIMARY KEY, project_id TEXT, name TEXT NOT NULL, sort_order INTEGER + ); + CREATE TABLE ai_project_cards ( + id TEXT PRIMARY KEY, project_id TEXT NOT NULL, column_id TEXT NOT NULL, + title TEXT NOT NULL, description TEXT, prompt TEXT, context_json TEXT, + tags_json TEXT, due_at TEXT, linked_chat_id TEXT, linked_workflow_id TEXT, + priority INTEGER, parent_card_id TEXT, status TEXT, assigned_agent_id TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE ai_card_comments ( + id TEXT PRIMARY KEY, card_id TEXT NOT NULL, author TEXT NOT NULL, + body TEXT NOT NULL, kind TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE ai_calendar_events ( + id TEXT PRIMARY KEY, agent_id TEXT, user_id TEXT, kind TEXT NOT NULL, + title TEXT NOT NULL, description TEXT, start_at TEXT NOT NULL, end_at TEXT, + all_day INTEGER NOT NULL DEFAULT 0, location TEXT, linked_card_id TEXT, + linked_run_id TEXT, status TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + `); + }); + + it("preserves workflow service validation and safe edits", () => { + const created = createRecord( + db, + "Workflow", + { + name: "Metadata workflow", + config_json: { nodes: [], edges: [] }, + enabled: true, + }, + ctx + ); + expect(created.data).toMatchObject({ + name: "Metadata workflow", + enabled: true, + }); + expect( + updateRecord(db, "Workflow", created.id, { name: "Updated workflow" }, ctx) + .data.name + ).toBe("Updated workflow"); + deleteRecord(db, "Workflow", created.id, ctx); + expect(() => getRecord(db, "Workflow", created.id, ctx)).toThrow( + /record not found/i + ); + }); + + it("reloads schedule definitions through service CRUD", () => { + const workflow = createRecord( + db, + "Workflow", + { name: "Scheduled", config_json: { nodes: [], edges: [] } }, + ctx + ); + const schedule = createRecord( + db, + "Schedule", + { + workflow_id: workflow.id, + cron_expr: "0 9 * * *", + timezone: "America/Denver", + enabled: true, + }, + ctx + ); + expect(schedule.data).toMatchObject({ + workflow_id: workflow.id, + enabled: true, + }); + }); + + it("uses the authoritative Agent service and protects built-ins", () => { + const agent = createRecord( + db, + "Agent", + { id: "metadata-agent", name: "Metadata Agent", backend: "local" }, + ctx + ); + expect(agent.data).toMatchObject({ + name: "Metadata Agent", + enabled: true, + is_template: false, + }); + expect( + updateRecord(db, "Agent", agent.id, { team: "Operations" }, ctx).data.team + ).toBe("Operations"); + }); + + it("scopes productivity edits to the active user", () => { + const event = createRecord( + db, + "CalendarEvent", + { + title: "Kernel review", + start_at: "2026-07-15T09:00:00Z", + all_day: false, + }, + ctx + ); + expect(event.data.title).toBe("Kernel review"); + const card = createRecord(db, "TaskCard", { title: "Ship metadata" }, ctx); + expect(() => + createRecord( + db, + "CardComment", + { card_id: card.id, body: "Validated" }, + ctx + ) + ).toThrow(/create is disabled/i); + expect(() => + getRecord(db, "TaskCard", card.id, { + ...ctx, + userId: "another-user", + }) + ).toThrow(/not found/i); + }); +}); diff --git a/apps/bridge/src/kernel/__tests__/platform-actions.test.ts b/apps/bridge/src/kernel/__tests__/platform-actions.test.ts new file mode 100644 index 0000000..1bb59f7 --- /dev/null +++ b/apps/bridge/src/kernel/__tests__/platform-actions.test.ts @@ -0,0 +1,221 @@ +import Database from "better-sqlite3"; +import type { ObjectTypeDef } from "@godmode/kernel"; +import { describe, expect, it } from "vitest"; +import type { OperationContext } from "../adapter-registry.js"; +import { + bridgeConnectionAdapter, + catalogSourceAdapter, + financeConnectionAdapter, + peerConnectionAdapter, + platformActionAdapterRegistrations, +} from "../adapters/platform-actions.js"; + +function definition(name: string, fields: string[]): ObjectTypeDef { + return { + name, + label: name, + labelPlural: `${name}s`, + module: "test", + database: "core", + storage: { kind: "adapter", adapterId: "test" }, + fields: fields.map((field) => ({ + name: field, + label: field, + fieldType: "Data", + })), + permissions: [{ role: "owner", read: true, create: true, delete: true }], + operations: ["list", "get", "create", "delete"], + contractVersion: 1, + schemaVersion: 1, + }; +} + +function context( + core: Database.Database, + overrides: Partial = {} +): OperationContext { + return { + tenantId: "tenant-a", + userId: "user-a", + role: "owner", + source: "http", + data: { + coreDb: core, + tenantDb: core, + declaredDatabase: "core", + }, + ...overrides, + }; +} + +describe("platform action adapters", () => { + it("reports stable registration IDs and named actions", () => { + expect(platformActionAdapterRegistrations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + adapterId: "share_grant_read", + actions: expect.arrayContaining(["grant", "revoke"]), + }), + expect.objectContaining({ + adapterId: "dm_message_read", + actions: ["send"], + }), + expect.objectContaining({ + adapterId: "marketplace_listing_read", + actions: expect.arrayContaining(["acquire_live", "publish", "archive"]), + }), + expect.objectContaining({ + adapterId: "finance_connection_service", + actions: expect.arrayContaining(["add_manual", "connect_external"]), + }), + ]) + ); + }); + + it("scopes catalog sources to the authenticated user", () => { + const db = new Database(":memory:"); + const def = definition("CatalogSource", [ + "id", + "user_id", + "name", + "url", + "created_at", + ]); + const ctxA = context(db); + const ctxB = context(db, { userId: "user-b" }); + + const source = catalogSourceAdapter.create!(db, def, { + name: "Private", + url: "https://catalog.example/index.json", + }, ctxA); + + expect(catalogSourceAdapter.list!(db, def, {}, ctxA).total).toBe(1); + expect(catalogSourceAdapter.list!(db, def, {}, ctxB).total).toBe(0); + expect(catalogSourceAdapter.get!(db, def, source.id, ctxB)).toBeNull(); + expect(() => + catalogSourceAdapter.actions!.remove(db, def, source.id, {}, ctxB) + ).toThrow(/not found/i); + }); + + it("prevents cross-tenant bridge reads and mutations", () => { + const db = new Database(":memory:"); + db.exec(` + CREATE TABLE bridge_connections ( + id TEXT PRIMARY KEY, + owner_tenant_id TEXT NOT NULL, + owner_user_id TEXT NOT NULL, + label TEXT NOT NULL, + mode TEXT NOT NULL, + remote_bridge_url TEXT, + remote_bridge_token TEXT, + status TEXT NOT NULL, + last_seen_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + `); + const def = definition("BridgeConnection", [ + "id", + "owner_tenant_id", + "owner_user_id", + "label", + "mode", + "remote_bridge_url", + "status", + "last_seen_at", + "created_at", + "updated_at", + ]); + const ctxA = context(db); + const ctxB = context(db, { tenantId: "tenant-b", userId: "user-b" }); + + const connection = bridgeConnectionAdapter.create!(db, def, { + label: "Local", + mode: "local", + }, ctxA); + + expect(bridgeConnectionAdapter.get!(db, def, connection.id, ctxB)).toBeNull(); + expect(() => + bridgeConnectionAdapter.delete!(db, def, connection.id, ctxB) + ).toThrow(/not found/i); + expect(bridgeConnectionAdapter.get!(db, def, connection.id, ctxA)).not.toBeNull(); + }); + + it("keeps peer network operations explicit and disabled", () => { + const db = new Database(":memory:"); + expect(() => + peerConnectionAdapter.actions!.invite( + db, + definition("PeerConnection", ["id"]), + "", + {}, + context(db) + ) + ).toThrow(/not available through the kernel/i); + }); + + it("supports only local manual finance connection operations", () => { + const db = new Database(":memory:"); + db.exec(` + CREATE TABLE holdings_connections ( + id TEXT PRIMARY KEY, + category TEXT NOT NULL, + provider TEXT NOT NULL, + label TEXT NOT NULL, + currency TEXT NOT NULL, + reference TEXT, + status TEXT NOT NULL, + external_id TEXT, + balance REAL NOT NULL, + balance_cad REAL NOT NULL, + breakdown_json TEXT, + last_synced_at TEXT, + created_at TEXT NOT NULL + ); + CREATE TABLE holdings_balance_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + connection_id TEXT NOT NULL, + balance REAL NOT NULL, + currency TEXT NOT NULL, + balance_cad REAL NOT NULL, + raw_json TEXT, + as_of TEXT NOT NULL + ); + `); + const def = definition("FinanceConnection", [ + "id", + "category", + "provider", + "label", + "currency", + "balance", + "balance_cad", + "status", + ]); + const ctx = context(db); + + const created = financeConnectionAdapter.actions!.add_manual( + db, + def, + "", + { + category: "manual", + provider: "manual", + label: "Cash", + currency: "CAD", + balance: 25, + balance_cad: 25, + }, + ctx + ) as { id: string }; + + expect(financeConnectionAdapter.get!(db, def, created.id, ctx)?.data).toMatchObject({ + label: "Cash", + balance: 25, + balance_cad: 25, + }); + expect(() => + financeConnectionAdapter.actions!.connect_external(db, def, "", {}, ctx) + ).toThrow(/not available through the kernel/i); + }); +}); diff --git a/apps/bridge/src/kernel/__tests__/plugin-lifecycle.test.ts b/apps/bridge/src/kernel/__tests__/plugin-lifecycle.test.ts new file mode 100644 index 0000000..2f1113d --- /dev/null +++ b/apps/bridge/src/kernel/__tests__/plugin-lifecycle.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import type { ObjectTypeDef } from "@godmode/kernel"; +import { + getObjectType, + registerObjectType, + replaceObjectTypesByPlugin, + unregisterObjectTypesByPlugin, +} from "../registry.js"; + +function pluginDef(name: string, pluginId: string): ObjectTypeDef { + return { + name, + label: name, + pluginId, + storage: { kind: "native" }, + operations: ["list", "get", "create", "update", "delete"], + fields: [{ name: "id", label: "Id", fieldType: "Data" }], + }; +} + +describe("plugin ObjectType lifecycle", () => { + it("atomically replaces removed definitions and supports uninstall", () => { + replaceObjectTypesByPlugin("lifecycle-test", [ + pluginDef("LifecycleOld", "lifecycle-test"), + ]); + replaceObjectTypesByPlugin("lifecycle-test", [ + pluginDef("LifecycleNew", "lifecycle-test"), + ]); + expect(getObjectType("LifecycleOld")).toBeUndefined(); + expect(getObjectType("LifecycleNew")?.pluginId).toBe("lifecycle-test"); + unregisterObjectTypesByPlugin("lifecycle-test"); + expect(getObjectType("LifecycleNew")).toBeUndefined(); + }); + + it("cannot replace another plugin or core owner", () => { + registerObjectType(pluginDef("OwnedLifecycle", "owner-a")); + expect(() => + replaceObjectTypesByPlugin("owner-b", [ + pluginDef("OwnedLifecycle", "owner-b"), + ]) + ).toThrow(/owned by owner-a/); + unregisterObjectTypesByPlugin("owner-a"); + }); +}); diff --git a/apps/bridge/src/kernel/__tests__/productivity-actions.test.ts b/apps/bridge/src/kernel/__tests__/productivity-actions.test.ts new file mode 100644 index 0000000..6ed3a6b --- /dev/null +++ b/apps/bridge/src/kernel/__tests__/productivity-actions.test.ts @@ -0,0 +1,254 @@ +import { EventEmitter } from "node:events"; +import Database from "better-sqlite3"; +import type { ObjectTypeDef } from "@godmode/kernel"; +import { beforeEach, describe, expect, it } from "vitest"; +import { + CALENDAR_EVENT_ACTIONS, + CARD_COMMENT_ACTIONS, + TASK_CARD_ACTIONS, + calendarEventServiceAdapter, + cardCommentServiceAdapter, + taskCardServiceAdapter, +} from "../adapters/productivity.js"; + +const taskDef: ObjectTypeDef = { + name: "TaskCard", + label: "Task Card", + storage: { kind: "adapter", adapterId: taskCardServiceAdapter.id }, + fields: [ + "id", + "project_id", + "column_id", + "title", + "parent_card_id", + "status", + "assigned_agent_id", + "sort_order", + "linked_chat_id", + ].map((name) => ({ name, label: name, fieldType: "Data" })), +}; + +const commentDef: ObjectTypeDef = { + name: "CardComment", + label: "Card Comment", + storage: { kind: "adapter", adapterId: cardCommentServiceAdapter.id }, + fields: ["id", "card_id", "author", "body", "kind", "created_at"].map( + (name) => ({ name, label: name, fieldType: "Data" }) + ), +}; + +const eventDef: ObjectTypeDef = { + name: "CalendarEvent", + label: "Calendar Event", + storage: { kind: "adapter", adapterId: calendarEventServiceAdapter.id }, + fields: ["id", "user_id", "title", "start_at", "status"].map((name) => ({ + name, + label: name, + fieldType: "Data", + })), +}; + +const owner = { + tenantId: "owner-tenant", + userId: "owner-user", + role: "owner" as const, + source: "http" as const, + bus: new EventEmitter(), +}; + +describe("productivity adapter actions", () => { + let db: Database.Database; + + beforeEach(() => { + db = new Database(":memory:"); + db.exec(` + CREATE TABLE ai_projects ( + id TEXT PRIMARY KEY, name TEXT NOT NULL, user_id TEXT, agent_id TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE ai_project_columns ( + id TEXT PRIMARY KEY, project_id TEXT, name TEXT NOT NULL, sort_order INTEGER + ); + CREATE TABLE ai_project_cards ( + id TEXT PRIMARY KEY, project_id TEXT NOT NULL, column_id TEXT NOT NULL, + title TEXT NOT NULL, description TEXT, prompt TEXT, context_json TEXT, + tags_json TEXT, due_at TEXT, linked_chat_id TEXT, linked_workflow_id TEXT, + priority INTEGER, parent_card_id TEXT, status TEXT, assigned_agent_id TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE ai_card_comments ( + id TEXT PRIMARY KEY, card_id TEXT NOT NULL, author TEXT NOT NULL, + body TEXT NOT NULL, kind TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE ai_calendar_events ( + id TEXT PRIMARY KEY, agent_id TEXT, user_id TEXT, kind TEXT NOT NULL, + title TEXT NOT NULL, description TEXT, start_at TEXT NOT NULL, end_at TEXT, + all_day INTEGER NOT NULL DEFAULT 0, location TEXT, linked_card_id TEXT, + linked_run_id TEXT, status TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + `); + }); + + it("exports the exact action-capable adapter contracts", () => { + expect(Object.keys(taskCardServiceAdapter.actions ?? {})).toEqual([ + "move", + "assign", + "transition", + "add_comment", + ]); + expect(TASK_CARD_ACTIONS.map((action) => action.name)).toEqual([ + "move", + "assign", + "transition", + "add_comment", + ]); + expect(Object.keys(cardCommentServiceAdapter.actions ?? {})).toEqual([ + "add_comment", + ]); + expect(CARD_COMMENT_ACTIONS[0]).toMatchObject({ + name: "add_comment", + target: "collection", + effect: "write", + }); + expect(CALENDAR_EVENT_ACTIONS[0]).toMatchObject({ + name: "transition", + target: "record", + effect: "write", + }); + }); + + it("moves, assigns, and transitions only owner-scoped cards", async () => { + const card = taskCardServiceAdapter.create!(db, taskDef, { title: "Scoped" }, owner); + const completed: unknown[] = []; + owner.bus.on("card_completed", (event) => completed.push(event)); + + const moved = await taskCardServiceAdapter.actions!.move( + db, + taskDef, + card.id, + { column_id: "in_progress" }, + owner + ); + expect((moved as { data: Record }).data).toMatchObject({ + column_id: "in_progress", + }); + + const assigned = await taskCardServiceAdapter.actions!.assign( + db, + taskDef, + card.id, + { assigned_agent_id: "agent-7" }, + owner + ); + expect((assigned as { data: Record }).data.assigned_agent_id).toBe( + "agent-7" + ); + + const transitioned = await taskCardServiceAdapter.actions!.transition( + db, + taskDef, + card.id, + { status: "accepted" }, + owner + ); + expect((transitioned as { data: Record }).data).toMatchObject({ + column_id: "done", + status: "accepted", + }); + expect(completed).toEqual([{ cardId: card.id, agentId: "agent-7" }]); + + expect(() => + taskCardServiceAdapter.actions!.move( + db, + taskDef, + card.id, + { column_id: "backlog" }, + { ...owner, userId: "other-user" } + ) + ).toThrow(/TaskCard not found/); + }); + + it("adds scoped comments and preserves subtask progress invariants", async () => { + const parent = taskCardServiceAdapter.create!( + db, + taskDef, + { title: "Parent" }, + owner + ); + const first = taskCardServiceAdapter.create!( + db, + taskDef, + { title: "First", parent_card_id: parent.id }, + owner + ); + const second = taskCardServiceAdapter.create!( + db, + taskDef, + { title: "Second", parent_card_id: parent.id }, + owner + ); + + const comment = await taskCardServiceAdapter.actions!.add_comment( + db, + taskDef, + first.id, + { body: "Finished the first step", kind: "result" }, + { ...owner, source: "agent", agentId: "worker" } + ); + expect((comment as { data: Record }).data).toMatchObject({ + card_id: first.id, + author: "agent", + kind: "result", + }); + expect( + db.prepare(`SELECT column_id, status FROM ai_project_cards WHERE id=?`).get(first.id) + ).toEqual({ column_id: "done", status: "accepted" }); + expect( + db.prepare(`SELECT column_id, status FROM ai_project_cards WHERE id=?`).get(second.id) + ).toEqual({ column_id: "in_progress", status: "working" }); + + expect(() => + cardCommentServiceAdapter.actions!.add_comment( + db, + commentDef, + "", + { card_id: first.id, body: "Cross-tenant attempt" }, + { ...owner, userId: "other-user" } + ) + ).toThrow(/TaskCard not found/); + }); + + it("transitions only the authenticated user's calendar events", async () => { + const event = calendarEventServiceAdapter.create!( + db, + eventDef, + { title: "Review", start_at: "2026-07-15T09:00:00Z" }, + owner + ); + const transitioned = await calendarEventServiceAdapter.actions!.transition( + db, + eventDef, + event.id, + { status: "completed" }, + owner + ); + expect((transitioned as { data: Record }).data.status).toBe( + "completed" + ); + expect(() => + calendarEventServiceAdapter.actions!.transition( + db, + eventDef, + event.id, + { status: "cancelled" }, + { ...owner, userId: "other-user" } + ) + ).toThrow(/CalendarEvent not found/); + }); +}); diff --git a/apps/bridge/src/kernel/__tests__/record-api.test.ts b/apps/bridge/src/kernel/__tests__/record-api.test.ts new file mode 100644 index 0000000..5002ac3 --- /dev/null +++ b/apps/bridge/src/kernel/__tests__/record-api.test.ts @@ -0,0 +1,183 @@ +import Database from "better-sqlite3"; +import { EventEmitter } from "node:events"; +import { beforeEach, describe, expect, it } from "vitest"; +import type { ObjectTypeDef } from "@godmode/kernel"; +import { + createRecord, + deleteRecord, + getRecord, + KernelError, + listRecords, + seedRecords, + updateRecord, +} from "../record-api.js"; +import { registerObjectType } from "../registry.js"; +import { setKernelEventBus } from "../adapter-registry.js"; + +const def: ObjectTypeDef = { + name: "KernelTestItem", + label: "Kernel Test Item", + pluginId: "kernel-test-plugin", + storage: { kind: "native" }, + operations: ["list", "get", "create", "update", "delete"], + fields: [ + { name: "id", label: "Id", fieldType: "Data", required: true }, + { name: "title", label: "Title", fieldType: "Data", required: true }, + { + name: "status", + label: "Status", + fieldType: "Select", + options: ["open", "done"], + default: "open", + }, + { name: "checked", label: "Checked", fieldType: "Check" }, + { name: "meta", label: "Metadata", fieldType: "JSON" }, + ], + permissions: [ + { role: "viewer", read: true }, + { + role: "owner", + read: true, + create: true, + update: true, + delete: true, + }, + ], +}; + +const owner = { + tenantId: "tenant-a", + userId: "user-a", + role: "owner" as const, + source: "http" as const, + installedPluginIds: new Set(["kernel-test-plugin"]), +}; + +describe("record API", () => { + let db: Database.Database; + + beforeEach(() => { + db = new Database(":memory:"); + registerObjectType(def); + }); + + it("supports native CRUD, pagination, defaults, and JSON/Check round trips", () => { + const created = createRecord( + db, + def.name, + { id: "one", title: "One", checked: true, meta: { answer: 42 } }, + owner + ); + expect(created.data.status).toBe("open"); + expect(getRecord(db, def.name, "one", owner).data).toMatchObject({ + checked: true, + meta: { answer: 42 }, + }); + updateRecord(db, def.name, "one", { status: "done" }, owner); + createRecord(db, def.name, { id: "two", title: "Two" }, owner); + expect(listRecords(db, def.name, { limit: 1, offset: 1 }, owner)).toMatchObject({ + total: 2, + records: [{ id: "two" }], + }); + deleteRecord(db, def.name, "one", owner); + expect(() => getRecord(db, def.name, "one", owner)).toThrowError(KernelError); + }); + + it("enforces visibility, permissions, fields, and Select values", () => { + expect(() => + listRecords(db, def.name, {}, { ...owner, installedPluginIds: new Set() }) + ).toThrowError(/Unknown ObjectType/); + expect(() => + createRecord(db, def.name, { id: "x", title: "X" }, { + ...owner, + role: "viewer", + }) + ).toThrowError(/cannot create/); + expect(() => + createRecord(db, def.name, { id: "x", title: "X", unknown: true }, owner) + ).toThrowError(/Unknown field/); + expect(() => + createRecord(db, def.name, { id: "x", title: "X", status: "invalid" }, owner) + ).toThrowError(/Invalid Status/); + }); + + it("applies seeds transactionally and idempotently", () => { + const seeds = [{ objectType: def.name, data: { id: "seed", title: "First" } }]; + seedRecords(db, seeds, owner); + seedRecords(db, [{ ...seeds[0], data: { id: "seed", title: "Updated" } }], owner); + expect(getRecord(db, def.name, "seed", owner).data.title).toBe("Updated"); + }); + + it("reconciles additive native schema changes", () => { + createRecord(db, def.name, { id: "before", title: "Before" }, owner); + registerObjectType({ + ...def, + schemaVersion: 2, + fields: [ + ...def.fields, + { + name: "priority", + label: "Priority", + fieldType: "Int", + required: true, + default: 1, + }, + ], + }); + createRecord( + db, + def.name, + { id: "after", title: "After", priority: 2 }, + owner + ); + expect(getRecord(db, def.name, "after", owner).data.priority).toBe(2); + }); + + it("propagates Structure mutation events", () => { + db.exec(` + CREATE TABLE structure_nodes ( + id TEXT PRIMARY KEY, parent_id TEXT, label TEXT NOT NULL, icon TEXT NOT NULL, + segment TEXT NOT NULL, kind TEXT NOT NULL, object_type TEXT, right_sidebar TEXT, + agent_id TEXT, built_in INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, tabs_json TEXT + ) + `); + const bus = new EventEmitter(); + const seen: unknown[] = []; + bus.on("structure.node.created", (event) => seen.push(event)); + setKernelEventBus(bus); + createRecord( + db, + "StructureNode", + { + id: "test-page", + label: "Test Page", + icon: "folder", + kind: "placeholder", + }, + owner + ); + expect(seen).toEqual([{ nodeId: "test-page" }]); + }); + + it("propagates generic mutation reconciliation events", () => { + const bus = new EventEmitter(); + const seen: unknown[] = []; + bus.on("object.record.created", (event) => seen.push(event)); + createRecord( + db, + def.name, + { id: "event-row", title: "Event Row" }, + { ...owner, bus, userId: "user-a" } + ); + expect(seen).toEqual([ + expect.objectContaining({ + objectType: def.name, + recordId: "event-row", + tenantId: "tenant-a", + actorId: "user-a", + source: "http", + }), + ]); + }); +}); diff --git a/apps/bridge/src/kernel/__tests__/runtime-actions.test.ts b/apps/bridge/src/kernel/__tests__/runtime-actions.test.ts new file mode 100644 index 0000000..63fd308 --- /dev/null +++ b/apps/bridge/src/kernel/__tests__/runtime-actions.test.ts @@ -0,0 +1,276 @@ +import Database from "better-sqlite3"; +import type { ObjectTypeDef } from "@godmode/kernel"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OperationContext } from "../adapter-registry.js"; +import { + CHAT_SESSION_ACTIONS, + clearRuntimeAdapterServices, + configureRuntimeAdapterServices, + chatMessageRuntimeAdapter, + chatSessionRuntimeAdapter, + modelRuntimeAdapter, + promptQueueRuntimeAdapter, + runtimeAdapterRegistrations, + type RuntimeAdapterServices, +} from "../adapters/runtime.js"; + +function definition(name: string, adapterId: string): ObjectTypeDef { + return { + name, + label: name, + labelPlural: `${name}s`, + module: "test", + storage: { kind: "adapter", adapterId }, + fields: [ + { name: "id", label: "id", fieldType: "Data" }, + { name: "title", label: "title", fieldType: "Data" }, + { name: "chat_id", label: "chat_id", fieldType: "Data" }, + { name: "role", label: "role", fieldType: "Data" }, + { name: "content", label: "content", fieldType: "JSON" }, + { name: "created_at", label: "created_at", fieldType: "Data" }, + { name: "updated_at", label: "updated_at", fieldType: "Data" }, + ], + permissions: [{ role: "owner", read: true }], + operations: ["list", "get"], + contractVersion: 1, + schemaVersion: 1, + }; +} + +const owner: OperationContext = { + tenantId: "tenant-a", + userId: "user-a", + role: "owner", + source: "http", +}; + +function fakeServices(overrides: Partial = {}) { + const llm = { + getStatus: vi.fn(() => ({ + state: "running", + healthOk: true, + pid: 42, + port: 8080, + ctxSize: 8192, + error: null, + modelPath: "C:\\secret\\model.gguf", + })), + getSamplingParams: vi.fn(() => ({})), + scanModels: vi.fn(() => []), + start: vi.fn(async () => ({ state: "running" })), + stop: vi.fn(async () => ({ state: "stopped" })), + restart: vi.fn(async () => ({ state: "running" })), + isReady: vi.fn(() => true), + getServerBaseUrl: vi.fn(() => "http://127.0.0.1:8080"), + getEnabledAdapterPaths: vi.fn(() => []), + } as unknown as RuntimeAdapterServices["llm"]; + return { + llm, + queue: { enqueue: vi.fn(() => "queue-1") }, + training: { + listJobs: vi.fn(() => []), + getJob: vi.fn(() => null), + startJob: vi.fn(async () => "training-1"), + cancelJob: vi.fn(() => false), + }, + sendMessage: vi.fn(async () => ({ ok: true, messageId: "assistant-1" })), + syncIntegration: vi.fn(async () => ({ ok: true, queued: true })), + ...overrides, + } satisfies RuntimeAdapterServices; +} + +describe("runtime ObjectType actions", () => { + let db: Database.Database; + + beforeEach(() => { + db = new Database(":memory:"); + db.exec(` + CREATE TABLE ai_chats ( + id TEXT PRIMARY KEY, title TEXT NOT NULL, user_id TEXT, + created_at TEXT NOT NULL, updated_at TEXT NOT NULL + ); + CREATE TABLE ai_messages ( + id TEXT PRIMARY KEY, chat_id TEXT NOT NULL, role TEXT NOT NULL, + content_json TEXT NOT NULL, created_at TEXT NOT NULL + ); + CREATE TABLE ai_prompt_queue ( + id TEXT PRIMARY KEY, status TEXT NOT NULL DEFAULT 'pending', + priority INTEGER NOT NULL DEFAULT 0, workflow_id TEXT, prompt TEXT, + result_json TEXT, error TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), + started_at TEXT, finished_at TEXT + ); + `); + configureRuntimeAdapterServices(fakeServices()); + }); + + afterEach(() => { + clearRuntimeAdapterServices(); + db.close(); + }); + + it("exports exact adapter IDs, named actions, and execution modes", () => { + expect(runtimeAdapterRegistrations.map((entry) => entry.adapterId)).toEqual([ + "chat_session_runtime", + "chat_message_runtime", + "model_runtime", + "prompt_queue_runtime", + "dataset_runtime", + "training_job_runtime", + "inference_runtime", + "integration_runtime", + ]); + expect(CHAT_SESSION_ACTIONS.map(({ name, execution }) => [name, execution])).toEqual([ + ["send_message", "async"], + ["confirm_tool", "sync"], + ["truncate", "sync"], + ]); + const actionNames = runtimeAdapterRegistrations.flatMap((entry) => + entry.actions.map((action) => action.name) + ); + expect(actionNames).toEqual( + expect.arrayContaining([ + "select_model", + "start", + "stop", + "restart", + "enqueue", + "cancel", + "build_dataset", + "run_inference", + "sync", + ]) + ); + }); + + it("scopes chat sessions and messages to their authenticated owner", () => { + db.prepare( + `INSERT INTO ai_chats (id, title, user_id, created_at, updated_at) + VALUES ('chat-a', 'A', 'user-a', '1', '1'), ('chat-b', 'B', 'user-b', '1', '1')` + ).run(); + db.prepare( + `INSERT INTO ai_messages (id, chat_id, role, content_json, created_at) + VALUES ('message-a', 'chat-a', 'user', '{"text":"a"}', '1'), + ('message-b', 'chat-b', 'user', '{"text":"b"}', '1')` + ).run(); + + const chats = chatSessionRuntimeAdapter.list!( + db, + definition("ChatSession", "chat_session_runtime"), + {}, + owner + ); + const messages = chatMessageRuntimeAdapter.list!( + db, + definition("ChatMessage", "chat_message_runtime"), + {}, + owner + ); + expect(chats.records.map((row) => row.id)).toEqual(["chat-a"]); + expect(messages.records.map((row) => row.id)).toEqual(["message-a"]); + expect( + chatSessionRuntimeAdapter.get!( + db, + definition("ChatSession", "chat_session_runtime"), + "chat-b", + owner + ) + ).toBeNull(); + }); + + it("delegates chat sends to the configured policy runtime", async () => { + const active = fakeServices(); + configureRuntimeAdapterServices(active); + db.prepare( + `INSERT INTO ai_chats (id, title, user_id, created_at, updated_at) + VALUES ('chat-a', 'A', 'user-a', '1', '1')` + ).run(); + + await expect( + chatSessionRuntimeAdapter.actions!.send_message( + db, + definition("ChatSession", "chat_session_runtime"), + "chat-a", + { content: "hello", agent_id: "intelligence" }, + owner + ) + ).resolves.toMatchObject({ ok: true }); + expect(active.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + db, + chatId: "chat-a", + content: "hello", + agentId: "intelligence", + context: owner, + }) + ); + }); + + it("truncates only messages after an owned chat anchor", () => { + db.prepare( + `INSERT INTO ai_chats (id, title, user_id, created_at, updated_at) + VALUES ('chat-a', 'A', 'user-a', '1', '1')` + ).run(); + db.prepare( + `INSERT INTO ai_messages (id, chat_id, role, content_json, created_at) + VALUES ('m1', 'chat-a', 'user', '{}', '2026-01-01T00:00:00Z'), + ('m2', 'chat-a', 'assistant', '{}', '2026-01-01T00:00:01Z')` + ).run(); + + expect( + chatSessionRuntimeAdapter.actions!.truncate( + db, + definition("ChatSession", "chat_session_runtime"), + "chat-a", + { after_message_id: "m1" }, + owner + ) + ).toEqual({ ok: true, deleted: 1 }); + }); + + it("uses the live queue and rejects non-operator enqueue attempts", () => { + const active = fakeServices(); + configureRuntimeAdapterServices(active); + const def = definition("PromptQueueJob", "prompt_queue_runtime"); + expect( + promptQueueRuntimeAdapter.actions!.enqueue( + db, + def, + "", + { prompt: "do work", priority: 3 }, + owner + ) + ).toEqual({ ok: true, jobId: "queue-1" }); + expect(active.queue.enqueue).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: "do work", + priority: 3, + tenantId: "tenant-a", + }) + ); + expect(() => + promptQueueRuntimeAdapter.actions!.enqueue( + db, + def, + "", + { prompt: "do work" }, + { ...owner, role: "editor" } + ) + ).toThrow(/operator role required/i); + }); + + it("does not expose model filesystem paths and owner-gates lifecycle actions", () => { + const def = definition("ModelRuntime", "model_runtime"); + const row = modelRuntimeAdapter.get!(db, def, "runtime", owner); + expect(row?.data).not.toHaveProperty("modelPath"); + expect(row?.data).not.toHaveProperty("model_path"); + expect(() => + modelRuntimeAdapter.actions!.stop( + db, + def, + "runtime", + {}, + { ...owner, role: "editor" } + ) + ).toThrow(/owner role required/i); + }); +}); diff --git a/apps/bridge/src/kernel/adapter-registry.ts b/apps/bridge/src/kernel/adapter-registry.ts new file mode 100644 index 0000000..5693f4c --- /dev/null +++ b/apps/bridge/src/kernel/adapter-registry.ts @@ -0,0 +1,122 @@ +import type { EventEmitter } from "node:events"; +import type { AppDatabase } from "../db.js"; +import type { + ActionDef, + ListRecordsResult, + ObjectTypeDef, + RecordData, + RecordRow, +} from "@godmode/kernel"; + +export type RecordOperation = "list" | "get" | "create" | "update" | "delete"; + +export interface RecordQuery { + parentId?: string | null; + limit?: number; + offset?: number; + filters?: Record; + sort?: string; + direction?: "asc" | "desc"; +} + +export interface OperationContext { + tenantId?: string; + userId?: string; + isAdmin?: boolean; + role: "viewer" | "editor" | "owner" | "intelligence"; + agentId?: string; + source: "http" | "agent" | "plugin" | "system"; + bus?: EventEmitter; + /** Plugin ids installed for the active tenant. */ + installedPluginIds?: ReadonlySet; + requestId?: string; + idempotencyKey?: string; + expectedVersion?: string; + confirmation?: { + actorId: string; + objectType: string; + action: string; + inputHash: string; + expiresAt: string; + recordId?: string; + resourceVersion?: string; + }; + confirmationId?: string; + /** Set only after the existing agent confirmation policy approved this call. */ + trustedConfirmation?: boolean; + signal?: AbortSignal; + data?: { + tenantDb: AppDatabase; + coreDb: AppDatabase; + declaredDatabase: "tenant" | "core"; + }; + /** Set only by the Bridge's internal system-context factory. */ + systemCapability?: symbol; +} + +export interface RecordPolicy { + authorize?( + operation: RecordOperation | "action", + def: ObjectTypeDef, + ctx: OperationContext, + row?: RecordRow | null, + action?: ActionDef + ): boolean | void; + project?(def: ObjectTypeDef, row: RecordRow, ctx: OperationContext): RecordRow; +} + +export interface RecordAdapter { + id: string; + policy?: RecordPolicy; + list?(db: AppDatabase, def: ObjectTypeDef, query: RecordQuery, ctx: OperationContext): ListRecordsResult; + get?(db: AppDatabase, def: ObjectTypeDef, id: string, ctx: OperationContext): RecordRow | null; + create?(db: AppDatabase, def: ObjectTypeDef, data: RecordData, ctx: OperationContext): RecordRow; + update?( + db: AppDatabase, + def: ObjectTypeDef, + id: string, + data: RecordData, + ctx: OperationContext + ): RecordRow; + delete?(db: AppDatabase, def: ObjectTypeDef, id: string, ctx: OperationContext): void; + actions?: Record< + string, + ( + db: AppDatabase, + def: ObjectTypeDef, + id: string, + input: RecordData, + ctx: OperationContext + ) => unknown | Promise + >; +} + +const adapters = new Map(); +let kernelBus: EventEmitter | undefined; + +export function setKernelEventBus(bus: EventEmitter): void { + kernelBus = bus; +} + +export function withKernelEventBus(ctx: OperationContext): OperationContext { + return ctx.bus || !kernelBus ? ctx : { ...ctx, bus: kernelBus }; +} + +export function registerRecordAdapter(adapter: RecordAdapter): void { + if (adapters.has(adapter.id)) { + throw new Error(`Record adapter already registered: ${adapter.id}`); + } + adapters.set(adapter.id, adapter); +} + +export function getRecordAdapter(id: string): RecordAdapter | undefined { + return adapters.get(id); +} + +export function hasRecordAdapter(id: string): boolean { + return adapters.has(id); +} + +export function unregisterRecordAdapter(id: string): void { + adapters.delete(id); +} diff --git a/apps/bridge/src/kernel/adapters/content.ts b/apps/bridge/src/kernel/adapters/content.ts new file mode 100644 index 0000000..f498748 --- /dev/null +++ b/apps/bridge/src/kernel/adapters/content.ts @@ -0,0 +1,988 @@ +import { v4 as uuidv4 } from "uuid"; +import type { + ActionDef, + ObjectTypeDef, + RecordData, + RecordRow, +} from "@godmode/kernel"; +import type { AppDatabase } from "../../db.js"; +import { + createRuleFile, + deleteRuleFile, + listAiRules, + setAiRuleStatus, + updateAiRuleState, +} from "../../services/ai-rules.js"; +import { + createSkillFile, + deleteSkillFile, + listAiSkills, + setAiSkillStatus, + updateAiSkillState, +} from "../../services/ai-skills.js"; +import { + deleteArtifact, + getArtifact, + listArtifacts, + readArtifact, + saveArtifact, +} from "../../services/ai-artifacts.js"; +import { + approveWikiProposal, + createWikiProposal, + listWikiProposals, + rejectWikiProposal, + type WikiPageProposal, + type WikiProposalStatus, +} from "../../services/wiki-proposals.js"; +import { + approveReflectionProposal, + createReflectionProposal, + listReflectionProposals, + rejectReflectionProposal, + type ReflectionProposal, +} from "../../services/reflection-proposals.js"; +import { + clearNotifications, + createNotification, + deleteNotification, + listNotificationsForAgent, + listNotificationsForUser, + markAllRead, + markRead, + type NotificationRecipient, +} from "../../services/notification-service.js"; +import { + indexMemory, + removeMemoryFromIndex, +} from "../../services/embeddings/memory-embeddings.js"; +import type { + OperationContext, + RecordAdapter, + RecordQuery, +} from "../adapter-registry.js"; + +export const CONTENT_LIFECYCLE_ACTIONS: ActionDef[] = [ + { + name: "approve", + label: "Approve", + target: "record", + effect: "write", + execution: "sync", + roles: ["editor", "owner", "intelligence"], + idempotency: { required: true }, + inputSchema: { type: "object", additionalProperties: false }, + }, + { + name: "reject", + label: "Reject", + target: "record", + effect: "destructive", + execution: "sync", + roles: ["editor", "owner", "intelligence"], + confirmation: { required: true, ttlSeconds: 300 }, + idempotency: { required: true }, + inputSchema: { type: "object", additionalProperties: false }, + }, +]; + +export const NOTIFICATION_ACTIONS: ActionDef[] = [ + { + name: "mark_read", + label: "Mark Read", + target: "record", + effect: "write", + execution: "sync", + roles: ["viewer", "editor", "owner", "intelligence"], + inputSchema: { type: "object", additionalProperties: false }, + }, + { + name: "mark_all_read", + label: "Mark All Read", + target: "collection", + effect: "write", + execution: "sync", + roles: ["viewer", "editor", "owner", "intelligence"], + inputSchema: { type: "object", additionalProperties: false }, + }, + { + name: "clear", + label: "Clear", + target: "collection", + effect: "destructive", + execution: "sync", + roles: ["viewer", "editor", "owner", "intelligence"], + confirmation: { required: true, ttlSeconds: 300 }, + idempotency: { required: true }, + inputSchema: { + type: "object", + additionalProperties: false, + properties: { read_only: { type: "boolean" } }, + }, + }, +]; + +type DataRow = Record; + +function record(def: ObjectTypeDef, id: string, data: RecordData): RecordRow { + return { id, objectType: def.name, data: { id, ...data } }; +} + +function page(rows: T[], query: RecordQuery): { rows: T[]; total: number } { + const offset = Math.max(Number(query.offset) || 0, 0); + const limit = Math.min(Math.max(Number(query.limit) || 100, 1), 500); + return { rows: rows.slice(offset, offset + limit), total: rows.length }; +} + +function requiredText(data: RecordData, name: string): string { + const value = data[name]; + if (typeof value !== "string" || !value.trim()) { + throw Object.assign(new Error(`${name} required`), { status: 400 }); + } + return value.trim(); +} + +function notFound(label: string): never { + throw Object.assign(new Error(`${label} not found`), { status: 404 }); +} + +function agentId(ctx: OperationContext): string { + return ctx.agentId ?? "intelligence"; +} + +function agentClause(ctx: OperationContext): { sql: string; params: string[] } { + const id = agentId(ctx); + return id === "intelligence" + ? { sql: `(agent_id = ? OR agent_id IS NULL)`, params: [id] } + : { sql: `agent_id = ?`, params: [id] }; +} + +function coreDb(db: AppDatabase, ctx: OperationContext): AppDatabase { + return ctx.data?.coreDb ?? db; +} + +function jsonArray(value: unknown, fallback: string[] = []): string[] { + if (!Array.isArray(value)) return fallback; + return value.map(String); +} + +function parseJson(value: unknown): unknown { + if (typeof value !== "string") return value; + try { + return JSON.parse(value); + } catch { + return value; + } +} + +function memoryRecord( + def: ObjectTypeDef, + row: DataRow +): RecordRow { + return record(def, String(row.id), { + agent_id: row.agent_id ?? "intelligence", + scope: row.scope, + chat_id: row.chat_id, + text: row.text, + category: row.category, + source: row.source, + enabled: Boolean(row.enabled), + status: row.status, + pack_id: row.pack_id, + valid_from: row.valid_from, + valid_until: row.valid_until, + created_at: row.created_at, + updated_at: row.updated_at, + }); +} + +function memoryRow( + db: AppDatabase, + id: string, + ctx: OperationContext +): DataRow | undefined { + const scope = agentClause(ctx); + return db + .prepare( + `SELECT id, agent_id, scope, chat_id, text, category, source, enabled, + status, pack_id, valid_from, valid_until, created_at, updated_at + FROM ai_memories WHERE id = ? AND ${scope.sql}` + ) + .get(id, ...scope.params) as DataRow | undefined; +} + +export const memoryServiceAdapter: RecordAdapter = { + id: "memory_service", + list(db, def, query, ctx) { + const scope = agentClause(ctx); + const filters = query.filters ?? {}; + const where = [scope.sql]; + const params: unknown[] = [...scope.params]; + if (filters.status === "active" || filters.status === "pending") { + where.push("status = ?"); + params.push(filters.status); + } + if (typeof filters.scope === "string") { + where.push("scope = ?"); + params.push(filters.scope); + } + if (typeof filters.chat_id === "string") { + where.push("(scope = 'global' OR (scope = 'chat' AND chat_id = ?))"); + params.push(filters.chat_id); + } + const total = ( + db + .prepare(`SELECT COUNT(*) AS c FROM ai_memories WHERE ${where.join(" AND ")}`) + .get(...params) as { c: number } + ).c; + const offset = Math.max(Number(query.offset) || 0, 0); + const limit = Math.min(Math.max(Number(query.limit) || 100, 1), 500); + const rows = db + .prepare( + `SELECT id, agent_id, scope, chat_id, text, category, source, enabled, + status, pack_id, valid_from, valid_until, created_at, updated_at + FROM ai_memories WHERE ${where.join(" AND ")} + ORDER BY updated_at DESC LIMIT ? OFFSET ?` + ) + .all(...params, limit, offset) as DataRow[]; + return { + objectType: def.name, + records: rows.map((row) => memoryRecord(def, row)), + total, + }; + }, + get(db, def, id, ctx) { + const row = memoryRow(db, id, ctx); + return row ? memoryRecord(def, row) : null; + }, + create(db, def, data, ctx) { + const text = requiredText(data, "text"); + const scope = data.scope === "chat" ? "chat" : "global"; + const chatId = + scope === "chat" && typeof data.chat_id === "string" && data.chat_id + ? data.chat_id + : null; + if (scope === "chat" && !chatId) { + throw Object.assign(new Error("chat_id required for chat memory"), { + status: 400, + }); + } + const id = + typeof data.id === "string" && data.id.trim() ? data.id.trim() : uuidv4(); + const status = data.status === "pending" ? "pending" : "active"; + db.prepare( + `INSERT INTO ai_memories + (id, agent_id, scope, chat_id, text, category, source, enabled, status, + pack_id, valid_from, valid_until) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + id, + agentId(ctx), + scope, + chatId, + text, + data.category ?? null, + typeof data.source === "string" ? data.source : "manual", + data.enabled === false ? 0 : 1, + status, + data.pack_id ?? null, + data.valid_from ?? null, + data.valid_until ?? null + ); + if (status === "active" && data.enabled !== false) { + indexMemory(db, null, id, text); + } + return memoryRecord(def, memoryRow(db, id, ctx)!); + }, + update(db, def, id, data, ctx) { + const existing = memoryRow(db, id, ctx); + if (!existing) notFound("Memory"); + const writable = new Set([ + "text", + "category", + "enabled", + "status", + "valid_from", + "valid_until", + ]); + const sets: string[] = []; + const values: unknown[] = []; + for (const [name, value] of Object.entries(data)) { + if (!writable.has(name)) continue; + if (name === "text" && (typeof value !== "string" || !value.trim())) { + throw Object.assign(new Error("text required"), { status: 400 }); + } + if (name === "status" && value !== "active" && value !== "pending") { + throw Object.assign(new Error("status must be active or pending"), { + status: 400, + }); + } + sets.push(`"${name}" = ?`); + values.push(name === "enabled" ? (value ? 1 : 0) : value ?? null); + } + if (sets.length) { + sets.push(`updated_at = datetime('now')`); + const scope = agentClause(ctx); + db.prepare( + `UPDATE ai_memories SET ${sets.join(", ")} + WHERE id = ? AND ${scope.sql}` + ).run(...values, id, ...scope.params); + } + const row = memoryRow(db, id, ctx)!; + if (row.status === "active" && Boolean(row.enabled)) { + indexMemory(db, null, id, String(row.text)); + } else { + removeMemoryFromIndex(db, id); + } + return memoryRecord(def, row); + }, + delete(db, _def, id, ctx) { + if (!memoryRow(db, id, ctx)) notFound("Memory"); + removeMemoryFromIndex(db, id); + const scope = agentClause(ctx); + db.prepare(`DELETE FROM ai_memories WHERE id = ? AND ${scope.sql}`).run( + id, + ...scope.params + ); + }, + actions: { + approve(db, def, id, _input, ctx) { + return memoryServiceAdapter.update!(db, def, id, { status: "active" }, ctx); + }, + reject(db, _def, id, _input, ctx) { + const row = memoryRow(db, id, ctx); + if (!row || row.status !== "pending") notFound("Pending memory"); + memoryServiceAdapter.delete!(db, _def, id, ctx); + return { ok: true }; + }, + }, +}; + +function ruleRecord( + def: ObjectTypeDef, + row: ReturnType[number] +): RecordRow { + return record(def, row.id, { + agent_id: row.agentId ?? "intelligence", + description: row.description, + body: row.body, + always_apply: row.alwaysApply, + globs_json: row.globs, + departments_json: row.departments, + priority: row.priority, + enabled: row.enabled, + status: row.status, + version: row.version, + updated_at: row.updatedAt, + }); +} + +function findRule(db: AppDatabase, id: string, ctx: OperationContext) { + return listAiRules(db, agentId(ctx)).find((row) => row.id === id); +} + +function assertOwnedKnowledge( + db: AppDatabase, + table: "ai_rules" | "ai_skills", + id: string, + ctx: OperationContext, + label: string +): void { + const row = db + .prepare(`SELECT agent_id, status FROM ${table} WHERE id = ?`) + .get(id) as { agent_id: string | null; status: string } | undefined; + if (!row || (row.agent_id ?? "intelligence") !== agentId(ctx)) { + throw Object.assign(new Error(`${label} is shared or not owned by this agent`), { + status: 403, + }); + } +} + +export const ruleServiceAdapter: RecordAdapter = { + id: "rule_service", + list(db, def, query, ctx) { + const result = page(listAiRules(db, agentId(ctx)), query); + return { + objectType: def.name, + records: result.rows.map((row) => ruleRecord(def, row)), + total: result.total, + }; + }, + get(db, def, id, ctx) { + const row = findRule(db, id, ctx); + return row ? ruleRecord(def, row) : null; + }, + create(db, def, data, ctx) { + const description = requiredText(data, "description"); + const id = createRuleFile( + db, + agentId(ctx), + { + name: + typeof data.id === "string" && data.id.trim() + ? data.id + : description, + description, + body: requiredText(data, "body"), + globs: jsonArray(data.globs_json), + departments: jsonArray(data.departments_json), + alwaysApply: + typeof data.always_apply === "boolean" + ? data.always_apply + : undefined, + priority: + typeof data.priority === "number" ? data.priority : undefined, + }, + data.status === "active" ? "active" : "pending" + ); + return ruleRecord(def, findRule(db, id, ctx)!); + }, + update(db, def, id, data, ctx) { + if (!findRule(db, id, ctx)) notFound("Rule"); + updateAiRuleState(db, agentId(ctx), id, { + enabled: + typeof data.enabled === "boolean" ? data.enabled : undefined, + priorityOverride: + typeof data.priority === "number" ? data.priority : undefined, + }); + return ruleRecord(def, findRule(db, id, ctx)!); + }, + delete(db, _def, id, ctx) { + assertOwnedKnowledge(db, "ai_rules", id, ctx, "Rule"); + if (!deleteRuleFile(db, id)) notFound("Rule"); + }, + actions: { + approve(db, def, id, _input, ctx) { + if (!findRule(db, id, ctx)) notFound("Rule"); + setAiRuleStatus(db, agentId(ctx), id, "active"); + return ruleRecord(def, findRule(db, id, ctx)!); + }, + reject(db, _def, id, _input, ctx) { + assertOwnedKnowledge(db, "ai_rules", id, ctx, "Rule"); + const row = db + .prepare(`SELECT status FROM ai_rules WHERE id = ?`) + .get(id) as { status: string }; + if (row.status !== "pending") { + throw Object.assign(new Error("Only pending rules can be rejected"), { + status: 409, + }); + } + if (!deleteRuleFile(db, id)) notFound("Rule"); + return { ok: true }; + }, + }, +}; + +function skillRecord( + def: ObjectTypeDef, + row: ReturnType[number] +): RecordRow { + return record(def, row.id, { + agent_id: row.agentId ?? "intelligence", + name: row.name, + description: row.description, + body: row.body, + tools_json: row.tools, + departments_json: row.departments, + enabled: row.enabled, + status: row.status, + version: row.version, + updated_at: row.updatedAt, + }); +} + +function findSkill(db: AppDatabase, id: string, ctx: OperationContext) { + return listAiSkills(db, true, agentId(ctx)).find((row) => row.id === id); +} + +export const skillServiceAdapter: RecordAdapter = { + id: "skill_service", + list(db, def, query, ctx) { + const result = page(listAiSkills(db, true, agentId(ctx)), query); + return { + objectType: def.name, + records: result.rows.map((row) => skillRecord(def, row)), + total: result.total, + }; + }, + get(db, def, id, ctx) { + const row = findSkill(db, id, ctx); + return row ? skillRecord(def, row) : null; + }, + create(db, def, data, ctx) { + const id = createSkillFile( + db, + agentId(ctx), + { + name: requiredText(data, "name"), + description: + typeof data.description === "string" ? data.description : "", + body: requiredText(data, "body"), + tools: jsonArray(data.tools_json), + departments: jsonArray(data.departments_json), + }, + data.status === "active" ? "active" : "pending" + ); + return skillRecord(def, findSkill(db, id, ctx)!); + }, + update(db, def, id, data, ctx) { + if (!findSkill(db, id, ctx)) notFound("Skill"); + if (typeof data.enabled === "boolean") { + updateAiSkillState(db, agentId(ctx), id, data.enabled); + } + return skillRecord(def, findSkill(db, id, ctx)!); + }, + delete(db, _def, id, ctx) { + assertOwnedKnowledge(db, "ai_skills", id, ctx, "Skill"); + if (!deleteSkillFile(db, id)) notFound("Skill"); + }, + actions: { + approve(db, def, id, _input, ctx) { + if (!findSkill(db, id, ctx)) notFound("Skill"); + setAiSkillStatus(db, agentId(ctx), id, "active"); + return skillRecord(def, findSkill(db, id, ctx)!); + }, + reject(db, _def, id, _input, ctx) { + assertOwnedKnowledge(db, "ai_skills", id, ctx, "Skill"); + const row = db + .prepare(`SELECT status FROM ai_skills WHERE id = ?`) + .get(id) as { status: string }; + if (row.status !== "pending") { + throw Object.assign(new Error("Only pending skills can be rejected"), { + status: 409, + }); + } + if (!deleteSkillFile(db, id)) notFound("Skill"); + return { ok: true }; + }, + }, +}; + +function artifactRecord( + def: ObjectTypeDef, + row: NonNullable> & { has_content?: number } +): RecordRow { + return record(def, row.id, { + agent_id: row.agent_id, + name: row.name, + kind: row.kind, + mime_type: row.mime_type, + size_bytes: row.size_bytes, + description: row.description, + source: row.source, + has_content: Boolean(row.has_content ?? row.size_bytes), + created_at: row.created_at, + updated_at: row.updated_at, + }); +} + +export const artifactServiceAdapter: RecordAdapter = { + id: "artifact_service", + list(db, def, query, ctx) { + const rows = listArtifacts(db, agentId(ctx), 500); + const result = page(rows, query); + return { + objectType: def.name, + records: result.rows.map((row) => artifactRecord(def, row)), + total: rows.length, + }; + }, + get(db, def, id, ctx) { + const row = getArtifact(db, agentId(ctx), id); + return row ? artifactRecord(def, row) : null; + }, + create(db, def, data, ctx) { + return artifactRecord( + def, + saveArtifact(db, agentId(ctx), { + name: requiredText(data, "name"), + content: typeof data.content === "string" ? data.content : "", + kind: typeof data.kind === "string" ? data.kind : undefined, + mimeType: + typeof data.mime_type === "string" ? data.mime_type : undefined, + description: + typeof data.description === "string" ? data.description : undefined, + source: typeof data.source === "string" ? data.source : "manual", + }) + ); + }, + update(db, def, id, data, ctx) { + const existing = getArtifact(db, agentId(ctx), id); + if (!existing) notFound("Artifact"); + if (typeof data.name === "string" && data.name !== existing.name) { + throw Object.assign(new Error("Artifact name cannot be changed"), { + status: 400, + }); + } + const content = + typeof data.content === "string" + ? data.content + : readArtifact(db, agentId(ctx), id).content; + return artifactRecord( + def, + saveArtifact(db, agentId(ctx), { + name: existing.name, + content, + kind: typeof data.kind === "string" ? data.kind : existing.kind, + mimeType: + data.mime_type === null + ? undefined + : typeof data.mime_type === "string" + ? data.mime_type + : existing.mime_type ?? undefined, + description: + data.description === null + ? undefined + : typeof data.description === "string" + ? data.description + : existing.description ?? undefined, + source: + typeof data.source === "string" ? data.source : existing.source, + }) + ); + }, + delete(db, _def, id, ctx) { + if (!deleteArtifact(db, agentId(ctx), id)) notFound("Artifact"); + }, +}; + +function wikiProposalRecord( + def: ObjectTypeDef, + row: WikiPageProposal +): RecordRow { + return record(def, row.id, { ...row }); +} + +function wikiProposal( + db: AppDatabase, + id: string, + ctx: OperationContext +): WikiPageProposal | undefined { + if (!ctx.tenantId) return undefined; + return listWikiProposals( + { tenantId: ctx.tenantId, status: "all" }, + coreDb(db, ctx) + ).find((row) => row.id === id); +} + +export const wikiProposalServiceAdapter: RecordAdapter = { + id: "wiki_proposal_service", + list(db, def, query, ctx) { + if (!ctx.tenantId) { + throw Object.assign(new Error("Tenant required"), { status: 401 }); + } + const status = + query.filters?.status === "all" || + query.filters?.status === "pending" || + query.filters?.status === "approved" || + query.filters?.status === "rejected" + ? (query.filters.status as WikiProposalStatus | "all") + : "all"; + const rows = listWikiProposals( + { tenantId: ctx.tenantId, status }, + coreDb(db, ctx) + ); + const result = page(rows, query); + return { + objectType: def.name, + records: result.rows.map((row) => wikiProposalRecord(def, row)), + total: result.total, + }; + }, + get(db, def, id, ctx) { + const row = wikiProposal(db, id, ctx); + return row ? wikiProposalRecord(def, row) : null; + }, + create(db, def, data, ctx) { + if (!ctx.tenantId) { + throw Object.assign(new Error("Tenant required"), { status: 401 }); + } + const action = data.action === "update" ? "update" : "create"; + if (action === "update" && typeof data.target_page_id !== "string") { + throw Object.assign(new Error("target_page_id required for update"), { + status: 400, + }); + } + return wikiProposalRecord( + def, + createWikiProposal( + { + tenantId: ctx.tenantId, + action, + title: requiredText(data, "title"), + bodyMarkdown: + typeof data.body_markdown === "string" ? data.body_markdown : "", + space: data.space as string | null | undefined, + slug: data.slug as string | null | undefined, + targetPageId: data.target_page_id as string | null | undefined, + reason: data.reason as string | null | undefined, + source: typeof data.source === "string" ? data.source : "manual", + }, + coreDb(db, ctx) + ) + ); + }, + actions: { + approve(db, _def, id, _input, ctx) { + const row = wikiProposal(db, id, ctx); + if (!row) notFound("Wiki proposal"); + if (!ctx.userId || !ctx.tenantId) { + throw Object.assign(new Error("Tenant and user required"), { + status: 401, + }); + } + const result = approveWikiProposal( + id, + { + authorUserId: ctx.userId, + scope: { tenantIds: [ctx.tenantId] }, + }, + coreDb(db, ctx) + ); + if (!result.ok) { + throw Object.assign(new Error(result.error ?? "Approval failed"), { + status: 409, + }); + } + return result; + }, + reject(db, _def, id, _input, ctx) { + if (!wikiProposal(db, id, ctx)) notFound("Wiki proposal"); + if (!rejectWikiProposal(id, coreDb(db, ctx))) { + throw Object.assign(new Error("Wiki proposal is not pending"), { + status: 409, + }); + } + return { ok: true }; + }, + }, +}; + +function reflectionRecord( + def: ObjectTypeDef, + row: ReflectionProposal +): RecordRow { + return record(def, row.id, { + agent_id: row.agent_id, + kind: row.kind, + target_id: row.target_id, + action: row.action, + payload_json: parseJson(row.payload_json), + status: row.status, + created_at: row.created_at, + updated_at: row.updated_at, + }); +} + +function reflectionProposal( + db: AppDatabase, + id: string, + ctx: OperationContext +): ReflectionProposal | undefined { + return listReflectionProposals(db, agentId(ctx), "all").find( + (row) => row.id === id + ); +} + +export const reflectionProposalServiceAdapter: RecordAdapter = { + id: "reflection_proposal_service", + list(db, def, query, ctx) { + const requested = query.filters?.status; + const status = + requested === "pending" || + requested === "approved" || + requested === "rejected" || + requested === "all" + ? requested + : "all"; + const rows = listReflectionProposals(db, agentId(ctx), status); + const result = page(rows, query); + return { + objectType: def.name, + records: result.rows.map((row) => reflectionRecord(def, row)), + total: result.total, + }; + }, + get(db, def, id, ctx) { + const row = reflectionProposal(db, id, ctx); + return row ? reflectionRecord(def, row) : null; + }, + create(db, def, data, ctx) { + const id = createReflectionProposal(db, { + agentId: agentId(ctx), + kind: requiredText(data, "kind") as never, + targetId: requiredText(data, "target_id"), + action: requiredText(data, "action") as never, + payload: + data.payload_json && typeof data.payload_json === "object" + ? (data.payload_json as never) + : undefined, + }); + return reflectionRecord(def, reflectionProposal(db, id, ctx)!); + }, + actions: { + approve(db, _def, id, _input, ctx) { + if (!reflectionProposal(db, id, ctx)) notFound("Reflection proposal"); + if (!approveReflectionProposal(db, id)) { + throw Object.assign(new Error("Reflection proposal is not pending"), { + status: 409, + }); + } + return { ok: true }; + }, + reject(db, _def, id, _input, ctx) { + if (!reflectionProposal(db, id, ctx)) notFound("Reflection proposal"); + if (!rejectReflectionProposal(db, id)) { + throw Object.assign(new Error("Reflection proposal is not pending"), { + status: 409, + }); + } + return { ok: true }; + }, + }, +}; + +function recipient(ctx: OperationContext): NotificationRecipient { + if (ctx.source === "agent" || (!ctx.userId && ctx.agentId)) { + if (!ctx.agentId) { + throw Object.assign(new Error("Agent required"), { status: 401 }); + } + return { kind: "agent", id: ctx.agentId }; + } + if (!ctx.userId) { + throw Object.assign(new Error("Authenticated user required"), { + status: 401, + }); + } + return { kind: "user", id: ctx.userId }; +} + +type NotificationRow = ReturnType[number]; + +function notificationRecord( + def: ObjectTypeDef, + row: NotificationRow +): RecordRow { + return record(def, row.id, { + recipient_kind: row.recipient_kind, + recipient_id: row.recipient_id, + recipient_tenant_id: row.recipient_tenant_id, + category: row.category, + title: row.title, + body: row.body, + link: row.link, + resource_kind: row.resource_kind, + resource_id: row.resource_id, + read_at: row.read_at, + created_at: row.created_at, + }); +} + +function notifications( + db: AppDatabase, + query: RecordQuery, + ctx: OperationContext +): NotificationRow[] { + const owner = recipient(ctx); + const opts = { + unreadOnly: query.filters?.unread === true, + limit: 200, + }; + return owner.kind === "user" + ? listNotificationsForUser(owner.id, opts, coreDb(db, ctx)) + : listNotificationsForAgent( + owner.id, + ctx.tenantId ?? null, + opts, + coreDb(db, ctx) + ); +} + +function notification( + db: AppDatabase, + id: string, + ctx: OperationContext +): NotificationRow | undefined { + const owner = recipient(ctx); + const tenantGuard = + owner.kind === "agent" && ctx.tenantId + ? `AND (recipient_tenant_id = ? OR recipient_tenant_id IS NULL)` + : ""; + const params = + owner.kind === "agent" && ctx.tenantId + ? [id, owner.kind, owner.id, ctx.tenantId] + : [id, owner.kind, owner.id]; + return coreDb(db, ctx) + .prepare( + `SELECT * FROM notifications + WHERE id = ? AND recipient_kind = ? AND recipient_id = ? ${tenantGuard}` + ) + .get(...params) as NotificationRow | undefined; +} + +export const notificationServiceAdapter: RecordAdapter = { + id: "notification_service", + list(db, def, query, ctx) { + const rows = notifications(db, query, ctx); + const result = page(rows, query); + return { + objectType: def.name, + records: result.rows.map((row) => notificationRecord(def, row)), + total: rows.length, + }; + }, + get(db, def, id, ctx) { + const row = notification(db, id, ctx); + return row ? notificationRecord(def, row) : null; + }, + create(db, def, data, ctx) { + const owner = recipient(ctx); + return notificationRecord( + def, + createNotification( + { + recipientKind: owner.kind, + recipientId: owner.id, + recipientTenantId: + owner.kind === "agent" ? ctx.tenantId ?? null : ctx.tenantId ?? null, + category: + typeof data.category === "string" ? data.category : undefined, + title: requiredText(data, "title"), + body: data.body as string | null | undefined, + link: data.link as string | null | undefined, + resourceKind: data.resource_kind as string | null | undefined, + resourceId: data.resource_id as string | null | undefined, + }, + coreDb(db, ctx) + ) + ); + }, + delete(db, _def, id, ctx) { + if ( + !deleteNotification(id, recipient(ctx), coreDb(db, ctx)) + ) { + notFound("Notification"); + } + }, + actions: { + mark_read(db, def, id, _input, ctx) { + if (!notification(db, id, ctx)) notFound("Notification"); + markRead([id], coreDb(db, ctx)); + return notificationRecord(def, notification(db, id, ctx)!); + }, + mark_all_read(db, _def, _id, _input, ctx) { + return { + changed: markAllRead(recipient(ctx), coreDb(db, ctx)), + }; + }, + clear(db, _def, _id, input, ctx) { + return { + deleted: clearNotifications( + recipient(ctx), + { readOnly: input.read_only !== false }, + coreDb(db, ctx) + ), + }; + }, + }, +}; diff --git a/apps/bridge/src/kernel/adapters/core-services.ts b/apps/bridge/src/kernel/adapters/core-services.ts new file mode 100644 index 0000000..c1c36a8 --- /dev/null +++ b/apps/bridge/src/kernel/adapters/core-services.ts @@ -0,0 +1,1044 @@ +import type { ObjectTypeDef, RecordData, RecordRow } from "@godmode/kernel"; +import { v4 as uuidv4 } from "uuid"; +import type { AppDatabase } from "../../db.js"; +import { getCoreDb } from "../../core-db.js"; +import { + createWorkflow, + deleteWorkflow, + getWorkflow, + listWorkflows, + updateWorkflow, +} from "../../services/ai-workflows.js"; +import { + getReflectionConfig, + patchReflectionConfig, + type AgentReflectionConfig, +} from "../../services/reflection-config.js"; +import { + createSchedule, + deleteSchedule, + getSchedule, + listSchedules, + reloadAiSchedules, + updateSchedule, +} from "../../services/ai-scheduler.js"; +import { + createAgent, + deleteAgent, + getAgent, + listAgents, + updateAgent, +} from "../../services/agents/agents-db.js"; +import { + deleteHook, + getHook, + getHookForRun, + listHooks, + createHook, + updateHook, + type HookOwnerScope, +} from "../../services/hook-service.js"; +import { + approveHookRun, + rejectHookRun, +} from "../../services/hook-dispatcher.js"; +import { refreshScheduler } from "../../services/scheduler.js"; +import { + getAssignment, + listAssignments, + setAssignment, + type AiAgentAssignment, + type AssignmentScopeType, +} from "../../services/ai-agent-assignments.js"; +import type { OperationContext, RecordAdapter, RecordQuery } from "../adapter-registry.js"; +import { + createPage, + deletePage, + getPageById, + listPages, + updatePage, + type WikiScope, +} from "../../services/wiki-service.js"; + +function page(rows: T[], query: RecordQuery): { rows: T[]; total: number } { + const offset = Math.max(Number(query.offset) || 0, 0); + const limit = Math.min(Math.max(Number(query.limit) || 100, 1), 500); + return { rows: rows.slice(offset, offset + limit), total: rows.length }; +} + +function record(def: ObjectTypeDef, id: string, data: RecordData): RecordRow { + return { id, objectType: def.name, data: { id, ...data } }; +} + +function requiredText(data: RecordData, name: string): string { + const value = data[name]; + if (typeof value !== "string" || !value.trim()) { + throw Object.assign(new Error(`${name} required`), { status: 400 }); + } + return value.trim(); +} + +function jsonText(value: unknown): string | null | undefined { + if (value === undefined) return undefined; + if (value === null) return null; + return typeof value === "string" ? value : JSON.stringify(value); +} + +function parseJson(value: string | null): unknown { + if (value == null) return value; + try { + return JSON.parse(value); + } catch { + return value; + } +} + +function redactCredentials(value: unknown): unknown { + if (Array.isArray(value)) return value.map(redactCredentials); + if (!value || typeof value !== "object") return value; + const redacted: Record = {}; + for (const [key, entry] of Object.entries(value as Record)) { + if ( + /^(authorization|headers?|api[_-]?key|token|secret|password|credential)$/i.test( + key + ) + ) { + redacted[key] = "[REDACTED]"; + } else { + redacted[key] = redactCredentials(entry); + } + } + return redacted; +} + +function notFound(message: string): never { + throw Object.assign(new Error(message), { status: 404 }); +} + +function conflict(message: string): never { + throw Object.assign(new Error(message), { status: 409 }); +} + +function enqueueAiJob( + db: AppDatabase, + input: { + tenantId?: string; + workflowId?: string; + prompt?: string; + context?: Record; + priority?: number; + } +): string { + const id = uuidv4(); + db.prepare( + `INSERT INTO ai_prompt_queue + (id, status, priority, workflow_id, adapter_ids_json, prompt, context_json, tenant_id) + VALUES (?, 'pending', ?, ?, NULL, ?, ?, ?)` + ).run( + id, + Number.isFinite(input.priority) ? Number(input.priority) : 0, + input.workflowId ?? null, + input.prompt ?? null, + input.context ? JSON.stringify(input.context) : null, + input.tenantId ?? null + ); + return id; +} + +function workflowRecord(def: ObjectTypeDef, row: ReturnType extends infer T ? NonNullable : never): RecordRow { + return record(def, row.id, { + agent_id: row.agent_id, + name: row.name, + config_json: row.config, + enabled: Boolean(row.enabled), + created_at: row.created_at, + updated_at: row.updated_at, + }); +} + +export const workflowServiceAdapter: RecordAdapter = { + id: "workflow_service", + list(db, def, query) { + const result = page(listWorkflows(db), query); + return { + objectType: def.name, + records: result.rows.map((row) => workflowRecord(def, row)), + total: result.total, + }; + }, + get: (db, def, id) => { + const row = getWorkflow(db, id); + return row ? workflowRecord(def, row) : null; + }, + create(db, def, data, ctx) { + return workflowRecord( + def, + createWorkflow(db, { + name: requiredText(data, "name"), + agentId: + typeof data.agent_id === "string" + ? data.agent_id + : ctx.agentId ?? null, + config: + data.config_json && typeof data.config_json === "object" + ? (data.config_json as never) + : undefined, + enabled: data.enabled === undefined ? undefined : Boolean(data.enabled), + }) + ); + }, + update(db, def, id, data) { + const row = updateWorkflow(db, id, { + name: typeof data.name === "string" ? data.name : undefined, + agentId: + data.agent_id === undefined ? undefined : (data.agent_id as string | null), + config: + data.config_json && typeof data.config_json === "object" + ? (data.config_json as never) + : undefined, + enabled: data.enabled === undefined ? undefined : Boolean(data.enabled), + }); + if (!row) throw Object.assign(new Error("Workflow not found"), { status: 404 }); + return workflowRecord(def, row); + }, + delete(db, _def, id, ctx) { + if (!getWorkflow(db, id)) notFound("Workflow not found"); + + const coreDb = getCoreDb(); + const hookIds: string[] = []; + const rows = coreDb + .prepare( + `SELECT id, action_config_json FROM hooks + WHERE action_kind = 'run_workflow' + AND owner_tenant_id = ?` + ) + .all(ctx.tenantId ?? "") as Array<{ + id: string; + action_config_json: string | null; + }>; + for (const row of rows) { + const config = parseJson(row.action_config_json); + if ( + config && + typeof config === "object" && + (config as Record).workflowId === id + ) { + hookIds.push(row.id); + } + } + + db.transaction(() => { + const tableExists = (table: string) => + Boolean( + db + .prepare( + `SELECT 1 FROM sqlite_master WHERE type='table' AND name = ?` + ) + .get(table) + ); + if (tableExists("ai_prompt_queue")) { + db.prepare( + `DELETE FROM ai_prompt_queue + WHERE workflow_id = ? AND status = 'pending'` + ).run(id); + } + for (const table of [ + "ai_schedules", + "ai_workflow_runs", + "ai_workflow_comments", + ]) { + if (tableExists(table)) { + db.prepare(`DELETE FROM "${table}" WHERE workflow_id = ?`).run(id); + } + } + if (!deleteWorkflow(db, id)) notFound("Workflow not found"); + })(); + + if (hookIds.length) { + coreDb.transaction(() => { + const remove = coreDb.prepare(`DELETE FROM hooks WHERE id = ?`); + for (const hookId of hookIds) remove.run(hookId); + })(); + } + reloadAiSchedules(); + refreshScheduler(); + }, + actions: { + run(db, _def, id, input, ctx) { + if (!getWorkflow(db, id)) notFound("Workflow not found"); + const jobId = enqueueAiJob(db, { + tenantId: ctx.tenantId, + workflowId: id, + prompt: + typeof input.trigger_input === "string" ? input.trigger_input : undefined, + context: + typeof input.card_id === "string" && input.card_id + ? { cardId: input.card_id } + : undefined, + priority: 2, + }); + return { ok: true, jobId }; + }, + }, +}; + +function workflowRunRecord( + def: ObjectTypeDef, + row: Record +): RecordRow { + return record(def, String(row.id), { + workflow_id: row.workflow_id, + status: row.status, + trigger_input: row.trigger_input, + state_json: + typeof row.state_json === "string" + ? parseJson(row.state_json) + : row.state_json, + awaiting_node_id: row.awaiting_node_id, + card_id: row.card_id, + result_json: + typeof row.result_json === "string" + ? parseJson(row.result_json) + : row.result_json, + error: row.error, + created_at: row.created_at, + updated_at: row.updated_at, + }); +} + +export const workflowRunServiceAdapter: RecordAdapter = { + id: "workflow_run_read", + list(db, def, query) { + const offset = Math.max(Number(query.offset) || 0, 0); + const limit = Math.min(Math.max(Number(query.limit) || 100, 1), 500); + const total = ( + db.prepare(`SELECT COUNT(*) AS c FROM ai_workflow_runs`).get() as { + c: number; + } + ).c; + const rows = db + .prepare( + `SELECT * FROM ai_workflow_runs + ORDER BY updated_at DESC LIMIT ? OFFSET ?` + ) + .all(limit, offset) as Record[]; + return { + objectType: def.name, + records: rows.map((row) => workflowRunRecord(def, row)), + total, + }; + }, + get(db, def, id) { + const row = db + .prepare(`SELECT * FROM ai_workflow_runs WHERE id = ?`) + .get(id) as Record | undefined; + return row ? workflowRunRecord(def, row) : null; + }, + actions: { + resume(db, _def, id, input, ctx) { + const run = db + .prepare( + `SELECT id, status, card_id FROM ai_workflow_runs WHERE id = ?` + ) + .get(id) as + | { id: string; status: string; card_id: string | null } + | undefined; + if (!run) notFound("Workflow run not found"); + if (run.status !== "awaiting_input") { + conflict(`Run is not awaiting input (status=${run.status})`); + } + const decision = + input.decision === "request_changes" ? "request_changes" : "approve"; + const comments = + typeof input.comments === "string" && input.comments.trim() + ? input.comments.trim() + : undefined; + if (decision === "request_changes" && comments && run.card_id) { + db.prepare( + `INSERT INTO ai_card_comments (id, card_id, author, body) + VALUES (?, ?, 'user', ?)` + ).run(uuidv4(), run.card_id, comments); + } + const jobId = enqueueAiJob(db, { + tenantId: ctx.tenantId, + context: { + resumeRunId: run.id, + resumeDecision: { decision, comments }, + }, + priority: 3, + }); + return { ok: true, jobId }; + }, + cancel(db, _def, id) { + const exists = db + .prepare(`SELECT status FROM ai_workflow_runs WHERE id = ?`) + .get(id) as { status: string } | undefined; + if (!exists) notFound("Workflow run not found"); + const changed = db + .prepare( + `UPDATE ai_workflow_runs + SET status = 'failed', error = 'cancelled', updated_at = datetime('now') + WHERE id = ? AND status IN ('running', 'awaiting_input')` + ) + .run(id).changes; + if (!changed) conflict(`Run cannot be cancelled (status=${exists.status})`); + db.prepare( + `UPDATE ai_prompt_queue + SET status = 'error', error = 'cancelled', finished_at = datetime('now') + WHERE status = 'pending' + AND json_extract(context_json, '$.resumeRunId') = ?` + ).run(id); + return { ok: true }; + }, + }, +}; + +function scheduleRecord( + def: ObjectTypeDef, + row: NonNullable> +): RecordRow { + return record(def, row.id, { + workflow_id: row.workflow_id, + cron_expr: row.cron_expr, + timezone: row.timezone, + enabled: Boolean(row.enabled), + last_run_at: row.last_run_at, + created_at: row.created_at, + updated_at: row.updated_at, + }); +} + +export const scheduleServiceAdapter: RecordAdapter = { + id: "schedule_service", + list(db, def, query) { + const result = page(listSchedules(db), query); + return { + objectType: def.name, + records: result.rows.map((row) => scheduleRecord(def, row)), + total: result.total, + }; + }, + get: (db, def, id) => { + const row = getSchedule(db, id); + return row ? scheduleRecord(def, row) : null; + }, + create(db, def, data) { + const row = createSchedule(db, { + workflowId: requiredText(data, "workflow_id"), + cronExpr: requiredText(data, "cron_expr"), + timezone: typeof data.timezone === "string" ? data.timezone : undefined, + enabled: data.enabled === undefined ? undefined : Boolean(data.enabled), + }); + reloadAiSchedules(); + return scheduleRecord(def, row); + }, + update(db, def, id, data) { + const row = updateSchedule(db, id, { + cronExpr: typeof data.cron_expr === "string" ? data.cron_expr : undefined, + timezone: typeof data.timezone === "string" ? data.timezone : undefined, + enabled: data.enabled === undefined ? undefined : Boolean(data.enabled), + }); + if (!row) throw Object.assign(new Error("Schedule not found"), { status: 404 }); + reloadAiSchedules(); + return scheduleRecord(def, row); + }, + delete(db, _def, id) { + if (!deleteSchedule(db, id)) { + throw Object.assign(new Error("Schedule not found"), { status: 404 }); + } + reloadAiSchedules(); + }, + actions: { + enable(db, def, id) { + const row = updateSchedule(db, id, { enabled: true }); + if (!row) notFound("Schedule not found"); + reloadAiSchedules(); + return scheduleRecord(def, row); + }, + disable(db, def, id) { + const row = updateSchedule(db, id, { enabled: false }); + if (!row) notFound("Schedule not found"); + reloadAiSchedules(); + return scheduleRecord(def, row); + }, + }, +}; + +function agentRecord( + def: ObjectTypeDef, + row: NonNullable> +): RecordRow { + return record(def, row.id, { + name: row.name, + description: row.description, + icon: row.icon, + backend: row.backend, + enabled: row.enabled, + is_template: row.isTemplate, + parent_id: row.parentId, + team: row.team, + created_at: row.createdAt, + updated_at: row.updatedAt, + }); +} + +export const agentServiceAdapter: RecordAdapter = { + id: "agent_service", + list(db, def, query) { + const result = page(listAgents(db), query); + return { + objectType: def.name, + records: result.rows.map((row) => agentRecord(def, row)), + total: result.total, + }; + }, + get: (db, def, id) => { + const row = getAgent(db, id); + return row ? agentRecord(def, row) : null; + }, + create(db, def, data) { + return agentRecord( + def, + createAgent(db, { + id: typeof data.id === "string" ? data.id : undefined, + name: requiredText(data, "name"), + description: + typeof data.description === "string" ? data.description : undefined, + icon: typeof data.icon === "string" ? data.icon : undefined, + backend: typeof data.backend === "string" ? (data.backend as never) : undefined, + parentId: + data.parent_id === undefined ? undefined : (data.parent_id as string | null), + team: data.team === undefined ? undefined : (data.team as string | null), + }) + ); + }, + update(db, def, id, data) { + const row = updateAgent(db, id, { + name: typeof data.name === "string" ? data.name : undefined, + description: + data.description === undefined ? undefined : (data.description as string | null), + icon: data.icon === undefined ? undefined : (data.icon as string | null), + backend: typeof data.backend === "string" ? (data.backend as never) : undefined, + enabled: data.enabled === undefined ? undefined : Boolean(data.enabled), + parentId: + data.parent_id === undefined ? undefined : (data.parent_id as string | null), + team: data.team === undefined ? undefined : (data.team as string | null), + }); + if (!row) throw Object.assign(new Error("Agent not found"), { status: 404 }); + return agentRecord(def, row); + }, + delete(db, _def, id) { + if (!deleteAgent(db, id)) { + throw Object.assign(new Error("Agent cannot be deleted"), { status: 409 }); + } + }, + actions: { + clone(db, def, id, input) { + const source = getAgent(db, id); + if (!source) notFound("Agent not found"); + const cloned = createAgent(db, { + id: typeof input.id === "string" && input.id.trim() ? input.id.trim() : undefined, + name: requiredText(input, "name"), + description: + typeof input.description === "string" ? input.description : undefined, + cloneFromId: source.id, + parentId: + input.parent_id === undefined + ? source.parentId + : (input.parent_id as string | null), + team: + input.team === undefined ? source.team : (input.team as string | null), + }); + return agentRecord(def, cloned); + }, + assign(db, _def, id, input) { + if (!getAgent(db, id)) notFound("Agent not found"); + const row = setAssignment( + db, + requiredText(input, "scope_type") as AssignmentScopeType, + requiredText(input, "scope_id"), + id, + typeof input.role === "string" ? (input.role as never) : undefined + ); + if (!row) conflict("Assignment not created"); + return { + id: assignmentId(row), + scope_type: row.scope_type, + scope_id: row.scope_id, + agent_id: row.agent_id, + role: row.role, + updated_at: row.updated_at, + }; + }, + configure_reflection(db, _def, id, input, ctx) { + if (!getAgent(db, id)) notFound("Agent not found"); + const current = getReflectionConfig(db, id); + const patch: Partial = {}; + if (input.enabled !== undefined) patch.enabled = Boolean(input.enabled); + if (input.mode !== undefined) { + if (input.mode !== "approval" && input.mode !== "auto") { + throw Object.assign(new Error("Invalid reflection mode"), { status: 400 }); + } + patch.mode = input.mode; + } + if (input.schedule && typeof input.schedule === "object") { + patch.schedule = { + ...current.schedule, + ...(input.schedule as Partial), + }; + } + if (input.idle && typeof input.idle === "object") { + patch.idle = { + ...current.idle, + ...(input.idle as Partial), + }; + } + const next = patchReflectionConfig(db, id, patch); + if (!next) notFound("Agent not found"); + ctx.bus?.emit("agent.reflection.updated", { + agentId: id, + tenantId: ctx.tenantId, + }); + return { reflection: next }; + }, + run_reflection(db, _def, id, _input, ctx) { + if (!getAgent(db, id)) notFound("Agent not found"); + const jobId = enqueueAiJob(db, { + tenantId: ctx.tenantId, + context: { + reflectionAgentId: id, + reflectionTrigger: "manual", + }, + priority: 0, + }); + return { ok: true, jobId }; + }, + }, +}; + +function assignmentId(row: Pick): string { + return `${row.scope_type}:${row.scope_id}`; +} + +function parseAssignmentId(id: string): { + scopeType: AssignmentScopeType; + scopeId: string; +} { + const separator = id.indexOf(":"); + if (separator < 1 || separator === id.length - 1) { + throw Object.assign(new Error("Assignment id must be scope_type:scope_id"), { + status: 400, + }); + } + return { + scopeType: id.slice(0, separator) as AssignmentScopeType, + scopeId: id.slice(separator + 1), + }; +} + +function assignmentRecord(def: ObjectTypeDef, row: AiAgentAssignment): RecordRow { + return record(def, assignmentId(row), { + scope_type: row.scope_type, + scope_id: row.scope_id, + agent_id: row.agent_id, + role: row.role, + updated_at: row.updated_at, + }); +} + +export const assignmentServiceAdapter: RecordAdapter = { + id: "agent_assignment_service", + list(db, def, query) { + const result = page(listAssignments(db), query); + return { + objectType: def.name, + records: result.rows.map((row) => assignmentRecord(def, row)), + total: result.total, + }; + }, + get(db, def, id) { + const parsed = parseAssignmentId(id); + const row = getAssignment(db, parsed.scopeType, parsed.scopeId); + return row ? assignmentRecord(def, row) : null; + }, + create(db, def, data) { + const row = setAssignment( + db, + requiredText(data, "scope_type") as AssignmentScopeType, + requiredText(data, "scope_id"), + requiredText(data, "agent_id"), + typeof data.role === "string" ? (data.role as never) : undefined + ); + if (!row) throw Object.assign(new Error("Assignment not created"), { status: 400 }); + return assignmentRecord(def, row); + }, + update(db, def, id, data) { + const parsed = parseAssignmentId(id); + const existing = getAssignment(db, parsed.scopeType, parsed.scopeId); + if (!existing) throw Object.assign(new Error("Assignment not found"), { status: 404 }); + const row = setAssignment( + db, + parsed.scopeType, + parsed.scopeId, + typeof data.agent_id === "string" ? data.agent_id : existing.agent_id, + typeof data.role === "string" ? (data.role as never) : existing.role + ); + if (!row) throw Object.assign(new Error("Assignment not found"), { status: 404 }); + return assignmentRecord(def, row); + }, + delete(db, _def, id) { + const parsed = parseAssignmentId(id); + if (!getAssignment(db, parsed.scopeType, parsed.scopeId)) { + throw Object.assign(new Error("Assignment not found"), { status: 404 }); + } + setAssignment(db, parsed.scopeType, parsed.scopeId, null); + }, +}; + +function wikiScope(ctx: OperationContext): WikiScope { + return { tenantIds: ctx.tenantId ? [ctx.tenantId] : [] }; +} + +function wikiRecord( + def: ObjectTypeDef, + row: NonNullable> +): RecordRow { + return record(def, row.id, { + tenant_id: row.tenant_id, + space: row.space, + slug: row.slug, + title: row.title, + body_markdown: row.body_markdown, + visibility: row.visibility, + author_user_id: row.author_user_id, + created_at: row.created_at, + updated_at: row.updated_at, + }); +} + +export const wikiPageServiceAdapter: RecordAdapter = { + id: "wiki_page_service", + list(_db, def, query, ctx) { + let rows = listPages(wikiScope(ctx), { + visibility: + typeof query.filters?.visibility === "string" + ? (query.filters.visibility as never) + : undefined, + space: + typeof query.filters?.space === "string" + ? query.filters.space + : undefined, + }); + const result = page(rows, query); + return { + objectType: def.name, + records: result.rows.map((row) => wikiRecord(def, row)), + total: result.total, + }; + }, + get(_db, def, id, ctx) { + const row = getPageById(id); + if ( + !row || + (row.visibility !== "external" && + !wikiScope(ctx).tenantIds.includes(row.tenant_id)) + ) { + return null; + } + return wikiRecord(def, row); + }, + create(_db, def, data, ctx) { + if (!ctx.tenantId || !ctx.userId) { + throw Object.assign(new Error("Tenant and user required"), { status: 401 }); + } + return wikiRecord( + def, + createPage({ + tenantId: ctx.tenantId, + authorUserId: ctx.userId, + title: requiredText(data, "title"), + bodyMarkdown: + typeof data.body_markdown === "string" ? data.body_markdown : undefined, + space: + data.space === undefined ? undefined : (data.space as string | null), + visibility: + typeof data.visibility === "string" ? (data.visibility as never) : undefined, + slug: typeof data.slug === "string" ? data.slug : undefined, + }) + ); + }, + update(_db, def, id, data, ctx) { + return wikiRecord( + def, + updatePage( + id, + { + title: typeof data.title === "string" ? data.title : undefined, + bodyMarkdown: + typeof data.body_markdown === "string" + ? data.body_markdown + : undefined, + space: + data.space === undefined ? undefined : (data.space as string | null), + visibility: + typeof data.visibility === "string" + ? (data.visibility as never) + : undefined, + }, + wikiScope(ctx) + ) + ); + }, + delete(_db, _def, id, ctx) { + deletePage(id, wikiScope(ctx)); + }, +}; + +function revisionRecord( + def: ObjectTypeDef, + row: Record +): RecordRow { + return record(def, String(row.id), { + page_id: row.page_id, + title: row.title, + body_markdown: row.body_markdown, + author_user_id: row.author_user_id, + created_at: row.created_at, + }); +} + +export const wikiRevisionServiceAdapter: RecordAdapter = { + id: "wiki_revision_service", + list(_db, def, query, ctx) { + const db = getCoreDb(); + const limit = Math.min(Math.max(Number(query.limit) || 100, 1), 500); + const offset = Math.max(Number(query.offset) || 0, 0); + const tenantId = ctx.tenantId ?? ""; + const predicate = `(p.visibility='external' OR p.tenant_id=?)`; + const total = ( + db + .prepare( + `SELECT COUNT(*) AS c FROM wiki_revisions r + JOIN wiki_pages p ON p.id=r.page_id WHERE ${predicate}` + ) + .get(tenantId) as { c: number } + ).c; + const rows = db + .prepare( + `SELECT r.* FROM wiki_revisions r + JOIN wiki_pages p ON p.id=r.page_id + WHERE ${predicate} + ORDER BY r.created_at DESC LIMIT ? OFFSET ?` + ) + .all(tenantId, limit, offset) as Record[]; + return { + objectType: def.name, + records: rows.map((row) => revisionRecord(def, row)), + total, + }; + }, + get(_db, def, id, ctx) { + const row = getCoreDb() + .prepare( + `SELECT r.* FROM wiki_revisions r + JOIN wiki_pages p ON p.id=r.page_id + WHERE r.id=? AND (p.visibility='external' OR p.tenant_id=?)` + ) + .get(id, ctx.tenantId ?? "") as Record | undefined; + return row ? revisionRecord(def, row) : null; + }, +}; + +function hookScope(db: AppDatabase, ctx: OperationContext): HookOwnerScope { + if (!ctx.userId) throw Object.assign(new Error("Authenticated user required"), { status: 401 }); + return { + userId: ctx.userId, + tenantId: ctx.tenantId ?? null, + agentIds: listAgents(db).map((agent) => agent.id), + }; +} + +function hookRecord(def: ObjectTypeDef, row: ReturnType): RecordRow { + return record(def, row.id, { + owner_kind: row.owner_kind, + owner_id: row.owner_id, + owner_tenant_id: row.owner_tenant_id, + name: row.name, + enabled: Boolean(row.enabled), + trigger_kind: row.trigger_kind, + event_type: row.event_type, + schedule_cron: row.schedule_cron, + condition_json: parseJson(row.condition_json), + action_kind: row.action_kind, + action_config_json: redactCredentials(parseJson(row.action_config_json)), + require_approval: Boolean(row.require_approval), + created_at: row.created_at, + updated_at: row.updated_at, + last_fired_at: row.last_fired_at, + }); +} + +export const hookServiceAdapter: RecordAdapter = { + id: "hook_service", + list(db, def, query, ctx) { + const result = page(listHooks(hookScope(db, ctx)), query); + return { + objectType: def.name, + records: result.rows.map((row) => hookRecord(def, row)), + total: result.total, + }; + }, + get: (db, def, id, ctx) => hookRecord(def, getHook(id, hookScope(db, ctx))), + create(db, def, data, ctx) { + const scope = hookScope(db, ctx); + const created = hookRecord( + def, + createHook( + { + ownerKind: requiredText(data, "owner_kind") as never, + ownerId: requiredText(data, "owner_id"), + ownerTenantId: + data.owner_tenant_id === undefined + ? undefined + : (data.owner_tenant_id as string | null), + name: requiredText(data, "name"), + enabled: data.enabled === undefined ? undefined : Boolean(data.enabled), + triggerKind: requiredText(data, "trigger_kind") as never, + eventType: data.event_type as string | null | undefined, + scheduleCron: data.schedule_cron as string | null | undefined, + conditionJson: jsonText(data.condition_json), + actionKind: requiredText(data, "action_kind") as never, + actionConfigJson: jsonText(data.action_config_json), + requireApproval: Boolean(data.require_approval), + }, + scope + ) + ); + refreshScheduler(); + return created; + }, + update(db, def, id, data, ctx) { + const updated = hookRecord( + def, + updateHook( + id, + { + ...(data.name !== undefined ? { name: data.name } : {}), + ...(data.enabled !== undefined ? { enabled: data.enabled } : {}), + ...(data.trigger_kind !== undefined + ? { triggerKind: data.trigger_kind } + : {}), + ...(data.event_type !== undefined ? { eventType: data.event_type } : {}), + ...(data.schedule_cron !== undefined + ? { scheduleCron: data.schedule_cron } + : {}), + ...(data.condition_json !== undefined + ? { conditionJson: jsonText(data.condition_json) } + : {}), + ...(data.action_kind !== undefined + ? { actionKind: data.action_kind } + : {}), + ...(data.action_config_json !== undefined + ? { actionConfigJson: jsonText(data.action_config_json) } + : {}), + ...(data.require_approval !== undefined + ? { requireApproval: data.require_approval } + : {}), + }, + hookScope(db, ctx) + ) + ); + refreshScheduler(); + return updated; + }, + delete(db, _def, id, ctx) { + deleteHook(id, hookScope(db, ctx)); + refreshScheduler(); + }, + actions: { + enable(db, def, id, _input, ctx) { + const row = updateHook(id, { enabled: true }, hookScope(db, ctx)); + refreshScheduler(); + return hookRecord(def, row); + }, + disable(db, def, id, _input, ctx) { + const row = updateHook(id, { enabled: false }, hookScope(db, ctx)); + refreshScheduler(); + return hookRecord(def, row); + }, + }, +}; + +function hookRunRecord( + def: ObjectTypeDef, + row: Record +): RecordRow { + return record(def, String(row.id), { + hook_id: row.hook_id, + event_id: row.event_id, + status: row.status, + detail: row.detail, + result_json: + typeof row.result_json === "string" + ? parseJson(row.result_json) + : row.result_json, + created_at: row.created_at, + }); +} + +export const hookRunServiceAdapter: RecordAdapter = { + id: "hook_run_read", + list(db, def, query, ctx) { + const scope = hookScope(db, ctx); + const rows = listHooks(scope).flatMap((hook) => + getCoreDb() + .prepare( + `SELECT * FROM hook_runs WHERE hook_id = ? + ORDER BY created_at DESC LIMIT 200` + ) + .all(hook.id) as Record[] + ); + const result = page(rows, query); + return { + objectType: def.name, + records: result.rows.map((row) => hookRunRecord(def, row)), + total: result.total, + }; + }, + get(db, def, id, ctx) { + const scope = hookScope(db, ctx); + getHookForRun(id, scope); + const row = getCoreDb() + .prepare(`SELECT * FROM hook_runs WHERE id = ?`) + .get(id) as Record | undefined; + return row ? hookRunRecord(def, row) : null; + }, + actions: { + async approve(db, _def, id, _input, ctx) { + const coreDb = getCoreDb(); + getHookForRun(id, hookScope(db, ctx), coreDb); + const row = coreDb + .prepare(`SELECT status FROM hook_runs WHERE id = ?`) + .get(id) as { status: string } | undefined; + if (!row) notFound("Hook run not found"); + if (row.status !== "pending_approval") { + conflict(`Hook run is not pending approval (status=${row.status})`); + } + await approveHookRun(id, coreDb); + return { ok: true }; + }, + reject(db, _def, id, _input, ctx) { + const coreDb = getCoreDb(); + getHookForRun(id, hookScope(db, ctx), coreDb); + const row = coreDb + .prepare(`SELECT status FROM hook_runs WHERE id = ?`) + .get(id) as { status: string } | undefined; + if (!row) notFound("Hook run not found"); + if (row.status !== "pending_approval") { + conflict(`Hook run is not pending approval (status=${row.status})`); + } + rejectHookRun(id, coreDb); + return { ok: true }; + }, + }, +}; diff --git a/apps/bridge/src/kernel/adapters/operation-runs.ts b/apps/bridge/src/kernel/adapters/operation-runs.ts new file mode 100644 index 0000000..07feb5a --- /dev/null +++ b/apps/bridge/src/kernel/adapters/operation-runs.ts @@ -0,0 +1,79 @@ +import type { RecordAdapter, RecordQuery } from "../adapter-registry.js"; +import { cancelOperationRun } from "../record-api.js"; + +function predicate(ctx: Parameters>[3]): { + sql: string; + params: unknown[]; +} { + if (ctx.source === "system" || ctx.role === "intelligence") { + return { sql: "1=1", params: [] }; + } + if (!ctx.tenantId) return { sql: "0=1", params: [] }; + return { sql: "tenant_id=?", params: [ctx.tenantId] }; +} + +function row(def: Parameters>[1], value: Record) { + return { + objectType: def.name, + id: String(value.id), + data: value, + }; +} + +function paging(query: RecordQuery): { limit: number; offset: number } { + return { + limit: Math.min(Math.max(Number(query.limit) || 100, 1), 500), + offset: Math.max(Number(query.offset) || 0, 0), + }; +} + +export const operationRunAdapter: RecordAdapter = { + id: "operation_run_service", + policy: { + authorize(_operation, _def, ctx, record) { + if (!record || ctx.source === "system" || ctx.role === "intelligence") { + return true; + } + return ( + !ctx.tenantId || + record.data.tenant_id == null || + record.data.tenant_id === ctx.tenantId + ); + }, + }, + list(db, def, query, ctx) { + const scope = predicate(ctx); + const { limit, offset } = paging(query); + const total = ( + db + .prepare(`SELECT COUNT(*) AS count FROM kernel_operation_runs WHERE ${scope.sql}`) + .get(...scope.params) as { count: number } + ).count; + const values = db + .prepare( + `SELECT * FROM kernel_operation_runs + WHERE ${scope.sql} + ORDER BY updated_at DESC LIMIT ? OFFSET ?` + ) + .all(...scope.params, limit, offset) as Record[]; + return { + objectType: def.name, + records: values.map((value) => row(def, value)), + total, + }; + }, + get(db, def, id, ctx) { + const scope = predicate(ctx); + const value = db + .prepare( + `SELECT * FROM kernel_operation_runs WHERE id=? AND ${scope.sql}` + ) + .get(id, ...scope.params) as Record | undefined; + return value ? row(def, value) : null; + }, + actions: { + cancel(db, _def, id, _input, ctx) { + return { cancelled: cancelOperationRun(db, id, ctx) }; + }, + }, +}; diff --git a/apps/bridge/src/kernel/adapters/platform-actions.ts b/apps/bridge/src/kernel/adapters/platform-actions.ts new file mode 100644 index 0000000..646694b --- /dev/null +++ b/apps/bridge/src/kernel/adapters/platform-actions.ts @@ -0,0 +1,1079 @@ +import type { + ActionDef, + ObjectTypeDef, + RecordData, + RecordRow, +} from "@godmode/kernel"; +import type { AppDatabase } from "../../db.js"; +import { + createShareGrant, + listShareGrantsForUser, + revokeShareGrant, +} from "../../services/share-service.js"; +import { + createConversation, + createMessage, + getConversationForUser, + listConversationsForUser, + listMessages, + markConversationRead, +} from "../../services/dm-service.js"; +import { + addMessage, + createTicket, + getTicket, + getTicketMessages, + listAllTickets, + listTicketsForOwner, + listTicketsForRequester, + updateTicket, +} from "../../services/support-service.js"; +import { + addCatalogSource, + listCatalogSources, + removeCatalogSource, +} from "../../services/marketplace-catalog.js"; +import { + acquireLiveListing, + cancelEntitlement, + listEntitlementsForBuyer, +} from "../../services/entitlements.js"; +import { + createBridgeConnection, + deleteBridgeConnection, + getBridgeConnection, + listBridgeConnections, + touchBridgeConnection, +} from "../../services/bridge-connections.js"; +import { listPeerConnections } from "../../services/federation-peers.js"; +import { + createInferenceEndpoint, + getInferenceEndpoint, + listInferenceEndpoints, +} from "../../services/inference-service.js"; +import { + HoldingsService, + type HoldingCategory, +} from "../../services/holdings/holdings-service.js"; +import type { + OperationContext, + RecordAdapter, + RecordQuery, +} from "../adapter-registry.js"; + +function httpError(status: number, message: string): Error { + return Object.assign(new Error(message), { status }); +} + +function requireUser(ctx: OperationContext): string { + if (!ctx.userId) throw httpError(401, "Authenticated user required"); + return ctx.userId; +} + +function requireTenant(ctx: OperationContext): string { + if (!ctx.tenantId) throw httpError(401, "Tenant required"); + return ctx.tenantId; +} + +function requiredText(data: RecordData, name: string): string { + const value = data[name]; + if (typeof value !== "string" || !value.trim()) { + throw httpError(400, `${name} required`); + } + return value.trim(); +} + +function unsupported(operation: string): never { + throw httpError( + 501, + `${operation} is not available through the kernel; use the policy-enforcing platform workflow` + ); +} + +function page(rows: T[], query: RecordQuery): { rows: T[]; total: number } { + const offset = Math.max(Number(query.offset) || 0, 0); + const limit = Math.min(Math.max(Number(query.limit) || 100, 1), 500); + return { rows: rows.slice(offset, offset + limit), total: rows.length }; +} + +function normalize(value: unknown): unknown { + if (typeof value !== "string") return value; + try { + return JSON.parse(value); + } catch { + return value; + } +} + +function record( + def: ObjectTypeDef, + row: Record, + aliases: Record = {} +): RecordRow { + const data: RecordData = {}; + for (const field of def.fields) { + if (field.secret) continue; + const source = aliases[field.name] ?? field.name; + if (source in row) data[field.name] = normalize(row[source]); + } + const id = String(row.id); + return { id, objectType: def.name, data: { id, ...data } }; +} + +function result( + def: ObjectTypeDef, + rows: Array>, + query: RecordQuery, + aliases?: Record +) { + const paged = page(rows, query); + return { + objectType: def.name, + records: paged.rows.map((row) => record(def, row, aliases)), + total: paged.total, + }; +} + +const DM_CONVERSATION_ALIASES = { + created_by_user_id: "createdByUserId", + created_at: "createdAt", + updated_at: "updatedAt", + last_message_at: "lastMessageAt", + last_message_preview: "lastMessagePreview", +}; + +const DM_MESSAGE_ALIASES = { + conversation_id: "conversationId", + sender_user_id: "senderUserId", + body_text: "bodyText", + created_at: "createdAt", + edited_at: "editedAt", + deleted_at: "deletedAt", +}; + +export const shareGrantAdapter: RecordAdapter = { + id: "share_grant_read", + list(_db, def, query, ctx) { + return result(def, listShareGrantsForUser(ctx.data!.coreDb, requireUser(ctx)), query); + }, + get(_db, def, id, ctx) { + const row = listShareGrantsForUser(ctx.data!.coreDb, requireUser(ctx)).find( + (candidate) => String(candidate.id) === id + ); + return row ? record(def, row) : null; + }, + create(_db, def, data, ctx) { + const core = ctx.data!.coreDb; + const id = createShareGrant(core, { + ownerTenantId: requireTenant(ctx), + ownerUserId: requireUser(ctx), + resourceKind: requiredText(data, "resource_kind") as never, + resourceId: requiredText(data, "resource_id"), + granteeUserId: + typeof data.grantee_user_id === "string" ? data.grantee_user_id : undefined, + granteeTenantId: + typeof data.grantee_tenant_id === "string" ? data.grantee_tenant_id : undefined, + role: typeof data.role === "string" ? (data.role as never) : undefined, + }); + return this.get!(core, def, id, ctx)!; + }, + delete(_db, _def, id, ctx) { + revokeShareGrant(ctx.data!.coreDb, id, requireUser(ctx)); + }, + actions: { + grant(db, def, _id, input, ctx) { + return shareGrantAdapter.create!(db, def, input, ctx); + }, + revoke(_db, _def, id, _input, ctx) { + revokeShareGrant(ctx.data!.coreDb, id, requireUser(ctx)); + return { ok: true }; + }, + }, +}; + +export const directConversationAdapter: RecordAdapter = { + id: "dm_conversation_read", + list(_db, def, query, ctx) { + return result( + def, + listConversationsForUser(ctx.data!.coreDb, requireUser(ctx)) as unknown as Array< + Record + >, + query, + DM_CONVERSATION_ALIASES + ); + }, + get(_db, def, id, ctx) { + try { + return record( + def, + getConversationForUser( + ctx.data!.coreDb, + id, + requireUser(ctx) + ) as unknown as Record, + DM_CONVERSATION_ALIASES + ); + } catch (error) { + if ((error as { status?: number }).status === 404) return null; + throw error; + } + }, + create(_db, def, data, ctx) { + const members = Array.isArray(data.member_user_ids) + ? data.member_user_ids.filter((id): id is string => typeof id === "string") + : []; + const row = createConversation(ctx.data!.coreDb, { + creatorUserId: requireUser(ctx), + kind: data.kind === "group" ? "group" : "direct", + title: typeof data.title === "string" ? data.title : undefined, + memberUserIds: members, + }); + return record( + def, + row as unknown as Record, + DM_CONVERSATION_ALIASES + ); + }, + actions: { + start(db, def, _id, input, ctx) { + return directConversationAdapter.create!(db, def, input, ctx); + }, + mark_read(_db, _def, id, input, ctx) { + markConversationRead( + ctx.data!.coreDb, + id, + requireUser(ctx), + typeof input.message_id === "string" ? input.message_id : undefined + ); + return { ok: true }; + }, + }, +}; + +export const directMessageAdapter: RecordAdapter = { + id: "dm_message_read", + list(_db, def, query, ctx) { + const conversationId = + typeof query.filters?.conversation_id === "string" + ? query.filters.conversation_id + : undefined; + if (!conversationId) throw httpError(400, "conversation_id filter required"); + return result( + def, + listMessages(ctx.data!.coreDb, conversationId, requireUser(ctx), { + limit: Math.min(Math.max(Number(query.limit) || 50, 1), 200), + before: + typeof query.filters?.before === "string" ? query.filters.before : undefined, + }) as unknown as Array>, + { ...query, limit: 500, offset: 0 }, + DM_MESSAGE_ALIASES + ); + }, + get(_db, def, id, ctx) { + const core = ctx.data!.coreDb; + const pointer = core + .prepare("SELECT conversation_id FROM dm_messages WHERE id=?") + .get(id) as { conversation_id: string } | undefined; + if (!pointer) return null; + const row = listMessages(core, pointer.conversation_id, requireUser(ctx), { + limit: 200, + }).find((message) => message.id === id); + return row + ? record( + def, + row as unknown as Record, + DM_MESSAGE_ALIASES + ) + : null; + }, + create(_db, def, data, ctx) { + const row = createMessage(ctx.data!.coreDb, { + conversationId: requiredText(data, "conversation_id"), + senderUserId: requireUser(ctx), + bodyText: typeof data.body_text === "string" ? data.body_text : undefined, + attachments: Array.isArray(data.attachments) ? (data.attachments as never) : undefined, + }); + return record( + def, + row as unknown as Record, + DM_MESSAGE_ALIASES + ); + }, + actions: { + send(db, def, _id, input, ctx) { + return directMessageAdapter.create!(db, def, input, ctx); + }, + }, +}; + +function canAccessTicket( + row: NonNullable>, + ctx: OperationContext +): boolean { + if (ctx.isAdmin) return true; + const userId = requireUser(ctx); + return ( + (row.requester_kind === "user" && row.requester_id === userId) || + row.owner_user_id === userId + ); +} + +function accessibleTickets(ctx: OperationContext) { + const core = ctx.data!.coreDb; + const userId = requireUser(ctx); + if (ctx.isAdmin) return listAllTickets({}, core); + const byId = new Map( + [ + ...listTicketsForRequester("user", userId, core), + ...listTicketsForOwner(userId, core), + ].map((ticket) => [ticket.id, ticket]) + ); + return [...byId.values()]; +} + +export const supportTicketAdapter: RecordAdapter = { + id: "support_ticket_read", + list(_db, def, query, ctx) { + return result( + def, + accessibleTickets(ctx) as unknown as Array>, + query + ); + }, + get(_db, def, id, ctx) { + const row = getTicket(id, ctx.data!.coreDb); + return row && canAccessTicket(row, ctx) + ? record(def, row as unknown as Record) + : null; + }, + create(_db, def, data, ctx) { + const row = createTicket( + { + requesterKind: "user", + requesterId: requireUser(ctx), + requesterTenantId: requireTenant(ctx), + subject: requiredText(data, "subject"), + body: typeof data.body === "string" ? data.body : "", + category: typeof data.category === "string" ? data.category : undefined, + priority: typeof data.priority === "string" ? data.priority : undefined, + targetKind: + typeof data.target_kind === "string" ? (data.target_kind as never) : undefined, + sharedGrantId: + typeof data.shared_grant_id === "string" ? data.shared_grant_id : undefined, + ownerUserId: + typeof data.owner_user_id === "string" ? data.owner_user_id : undefined, + }, + ctx.data!.coreDb + ); + if ("redirectUrl" in row) { + throw httpError(409, "GitHub support requires the interactive support workflow"); + } + return record(def, row as unknown as Record); + }, + actions: { + open(db, def, _id, input, ctx) { + return supportTicketAdapter.create!(db, def, input, ctx); + }, + reply(_db, def, id, input, ctx) { + const ticket = getTicket(id, ctx.data!.coreDb); + if (!ticket || !canAccessTicket(ticket, ctx)) throw httpError(404, "Ticket not found"); + const row = addMessage( + id, + { kind: ctx.isAdmin ? "admin" : "user", id: requireUser(ctx) }, + requiredText(input, "body"), + ctx.data!.coreDb + ); + return record(def, row as unknown as Record); + }, + set_status(_db, def, id, input, ctx) { + if (!ctx.isAdmin) throw httpError(403, "Support staff required"); + const row = updateTicket( + id, + { + status: + typeof input.status === "string" ? (input.status as never) : undefined, + priority: + input.priority === null || typeof input.priority === "string" + ? input.priority + : undefined, + }, + ctx.data!.coreDb + ); + return record(def, row as unknown as Record); + }, + }, +}; + +export const supportMessageAdapter: RecordAdapter = { + id: "support_message_read", + list(_db, def, query, ctx) { + const ticketId = + typeof query.filters?.ticket_id === "string" ? query.filters.ticket_id : ""; + const ticket = getTicket(ticketId, ctx.data!.coreDb); + if (!ticket || !canAccessTicket(ticket, ctx)) throw httpError(404, "Ticket not found"); + return result( + def, + getTicketMessages(ticketId, ctx.data!.coreDb) as unknown as Array< + Record + >, + query + ); + }, + get(_db, def, id, ctx) { + const row = ctx.data!.coreDb + .prepare("SELECT ticket_id FROM support_messages WHERE id=?") + .get(id) as { ticket_id: string } | undefined; + if (!row) return null; + const ticket = getTicket(row.ticket_id, ctx.data!.coreDb); + if (!ticket || !canAccessTicket(ticket, ctx)) return null; + const message = getTicketMessages(row.ticket_id, ctx.data!.coreDb).find( + (candidate) => candidate.id === id + ); + return message + ? record(def, message as unknown as Record) + : null; + }, + actions: { + reply(_db, def, _id, input, ctx) { + return supportTicketAdapter.actions!.reply( + ctx.data!.coreDb, + def, + requiredText(input, "ticket_id"), + input, + ctx + ); + }, + }, +}; + +export const catalogSourceAdapter: RecordAdapter = { + id: "catalog_source_read", + list(_db, def, query, ctx) { + return result( + def, + listCatalogSources(ctx.data!.coreDb, requireUser(ctx)), + query + ); + }, + get(_db, def, id, ctx) { + const row = listCatalogSources(ctx.data!.coreDb, requireUser(ctx)).find( + (source) => source.id === id + ); + return row ? record(def, row) : null; + }, + create(_db, def, data, ctx) { + const core = ctx.data!.coreDb; + const userId = requireUser(ctx); + const id = addCatalogSource( + core, + userId, + requiredText(data, "name"), + requiredText(data, "url") + ); + return record(def, listCatalogSources(core, userId).find((row) => row.id === id)!); + }, + delete(_db, _def, id, ctx) { + if (!removeCatalogSource(ctx.data!.coreDb, requireUser(ctx), id)) { + throw httpError(404, "Catalog source not found"); + } + }, + actions: { + add(db, def, _id, input, ctx) { + return catalogSourceAdapter.create!(db, def, input, ctx); + }, + remove(_db, _def, id, _input, ctx) { + if (!removeCatalogSource(ctx.data!.coreDb, requireUser(ctx), id)) { + throw httpError(404, "Catalog source not found"); + } + return { ok: true }; + }, + fetch_external() { + return unsupported("External catalog fetch"); + }, + }, +}; + +function visibleListings(core: AppDatabase, ctx: OperationContext) { + const userId = requireUser(ctx); + return core + .prepare( + `SELECT * FROM marketplace_listings + WHERE (status='active' AND visibility='public') OR seller_user_id=? + ORDER BY created_at DESC` + ) + .all(userId) as Array>; +} + +export const marketplaceListingAdapter: RecordAdapter = { + id: "marketplace_listing_read", + list(_db, def, query, ctx) { + return result(def, visibleListings(ctx.data!.coreDb, ctx), query); + }, + get(_db, def, id, ctx) { + const row = visibleListings(ctx.data!.coreDb, ctx).find( + (listing) => String(listing.id) === id + ); + return row ? record(def, row) : null; + }, + actions: { + acquire_live(_db, _def, id, _input, ctx) { + const listing = ctx.data!.coreDb + .prepare( + `SELECT * FROM marketplace_listings + WHERE id=? AND status='active' AND visibility='public'` + ) + .get(id) as Record | undefined; + if (!listing) throw httpError(404, "Listing not found"); + if (listing.delivery_mode !== "live") { + return unsupported("Clone marketplace acquisition"); + } + return acquireLiveListing(ctx.data!.coreDb, { + listing, + buyerUserId: requireUser(ctx), + buyerTenantId: requireTenant(ctx), + }); + }, + publish() { + return unsupported("Marketplace publishing"); + }, + archive() { + return unsupported("Marketplace archival"); + }, + }, +}; + +export const marketplaceEntitlementAdapter: RecordAdapter = { + id: "marketplace_entitlement_read", + list(_db, def, query, ctx) { + return result( + def, + listEntitlementsForBuyer( + ctx.data!.coreDb, + requireUser(ctx), + requireTenant(ctx) + ), + query + ); + }, + get(_db, def, id, ctx) { + const row = listEntitlementsForBuyer( + ctx.data!.coreDb, + requireUser(ctx), + requireTenant(ctx) + ).find((entitlement) => String(entitlement.id) === id); + return row ? record(def, row) : null; + }, + actions: { + cancel(_db, _def, id, _input, ctx) { + cancelEntitlement(ctx.data!.coreDb, id, requireUser(ctx)); + return { ok: true }; + }, + }, +}; + +export const bridgeConnectionAdapter: RecordAdapter = { + id: "bridge_connection_read", + list(_db, def, query, ctx) { + return result( + def, + listBridgeConnections(ctx.data!.coreDb, requireTenant(ctx)) as unknown as Array< + Record + >, + query + ); + }, + get(_db, def, id, ctx) { + const row = getBridgeConnection(ctx.data!.coreDb, id); + return row && row.owner_tenant_id === requireTenant(ctx) + ? record(def, row as unknown as Record) + : null; + }, + create(_db, def, data, ctx) { + const row = createBridgeConnection(ctx.data!.coreDb, { + ownerTenantId: requireTenant(ctx), + ownerUserId: requireUser(ctx), + label: requiredText(data, "label"), + mode: requiredText(data, "mode") as never, + remoteBridgeUrl: + typeof data.remote_bridge_url === "string" ? data.remote_bridge_url : undefined, + remoteBridgeToken: + typeof data.remote_bridge_token === "string" + ? data.remote_bridge_token + : undefined, + }); + return record(def, row as unknown as Record); + }, + delete(_db, _def, id, ctx) { + const row = getBridgeConnection(ctx.data!.coreDb, id); + if (!row || row.owner_tenant_id !== requireTenant(ctx)) { + throw httpError(404, "Bridge connection not found"); + } + if (!deleteBridgeConnection(ctx.data!.coreDb, id)) { + throw httpError(404, "Bridge connection not found"); + } + }, + actions: { + register(db, def, _id, input, ctx) { + return bridgeConnectionAdapter.create!(db, def, input, ctx); + }, + touch(_db, _def, id, _input, ctx) { + const row = getBridgeConnection(ctx.data!.coreDb, id); + if (!row || row.owner_tenant_id !== requireTenant(ctx)) { + throw httpError(404, "Bridge connection not found"); + } + touchBridgeConnection(ctx.data!.coreDb, id); + return { ok: true }; + }, + probe_remote() { + return unsupported("Remote bridge probing"); + }, + }, +}; + +export const peerConnectionAdapter: RecordAdapter = { + id: "peer_connection_read", + list(_db, def, query, ctx) { + return result( + def, + listPeerConnections(ctx.data!.coreDb, requireUser(ctx)) as unknown as Array< + Record + >, + query + ); + }, + get(_db, def, id, ctx) { + const row = listPeerConnections(ctx.data!.coreDb, requireUser(ctx)).find( + (peer) => peer.id === id + ); + return row + ? record(def, row as unknown as Record) + : null; + }, + actions: { + invite() { + return unsupported("Peer invitation"); + }, + accept() { + return unsupported("Peer acceptance"); + }, + refresh_health() { + return unsupported("Peer health refresh"); + }, + }, +}; + +export const inferenceEndpointAdapter: RecordAdapter = { + id: "inference_endpoint_read", + list(_db, def, query, ctx) { + return result( + def, + listInferenceEndpoints(ctx.data!.coreDb, requireUser(ctx)), + query + ); + }, + get(_db, def, id, ctx) { + const row = getInferenceEndpoint(ctx.data!.coreDb, id); + return row && + row.owner_user_id === requireUser(ctx) && + row.owner_tenant_id === requireTenant(ctx) + ? record(def, row) + : null; + }, + create(_db, def, data, ctx) { + const core = ctx.data!.coreDb; + const id = createInferenceEndpoint(core, { + ownerTenantId: requireTenant(ctx), + ownerUserId: requireUser(ctx), + name: requiredText(data, "name"), + baseModelPath: requiredText(data, "base_model_path"), + adapterIds: Array.isArray(data.adapter_ids_json) + ? data.adapter_ids_json.filter((value): value is string => typeof value === "string") + : undefined, + meterUnit: + typeof data.meter_unit === "string" ? data.meter_unit : undefined, + meterRate: + typeof data.meter_rate === "number" ? data.meter_rate : undefined, + capacityHint: + typeof data.capacity_hint === "number" ? data.capacity_hint : undefined, + }); + return record(def, getInferenceEndpoint(core, id)!); + }, + actions: { + publish(db, def, _id, input, ctx) { + return inferenceEndpointAdapter.create!(db, def, input, ctx); + }, + run_remote() { + return unsupported("Remote inference"); + }, + }, +}; + +const FINANCE_ALIASES = { + external_id: "externalId", + balance_cad: "balanceCad", + breakdown_json: "breakdown", + last_synced_at: "lastSyncedAt", + created_at: "createdAt", +}; + +export const financeConnectionAdapter: RecordAdapter = { + id: "finance_connection_service", + list(db, def, query) { + return result( + def, + new HoldingsService(db).list() as unknown as Array>, + query, + FINANCE_ALIASES + ); + }, + get(db, def, id) { + const row = new HoldingsService(db).get(id); + return row + ? record(def, row as unknown as Record, FINANCE_ALIASES) + : null; + }, + create(db, def, data) { + const row = new HoldingsService(db).create({ + category: requiredText(data, "category") as HoldingCategory, + provider: requiredText(data, "provider"), + label: requiredText(data, "label"), + currency: requiredText(data, "currency"), + reference: + typeof data.reference === "string" ? data.reference : undefined, + externalId: + typeof data.external_id === "string" ? data.external_id : undefined, + balance: Number(data.balance ?? 0), + balanceCad: Number(data.balance_cad ?? data.balance ?? 0), + breakdown: data.breakdown_json, + status: typeof data.status === "string" ? (data.status as never) : undefined, + }); + return record( + def, + row as unknown as Record, + FINANCE_ALIASES + ); + }, + delete(db, _def, id) { + if (!new HoldingsService(db).delete(id)) { + throw httpError(404, "Finance connection not found"); + } + }, + actions: { + add_manual(db, def, _id, input, ctx) { + return financeConnectionAdapter.create!(db, def, input, ctx); + }, + disconnect(db, _def, id) { + if (!new HoldingsService(db).delete(id)) { + throw httpError(404, "Finance connection not found"); + } + return { ok: true }; + }, + connect_external() { + return unsupported("External finance connection"); + }, + refresh_external() { + return unsupported("External finance refresh"); + }, + }, +}; + +export const platformActionAdapters = [ + shareGrantAdapter, + directConversationAdapter, + directMessageAdapter, + supportTicketAdapter, + supportMessageAdapter, + catalogSourceAdapter, + marketplaceListingAdapter, + marketplaceEntitlementAdapter, + bridgeConnectionAdapter, + peerConnectionAdapter, + inferenceEndpointAdapter, + financeConnectionAdapter, +] as const; + +const OBJECT_TYPE_BY_ADAPTER_ID: Record = { + share_grant_read: "ShareGrant", + dm_conversation_read: "DirectConversation", + dm_message_read: "DirectMessage", + support_ticket_read: "SupportTicket", + support_message_read: "SupportMessage", + catalog_source_read: "CatalogSource", + marketplace_listing_read: "MarketplaceListing", + marketplace_entitlement_read: "MarketplaceEntitlement", + bridge_connection_read: "BridgeConnection", + peer_connection_read: "PeerConnection", + inference_endpoint_read: "InferenceEndpoint", + finance_connection_service: "FinanceConnection", +}; + +/** Registration metadata for the ObjectType bootstrap layer to consume. */ +export const platformActionAdapterRegistrations = platformActionAdapters.map( + (adapter) => ({ + objectType: OBJECT_TYPE_BY_ADAPTER_ID[adapter.id]!, + adapterId: adapter.id, + actions: Object.keys(adapter.actions ?? {}), + }) +); + +const writeRoles: ActionDef["roles"] = [ + "editor", + "owner", + "intelligence", +]; +const emptySchema = { type: "object", additionalProperties: false }; +const objectSchema = ( + properties: Record, + required: string[] = [] +) => ({ + type: "object", + additionalProperties: false, + properties, + required: required.length ? required : undefined, +}); +const action = ( + name: string, + options: Partial = {} +): ActionDef => ({ + name, + label: name + .split("_") + .map((part) => part[0]!.toUpperCase() + part.slice(1)) + .join(" "), + target: "record", + effect: "write", + execution: "sync", + roles: writeRoles, + inputSchema: emptySchema, + ...options, +}); + +export const PLATFORM_ACTION_METADATA: Record = { + ShareGrant: [ + action("grant", { + target: "collection", + confirmation: { required: true }, + idempotency: { required: true }, + inputSchema: objectSchema( + { + resource_kind: { type: "string" }, + resource_id: { type: "string" }, + grantee_user_id: { type: "string" }, + grantee_tenant_id: { type: "string" }, + role: { enum: ["viewer", "editor", "owner"] }, + }, + ["resource_kind", "resource_id"] + ), + }), + action("revoke", { + effect: "destructive", + confirmation: { required: true }, + }), + ], + DirectConversation: [ + action("start", { + target: "collection", + idempotency: { required: true }, + inputSchema: objectSchema({ + kind: { enum: ["direct", "group"] }, + title: { type: "string" }, + member_user_ids: { type: "array", items: { type: "string" } }, + }), + }), + action("mark_read", { + roles: ["viewer", ...writeRoles], + inputSchema: objectSchema({ message_id: { type: "string" } }), + }), + ], + DirectMessage: [ + action("send", { + target: "collection", + idempotency: { required: true }, + inputSchema: objectSchema( + { + conversation_id: { type: "string" }, + body_text: { type: "string" }, + attachments: { type: "array" }, + }, + ["conversation_id"] + ), + }), + ], + SupportTicket: [ + action("open", { + target: "collection", + idempotency: { required: true }, + inputSchema: objectSchema( + { + subject: { type: "string" }, + body: { type: "string" }, + category: { type: "string" }, + priority: { type: "string" }, + }, + ["subject"] + ), + }), + action("reply", { + idempotency: { required: true }, + inputSchema: objectSchema({ body: { type: "string" } }, ["body"]), + }), + action("set_status", { + roles: ["owner", "intelligence"], + inputSchema: objectSchema({ + status: { type: "string" }, + priority: { type: ["string", "null"] }, + }), + }), + ], + SupportMessage: [ + action("reply", { + target: "collection", + idempotency: { required: true }, + inputSchema: objectSchema( + { ticket_id: { type: "string" }, body: { type: "string" } }, + ["ticket_id", "body"] + ), + }), + ], + CatalogSource: [ + action("add", { + target: "collection", + inputSchema: objectSchema( + { name: { type: "string" }, url: { type: "string" } }, + ["name", "url"] + ), + }), + action("remove", { + effect: "destructive", + confirmation: { required: true }, + }), + action("fetch_external", { + target: "collection", + effect: "external", + execution: "async", + cancellable: false, + confirmation: { required: true }, + idempotency: { required: true }, + }), + ], + MarketplaceListing: [ + action("acquire_live", { + effect: "external", + confirmation: { required: true }, + idempotency: { required: true }, + }), + action("publish", { + effect: "external", + confirmation: { required: true }, + idempotency: { required: true }, + }), + action("archive", { + effect: "destructive", + confirmation: { required: true }, + }), + ], + MarketplaceEntitlement: [ + action("cancel", { + effect: "destructive", + confirmation: { required: true }, + }), + ], + BridgeConnection: [ + action("register", { + target: "collection", + confirmation: { required: true }, + sensitiveInputPaths: ["remote_bridge_token"], + inputSchema: objectSchema( + { + label: { type: "string" }, + mode: { type: "string" }, + remote_bridge_url: { type: "string" }, + remote_bridge_token: { type: "string" }, + }, + ["label", "mode"] + ), + }), + action("touch"), + action("probe_remote", { + effect: "external", + execution: "async", + cancellable: false, + confirmation: { required: true }, + }), + ], + PeerConnection: [ + action("invite", { + target: "collection", + effect: "external", + confirmation: { required: true }, + }), + action("accept", { + effect: "external", + confirmation: { required: true }, + }), + action("refresh_health", { + effect: "external", + execution: "async", + cancellable: false, + }), + ], + InferenceEndpoint: [ + action("publish", { + target: "collection", + effect: "external", + confirmation: { required: true }, + idempotency: { required: true }, + inputSchema: objectSchema( + { + name: { type: "string" }, + base_model_path: { type: "string" }, + adapter_ids_json: { type: "array", items: { type: "string" } }, + meter_unit: { type: "string" }, + meter_rate: { type: "number" }, + capacity_hint: { type: "number" }, + }, + ["name", "base_model_path"] + ), + }), + action("run_remote", { + effect: "external", + execution: "async", + cancellable: true, + confirmation: { required: true }, + idempotency: { required: true }, + }), + ], + FinanceConnection: [ + action("add_manual", { + target: "collection", + inputSchema: objectSchema( + { + name: { type: "string" }, + category: { type: "string" }, + provider: { type: "string" }, + label: { type: "string" }, + balance: { type: "number" }, + currency: { type: "string" }, + }, + ["category", "provider", "label", "currency"] + ), + }), + action("disconnect", { + effect: "destructive", + confirmation: { required: true }, + }), + action("connect_external", { + target: "collection", + effect: "external", + execution: "async", + cancellable: false, + confirmation: { required: true }, + }), + action("refresh_external", { + effect: "external", + execution: "async", + cancellable: true, + }), + ], +}; diff --git a/apps/bridge/src/kernel/adapters/productivity.ts b/apps/bridge/src/kernel/adapters/productivity.ts new file mode 100644 index 0000000..048122a --- /dev/null +++ b/apps/bridge/src/kernel/adapters/productivity.ts @@ -0,0 +1,687 @@ +import { v4 as uuidv4 } from "uuid"; +import type { + ActionDef, + ObjectTypeDef, + RecordData, + RecordRow, +} from "@godmode/kernel"; +import type { AppDatabase } from "../../db.js"; +import { + advanceSubtaskOnResultComment, + reconcileParentProgress, +} from "../../services/card-progress.js"; +import { ensureUserProject } from "../../services/user-productivity.js"; +import { broadcastCardActivity } from "../../ws-broker.js"; +import type { + OperationContext, + RecordAdapter, + RecordQuery, +} from "../adapter-registry.js"; + +function requireUser(ctx: OperationContext): string { + if (!ctx.userId) { + throw Object.assign(new Error("Authenticated user required"), { status: 401 }); + } + return ctx.userId; +} + +function record( + def: ObjectTypeDef, + row: Record +): RecordRow { + const data: RecordData = {}; + for (const field of def.fields) { + if (field.secret || !(field.name in row)) continue; + const value = row[field.name]; + if (field.fieldType === "Check") data[field.name] = Boolean(value); + else if (field.fieldType === "JSON" && typeof value === "string") { + try { + data[field.name] = JSON.parse(value); + } catch { + data[field.name] = value; + } + } else data[field.name] = value; + } + const id = String(row.id); + return { id, objectType: def.name, data: { id, ...data } }; +} + +function paging(query: RecordQuery): { limit: number; offset: number } { + return { + limit: Math.min(Math.max(Number(query.limit) || 100, 1), 500), + offset: Math.max(Number(query.offset) || 0, 0), + }; +} + +function notFound(label: string): never { + throw Object.assign(new Error(`${label} not found`), { status: 404 }); +} + +function badRequest(message: string): never { + throw Object.assign(new Error(message), { status: 400 }); +} + +function requiredText(data: RecordData, name: string): string { + const value = data[name]; + if (typeof value !== "string" || !value.trim()) { + badRequest(`${name} required`); + } + return value.trim(); +} + +const WRITE_ACTION_ROLES = ["editor", "owner", "intelligence"] as const; + +export const CALENDAR_EVENT_ACTIONS: ActionDef[] = [ + { + name: "transition", + label: "Transition", + description: "Change the lifecycle status of this calendar event.", + target: "record", + effect: "write", + execution: "sync", + roles: [...WRITE_ACTION_ROLES], + confirmation: { required: false }, + inputSchema: { + type: "object", + additionalProperties: false, + required: ["status"], + properties: { + status: { + type: "string", + enum: ["scheduled", "completed", "cancelled"], + }, + }, + }, + }, +]; + +export const TASK_CARD_ACTIONS: ActionDef[] = [ + { + name: "move", + label: "Move", + description: "Move this card to another Kanban lane.", + target: "record", + effect: "write", + execution: "sync", + roles: [...WRITE_ACTION_ROLES], + confirmation: { required: false }, + inputSchema: { + type: "object", + additionalProperties: false, + required: ["column_id"], + properties: { + column_id: { type: "string", minLength: 1 }, + sort_order: { type: "integer", minimum: 0 }, + }, + }, + }, + { + name: "assign", + label: "Assign", + description: "Assign or unassign an agent from this card.", + target: "record", + effect: "write", + execution: "sync", + roles: [...WRITE_ACTION_ROLES], + confirmation: { required: false }, + inputSchema: { + type: "object", + additionalProperties: false, + required: ["assigned_agent_id"], + properties: { + assigned_agent_id: { type: ["string", "null"], minLength: 1 }, + }, + }, + }, + { + name: "transition", + label: "Transition", + description: "Change this card's lifecycle status and canonical lane.", + target: "record", + effect: "write", + execution: "sync", + roles: [...WRITE_ACTION_ROLES], + confirmation: { required: false }, + inputSchema: { + type: "object", + additionalProperties: false, + required: ["status"], + properties: { + status: { + type: "string", + enum: ["pending", "working", "review", "blocked", "accepted", "done", "cancelled"], + }, + }, + }, + }, + { + name: "add_comment", + label: "Add comment", + description: "Append an audit comment to this card.", + target: "record", + effect: "write", + execution: "sync", + roles: [...WRITE_ACTION_ROLES], + confirmation: { required: false }, + inputSchema: { + type: "object", + additionalProperties: false, + required: ["body"], + properties: { + body: { type: "string", minLength: 1 }, + kind: { + type: "string", + enum: ["note", "action", "result", "issue"], + }, + }, + }, + }, +]; + +export const CARD_COMMENT_ACTIONS: ActionDef[] = [ + { + ...TASK_CARD_ACTIONS.find((action) => action.name === "add_comment")!, + target: "collection", + description: "Append an audit comment to a user-owned task card.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["card_id", "body"], + properties: { + card_id: { type: "string", minLength: 1 }, + body: { type: "string", minLength: 1 }, + kind: { + type: "string", + enum: ["note", "action", "result", "issue"], + }, + }, + }, + }, +]; + +const CALENDAR_WRITABLE = new Set([ + "kind", + "title", + "description", + "start_at", + "end_at", + "all_day", + "location", + "linked_card_id", + "linked_run_id", + "status", +]); + +function calendarRow( + db: AppDatabase, + id: string, + userId: string +): Record | undefined { + return db + .prepare(`SELECT * FROM ai_calendar_events WHERE id=? AND user_id=?`) + .get(id, userId) as Record | undefined; +} + +export const calendarEventServiceAdapter: RecordAdapter = { + id: "calendar_event_service", + list(db, def, query, ctx) { + const userId = requireUser(ctx); + const { limit, offset } = paging(query); + const rows = db + .prepare( + `SELECT * FROM ai_calendar_events WHERE user_id=? + ORDER BY start_at ASC LIMIT ? OFFSET ?` + ) + .all(userId, limit, offset) as Record[]; + const total = ( + db + .prepare(`SELECT COUNT(*) AS c FROM ai_calendar_events WHERE user_id=?`) + .get(userId) as { c: number } + ).c; + return { + objectType: def.name, + records: rows.map((row) => record(def, row)), + total, + }; + }, + get(db, def, id, ctx) { + const row = calendarRow(db, id, requireUser(ctx)); + return row ? record(def, row) : null; + }, + create(db, def, data, ctx) { + const userId = requireUser(ctx); + const id = typeof data.id === "string" && data.id ? data.id : uuidv4(); + db.prepare( + `INSERT INTO ai_calendar_events + (id, agent_id, user_id, kind, title, description, start_at, end_at, + all_day, location, linked_card_id, linked_run_id, status) + VALUES (?, '', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + id, + userId, + data.kind ?? "event", + data.title, + data.description ?? null, + data.start_at, + data.end_at ?? null, + data.all_day ? 1 : 0, + data.location ?? null, + data.linked_card_id ?? null, + data.linked_run_id ?? null, + data.status ?? "scheduled" + ); + return record(def, calendarRow(db, id, userId)!); + }, + update(db, def, id, data, ctx) { + const userId = requireUser(ctx); + if (!calendarRow(db, id, userId)) notFound("CalendarEvent"); + const sets: string[] = []; + const values: unknown[] = []; + for (const [name, value] of Object.entries(data)) { + if (!CALENDAR_WRITABLE.has(name)) continue; + sets.push(`"${name}"=?`); + values.push(name === "all_day" ? (value ? 1 : 0) : value ?? null); + } + if (sets.length) { + sets.push(`updated_at=datetime('now')`); + db.prepare( + `UPDATE ai_calendar_events SET ${sets.join(", ")} WHERE id=? AND user_id=?` + ).run(...values, id, userId); + } + return record(def, calendarRow(db, id, userId)!); + }, + delete(db, _def, id, ctx) { + const result = db + .prepare(`DELETE FROM ai_calendar_events WHERE id=? AND user_id=?`) + .run(id, requireUser(ctx)); + if (!result.changes) notFound("CalendarEvent"); + }, + actions: { + transition(db, def, id, input, ctx) { + const userId = requireUser(ctx); + if (!calendarRow(db, id, userId)) notFound("CalendarEvent"); + const status = requiredText(input, "status"); + if (!["scheduled", "completed", "cancelled"].includes(status)) { + badRequest("invalid calendar status"); + } + db.prepare( + `UPDATE ai_calendar_events + SET status=?, updated_at=datetime('now') + WHERE id=? AND user_id=?` + ).run(status, id, userId); + return record(def, calendarRow(db, id, userId)!); + }, + }, +}; + +const CARD_WRITABLE = new Set([ + "title", + "description", + "prompt", + "context_json", + "tags_json", + "due_at", + "linked_chat_id", + "linked_workflow_id", + "priority", + "parent_card_id", + "status", + "assigned_agent_id", +]); + +function cardRow( + db: AppDatabase, + id: string, + projectId: string +): Record | undefined { + return db + .prepare(`SELECT * FROM ai_project_cards WHERE id=? AND project_id=?`) + .get(id, projectId) as Record | undefined; +} + +function canonicalColumnExists(db: AppDatabase, columnId: string): boolean { + return Boolean( + db.prepare(`SELECT id FROM ai_project_columns WHERE id=?`).get(columnId) + ); +} + +function notifyCardMutation( + db: AppDatabase, + card: Record, + ctx: OperationContext, + reason: string +): void { + if ( + ctx.bus && + reason !== "comment" && + (card.column_id === "done" || + card.status === "accepted" || + card.status === "done") + ) { + const projectOwner = db + .prepare(`SELECT agent_id FROM ai_projects WHERE id=?`) + .get(String(card.project_id)) as { agent_id: string | null } | undefined; + ctx.bus.emit("card_completed", { + cardId: String(card.id), + agentId: + (card.assigned_agent_id as string | null) ?? + projectOwner?.agent_id ?? + "intelligence", + }); + } + broadcastCardActivity(ctx.tenantId, { + cardId: String(card.id), + agentId: (card.assigned_agent_id as string | null) ?? null, + chatId: (card.linked_chat_id as string | null) ?? null, + reason, + }); +} + +function appendScopedComment( + db: AppDatabase, + def: ObjectTypeDef, + cardId: string, + input: RecordData, + ctx: OperationContext +): RecordRow { + const projectId = ensureUserProject(requireUser(ctx), db); + const card = cardRow(db, cardId, projectId); + if (!card) notFound("TaskCard"); + const body = requiredText(input, "body"); + const kind = + input.kind === undefined ? null : requiredText(input, "kind"); + if (kind && !["note", "action", "result", "issue"].includes(kind)) { + badRequest("invalid comment kind"); + } + const author = ctx.source === "agent" || ctx.agentId ? "agent" : "user"; + const commentId = uuidv4(); + db.prepare( + `INSERT INTO ai_card_comments (id, card_id, author, body, kind) + VALUES (?, ?, ?, ?, ?)` + ).run(commentId, cardId, author, body, kind); + + if (author === "agent" && kind === "result") { + advanceSubtaskOnResultComment(db, cardId, ctx.tenantId); + } else if (author === "agent" && card.parent_card_id) { + reconcileParentProgress(db, String(card.parent_card_id), ctx.tenantId); + } + notifyCardMutation(db, cardRow(db, cardId, projectId)!, ctx, "comment"); + const row = db + .prepare(`SELECT * FROM ai_card_comments WHERE id=? AND card_id=?`) + .get(commentId, cardId) as Record; + return record(def, row); +} + +export const taskCardServiceAdapter: RecordAdapter = { + id: "task_card_service", + list(db, def, query, ctx) { + const projectId = ensureUserProject(requireUser(ctx), db); + const { limit, offset } = paging(query); + const rows = db + .prepare( + `SELECT * FROM ai_project_cards WHERE project_id=? + ORDER BY sort_order ASC LIMIT ? OFFSET ?` + ) + .all(projectId, limit, offset) as Record[]; + const total = ( + db + .prepare(`SELECT COUNT(*) AS c FROM ai_project_cards WHERE project_id=?`) + .get(projectId) as { c: number } + ).c; + return { + objectType: def.name, + records: rows.map((row) => record(def, row)), + total, + }; + }, + get(db, def, id, ctx) { + const row = cardRow(db, id, ensureUserProject(requireUser(ctx), db)); + return row ? record(def, row) : null; + }, + create(db, def, data, ctx) { + const projectId = ensureUserProject(requireUser(ctx), db); + const columnId = + typeof data.column_id === "string" ? data.column_id : "backlog"; + const id = typeof data.id === "string" && data.id ? data.id : uuidv4(); + const order = ( + db + .prepare( + `SELECT COALESCE(MAX(sort_order), -1) AS value + FROM ai_project_cards WHERE project_id=? AND column_id=?` + ) + .get(projectId, columnId) as { value: number } + ).value; + db.prepare( + `INSERT INTO ai_project_cards + (id, project_id, column_id, title, description, prompt, context_json, + tags_json, due_at, linked_chat_id, linked_workflow_id, priority, + parent_card_id, status, assigned_agent_id, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + id, + projectId, + columnId, + data.title, + data.description ?? null, + data.prompt ?? null, + data.context_json == null ? null : JSON.stringify(data.context_json), + data.tags_json == null ? null : JSON.stringify(data.tags_json), + data.due_at ?? null, + data.linked_chat_id ?? null, + data.linked_workflow_id ?? null, + data.priority ?? 2, + data.parent_card_id ?? null, + data.status ?? null, + data.assigned_agent_id ?? null, + order + 1 + ); + return record(def, cardRow(db, id, projectId)!); + }, + update(db, def, id, data, ctx) { + const projectId = ensureUserProject(requireUser(ctx), db); + if (!cardRow(db, id, projectId)) notFound("TaskCard"); + const sets: string[] = []; + const values: unknown[] = []; + for (const [name, raw] of Object.entries(data)) { + if (!CARD_WRITABLE.has(name)) continue; + const value = + ["context_json", "tags_json"].includes(name) && + raw != null && + typeof raw !== "string" + ? JSON.stringify(raw) + : raw; + sets.push(`"${name}"=?`); + values.push(value ?? null); + } + if (sets.length) { + sets.push(`updated_at=datetime('now')`); + db.prepare( + `UPDATE ai_project_cards SET ${sets.join(", ")} WHERE id=? AND project_id=?` + ).run(...values, id, projectId); + } + return record(def, cardRow(db, id, projectId)!); + }, + delete(db, _def, id, ctx) { + const projectId = ensureUserProject(requireUser(ctx), db); + const result = db + .prepare(`DELETE FROM ai_project_cards WHERE id=? AND project_id=?`) + .run(id, projectId); + if (!result.changes) notFound("TaskCard"); + }, + actions: { + move(db, def, id, input, ctx) { + const projectId = ensureUserProject(requireUser(ctx), db); + const current = cardRow(db, id, projectId); + if (!current) notFound("TaskCard"); + const columnId = requiredText(input, "column_id"); + if (!canonicalColumnExists(db, columnId)) { + badRequest("unknown project column"); + } + let sortOrder: number; + if (input.sort_order === undefined) { + sortOrder = + ( + db + .prepare( + `SELECT COALESCE(MAX(sort_order), -1) AS value + FROM ai_project_cards WHERE project_id=? AND column_id=?` + ) + .get(projectId, columnId) as { value: number } + ).value + 1; + } else { + sortOrder = Number(input.sort_order); + if (!Number.isInteger(sortOrder) || sortOrder < 0) { + badRequest("sort_order must be a non-negative integer"); + } + } + const impliedStatus = + columnId === "done" && !["accepted", "done", "cancelled"].includes(String(current.status ?? "")) + ? "done" + : current.status; + db.prepare( + `UPDATE ai_project_cards + SET column_id=?, sort_order=?, status=?, updated_at=datetime('now') + WHERE id=? AND project_id=?` + ).run(columnId, sortOrder, impliedStatus ?? null, id, projectId); + const updated = cardRow(db, id, projectId)!; + if (updated.parent_card_id) { + reconcileParentProgress(db, String(updated.parent_card_id), ctx.tenantId); + } + notifyCardMutation(db, updated, ctx, "card_updated"); + return record(def, updated); + }, + assign(db, def, id, input, ctx) { + const projectId = ensureUserProject(requireUser(ctx), db); + if (!cardRow(db, id, projectId)) notFound("TaskCard"); + const raw = input.assigned_agent_id; + if (raw !== null && (typeof raw !== "string" || !raw.trim())) { + badRequest("assigned_agent_id must be non-empty text or null"); + } + const assignedAgentId = raw === null ? null : raw.trim(); + db.prepare( + `UPDATE ai_project_cards + SET assigned_agent_id=?, updated_at=datetime('now') + WHERE id=? AND project_id=?` + ).run(assignedAgentId, id, projectId); + const updated = cardRow(db, id, projectId)!; + notifyCardMutation(db, updated, ctx, "card_updated"); + return record(def, updated); + }, + transition(db, def, id, input, ctx) { + const projectId = ensureUserProject(requireUser(ctx), db); + const current = cardRow(db, id, projectId); + if (!current) notFound("TaskCard"); + const status = requiredText(input, "status"); + const columnByStatus: Record = { + pending: "backlog", + working: "in_progress", + review: "review", + accepted: "done", + done: "done", + cancelled: "done", + }; + if ( + !["pending", "working", "review", "blocked", "accepted", "done", "cancelled"].includes( + status + ) + ) { + badRequest("invalid card status"); + } + const columnId = columnByStatus[status] ?? String(current.column_id); + db.prepare( + `UPDATE ai_project_cards + SET status=?, column_id=?, updated_at=datetime('now') + WHERE id=? AND project_id=?` + ).run(status, columnId, id, projectId); + const updated = cardRow(db, id, projectId)!; + if (updated.parent_card_id) { + reconcileParentProgress(db, String(updated.parent_card_id), ctx.tenantId); + } + notifyCardMutation(db, updated, ctx, "card_updated"); + return record(def, updated); + }, + add_comment(db, _def, id, input, ctx) { + return appendScopedComment(db, CARD_COMMENT_RECORD_DEF, id, input, ctx); + }, + }, +}; + +const CARD_COMMENT_RECORD_DEF: ObjectTypeDef = { + name: "CardComment", + label: "Card Comment", + storage: { kind: "adapter", adapterId: "card_comment_service" }, + fields: [ + { name: "id", label: "Id", fieldType: "Data" }, + { name: "card_id", label: "Card Id", fieldType: "Data" }, + { name: "author", label: "Author", fieldType: "Data" }, + { name: "body", label: "Body", fieldType: "Data" }, + { name: "kind", label: "Kind", fieldType: "Data" }, + { name: "created_at", label: "Created At", fieldType: "Data" }, + ], +}; + +export const cardCommentServiceAdapter: RecordAdapter = { + id: "card_comment_service", + list(db, def, query, ctx) { + const projectId = ensureUserProject(requireUser(ctx), db); + const { limit, offset } = paging(query); + const rows = db + .prepare( + `SELECT c.* FROM ai_card_comments c + JOIN ai_project_cards card ON card.id=c.card_id + WHERE card.project_id=? + ORDER BY c.created_at DESC LIMIT ? OFFSET ?` + ) + .all(projectId, limit, offset) as Record[]; + const total = ( + db + .prepare( + `SELECT COUNT(*) AS c FROM ai_card_comments c + JOIN ai_project_cards card ON card.id=c.card_id + WHERE card.project_id=?` + ) + .get(projectId) as { c: number } + ).c; + return { + objectType: def.name, + records: rows.map((row) => record(def, row)), + total, + }; + }, + get(db, def, id, ctx) { + const projectId = ensureUserProject(requireUser(ctx), db); + const row = db + .prepare( + `SELECT c.* FROM ai_card_comments c + JOIN ai_project_cards card ON card.id=c.card_id + WHERE c.id=? AND card.project_id=?` + ) + .get(id, projectId) as Record | undefined; + return row ? record(def, row) : null; + }, + create(db, def, data, ctx) { + const projectId = ensureUserProject(requireUser(ctx), db); + const cardId = String(data.card_id ?? ""); + if (!cardRow(db, cardId, projectId)) notFound("TaskCard"); + const id = typeof data.id === "string" && data.id ? data.id : uuidv4(); + db.prepare( + `INSERT INTO ai_card_comments (id, card_id, author, body) + VALUES (?, ?, ?, ?)` + ).run(id, cardId, data.author ?? "user", data.body); + const row = db + .prepare(`SELECT * FROM ai_card_comments WHERE id=?`) + .get(id) as Record; + return record(def, row); + }, + actions: { + add_comment(db, def, _id, input, ctx) { + return appendScopedComment( + db, + def, + requiredText(input, "card_id"), + input, + ctx + ); + }, + }, +}; diff --git a/apps/bridge/src/kernel/adapters/runtime.ts b/apps/bridge/src/kernel/adapters/runtime.ts new file mode 100644 index 0000000..e751e44 --- /dev/null +++ b/apps/bridge/src/kernel/adapters/runtime.ts @@ -0,0 +1,1057 @@ +import type { + ActionDef, + ObjectTypeDef, + RecordData, + RecordRow, +} from "@godmode/kernel"; +import type { AppDatabase } from "../../db.js"; +import { getCoreDb } from "../../core-db.js"; +import { + resolveToolConfirmation, + type AgentMessage, + type AgentSampling, +} from "../../services/ai-agent.js"; +import { AiDatasetBuilder, type DatasetSource } from "../../services/ai-dataset-builder.js"; +import type { AiQueueWorker, EnqueueInput } from "../../services/ai-queue-worker.js"; +import type { + AiTrainingManager, + TrainingJobConfig, +} from "../../services/ai-training-manager.js"; +import { + listModelCatalog, + selectIntelligenceModel, +} from "../../services/model-catalog.js"; +import { runRemoteInference } from "../../services/inference-service.js"; +import type { LlmManager } from "../../services/llm-manager.js"; +import type { + OperationContext, + RecordAdapter, + RecordQuery, +} from "../adapter-registry.js"; + +type IntegrationKind = "calendar" | "email"; + +export interface RuntimeAdapterServices { + llm: Pick< + LlmManager, + | "getStatus" + | "getSamplingParams" + | "scanModels" + | "start" + | "stop" + | "restart" + | "isReady" + | "getServerBaseUrl" + | "getEnabledAdapterPaths" + >; + queue: Pick; + training: Pick; + /** + * Must be the same policy-enforcing chat command used by the HTTP route. + * The kernel deliberately does not duplicate chat streaming/tool policy. + */ + sendMessage(input: { + db: AppDatabase; + chatId: string; + content: string; + agentId?: string; + context: OperationContext; + signal?: AbortSignal; + }): Promise; + /** Must enqueue through the configured provider connector/scheduler. */ + syncIntegration(input: { + db: AppDatabase; + kind: IntegrationKind; + context: OperationContext; + }): Promise; +} + +let services: RuntimeAdapterServices | undefined; + +/** Wire the already-running Bridge singletons; do not construct parallel managers. */ +export function configureRuntimeAdapterServices(next: RuntimeAdapterServices): void { + services = next; +} + +/** Test/shutdown helper. */ +export function clearRuntimeAdapterServices(): void { + services = undefined; +} + +function runtime(): RuntimeAdapterServices { + if (!services) { + throw httpError(503, "Runtime ObjectType services are not configured"); + } + return services; +} + +function httpError(status: number, message: string): Error { + return Object.assign(new Error(message), { status }); +} + +function requiredText(data: RecordData, name: string): string { + const value = data[name]; + if (typeof value !== "string" || !value.trim()) { + throw httpError(400, `${name} required`); + } + return value.trim(); +} + +function requiredUser(ctx: OperationContext): string { + if (!ctx.userId) throw httpError(401, "Authenticated user required"); + return ctx.userId; +} + +function ownerOnly(ctx: OperationContext): void { + requiredUser(ctx); + if (ctx.role !== "owner") throw httpError(403, "Owner role required"); +} + +function runtimeOperator(ctx: OperationContext): void { + requiredUser(ctx); + if (ctx.role !== "owner" && ctx.role !== "intelligence") { + throw httpError(403, "Runtime operator role required"); + } +} + +function record(def: ObjectTypeDef, id: string, data: RecordData): RecordRow { + return { id, objectType: def.name, data: { id, ...data } }; +} + +function parseJson(value: unknown): unknown { + if (typeof value !== "string") return value; + try { + return JSON.parse(value); + } catch { + return value; + } +} + +function page(rows: T[], query: RecordQuery): { rows: T[]; total: number } { + const offset = Math.max(Number(query.offset) || 0, 0); + const limit = Math.min(Math.max(Number(query.limit) || 100, 1), 500); + return { rows: rows.slice(offset, offset + limit), total: rows.length }; +} + +function chatRow( + db: AppDatabase, + id: string, + userId: string +): Record | undefined { + return db + .prepare( + `SELECT id, title, user_id, created_at, updated_at + FROM ai_chats + WHERE id = ? AND (user_id IS NULL OR user_id = ?)` + ) + .get(id, userId) as Record | undefined; +} + +function requireChat(db: AppDatabase, id: string, ctx: OperationContext) { + const row = chatRow(db, id, requiredUser(ctx)); + if (!row) throw httpError(404, "Chat session not found"); + return row; +} + +export const CHAT_SESSION_ACTIONS: ActionDef[] = [ + { + name: "send_message", + label: "Send message", + description: "Run a chat turn through the configured policy-enforcing chat runtime.", + target: "record", + effect: "external", + execution: "async", + roles: ["editor", "owner", "intelligence"], + confirmation: { required: false }, + inputSchema: { + type: "object", + additionalProperties: false, + required: ["content"], + properties: { + content: { type: "string", minLength: 1 }, + agent_id: { type: "string", minLength: 1 }, + }, + }, + timeoutMs: 300_000, + cancellable: true, + }, + { + name: "confirm_tool", + label: "Confirm tool", + description: "Resolve a pending tool confirmation for this authenticated chat actor.", + target: "record", + effect: "write", + execution: "sync", + roles: ["editor", "owner"], + confirmation: { required: false }, + inputSchema: { + type: "object", + additionalProperties: false, + required: ["tool_call_id", "approved"], + properties: { + tool_call_id: { type: "string", minLength: 1 }, + approved: { type: "boolean" }, + }, + }, + }, + { + name: "truncate", + label: "Truncate history", + description: "Delete chat messages after the selected anchor message.", + target: "record", + effect: "destructive", + execution: "sync", + roles: ["editor", "owner"], + confirmation: { required: true, ttlSeconds: 300 }, + inputSchema: { + type: "object", + additionalProperties: false, + required: ["after_message_id"], + properties: { + after_message_id: { type: "string", minLength: 1 }, + }, + }, + }, +]; + +export const chatSessionRuntimeAdapter: RecordAdapter = { + id: "chat_session_runtime", + policy: { + authorize(_operation, _def, ctx) { + requiredUser(ctx); + }, + }, + list(db, def, query, ctx) { + const userId = requiredUser(ctx); + const rows = db + .prepare( + `SELECT id, title, user_id, created_at, updated_at + FROM ai_chats + WHERE user_id IS NULL OR user_id = ? + ORDER BY updated_at DESC` + ) + .all(userId) as Array>; + const result = page(rows, query); + return { + objectType: def.name, + records: result.rows.map((row) => + record(def, String(row.id), { + title: row.title, + created_at: row.created_at, + updated_at: row.updated_at, + }) + ), + total: result.total, + }; + }, + get(db, def, id, ctx) { + const row = chatRow(db, id, requiredUser(ctx)); + return row + ? record(def, id, { + title: row.title, + created_at: row.created_at, + updated_at: row.updated_at, + }) + : null; + }, + actions: { + async send_message(db, _def, id, input, ctx) { + requireChat(db, id, ctx); + return runtime().sendMessage({ + db, + chatId: id, + content: requiredText(input, "content"), + agentId: + typeof input.agent_id === "string" ? input.agent_id.trim() || undefined : undefined, + context: ctx, + signal: ctx.signal, + }); + }, + confirm_tool(db, _def, id, input, ctx) { + requireChat(db, id, ctx); + return { + ok: resolveToolConfirmation( + requiredText(input, "tool_call_id"), + input.approved === true + ), + }; + }, + truncate(db, _def, id, input, ctx) { + requireChat(db, id, ctx); + const anchorId = requiredText(input, "after_message_id"); + const anchor = db + .prepare( + `SELECT created_at FROM ai_messages WHERE id = ? AND chat_id = ?` + ) + .get(anchorId, id) as { created_at: string } | undefined; + if (!anchor) throw httpError(404, "Message not found"); + const deleted = db + .prepare(`DELETE FROM ai_messages WHERE chat_id = ? AND created_at > ?`) + .run(id, anchor.created_at).changes; + return { ok: true, deleted }; + }, + }, +}; + +export const chatMessageRuntimeAdapter: RecordAdapter = { + id: "chat_message_runtime", + policy: { + authorize(_operation, _def, ctx) { + requiredUser(ctx); + }, + }, + list(db, def, query, ctx) { + const userId = requiredUser(ctx); + const chatId = + typeof query.filters?.chat_id === "string" + ? query.filters.chat_id + : query.parentId; + const rows = db + .prepare( + `SELECT m.id, m.chat_id, m.role, m.content_json, m.created_at + FROM ai_messages m + JOIN ai_chats c ON c.id = m.chat_id + WHERE (c.user_id IS NULL OR c.user_id = ?) + AND (? IS NULL OR m.chat_id = ?) + ORDER BY m.created_at ASC` + ) + .all(userId, chatId ?? null, chatId ?? null) as Array>; + const result = page(rows, query); + return { + objectType: def.name, + records: result.rows.map((row) => + record(def, String(row.id), { + chat_id: row.chat_id, + role: row.role, + content: parseJson(row.content_json), + created_at: row.created_at, + }) + ), + total: result.total, + }; + }, + get(db, def, id, ctx) { + const row = db + .prepare( + `SELECT m.id, m.chat_id, m.role, m.content_json, m.created_at + FROM ai_messages m + JOIN ai_chats c ON c.id = m.chat_id + WHERE m.id = ? AND (c.user_id IS NULL OR c.user_id = ?)` + ) + .get(id, requiredUser(ctx)) as Record | undefined; + return row + ? record(def, id, { + chat_id: row.chat_id, + role: row.role, + content: parseJson(row.content_json), + created_at: row.created_at, + }) + : null; + }, +}; + +function safeModelStatus(status: ReturnType): RecordData { + return { + state: status.state, + health_ok: status.healthOk, + pid: status.pid, + port: status.port, + ctx_size: status.ctxSize, + error: status.error, + }; +} + +export const MODEL_RUNTIME_ACTIONS: ActionDef[] = [ + { + name: "select_model", + label: "Select model", + description: "Select a model already present in the authorized model catalog.", + target: "record", + effect: "external", + execution: "async", + roles: ["owner"], + confirmation: { required: true, ttlSeconds: 300 }, + inputSchema: { + type: "object", + additionalProperties: false, + required: ["model_id"], + properties: { model_id: { type: "string", minLength: 1 } }, + }, + timeoutMs: 180_000, + cancellable: false, + }, + ...(["start", "stop", "restart"] as const).map( + (name): ActionDef => ({ + name, + label: `${name[0]!.toUpperCase()}${name.slice(1)} model runtime`, + description: `${name} the configured local model process.`, + target: "record", + effect: name === "stop" ? "destructive" : "external", + execution: "async", + roles: ["owner"], + confirmation: { required: true, ttlSeconds: 300 }, + inputSchema: { + type: "object", + additionalProperties: false, + properties: {}, + }, + timeoutMs: 180_000, + cancellable: false, + }) + ), +]; + +export const modelRuntimeAdapter: RecordAdapter = { + id: "model_runtime", + policy: { + authorize(operation, _def, ctx) { + if (operation === "action") ownerOnly(ctx); + else requiredUser(ctx); + }, + }, + list(_db, def, query) { + const rows = [record(def, "runtime", safeModelStatus(runtime().llm.getStatus()))]; + const result = page(rows, query); + return { objectType: def.name, records: result.rows, total: result.total }; + }, + get(_db, def, id) { + return id === "runtime" + ? record(def, id, safeModelStatus(runtime().llm.getStatus())) + : null; + }, + actions: { + async select_model(db, _def, _id, input, ctx) { + ownerOnly(ctx); + const active = runtime(); + const modelId = requiredText(input, "model_id"); + const catalog = await listModelCatalog( + db, + active.llm as LlmManager, + getCoreDb(), + requiredUser(ctx) + ); + const selected = catalog.models.find((model) => model.id === modelId); + if (!selected) throw httpError(404, "Model is not in the authorized catalog"); + return selectIntelligenceModel(db, active.llm as LlmManager, { + source: selected.source, + path: selected.path, + model: selected.model, + provider: selected.provider, + endpointId: selected.endpointId, + }); + }, + start(_db, _def, _id, _input, ctx) { + ownerOnly(ctx); + return runtime().llm.start(); + }, + stop(_db, _def, _id, _input, ctx) { + ownerOnly(ctx); + return runtime().llm.stop(); + }, + restart(_db, _def, _id, _input, ctx) { + ownerOnly(ctx); + return runtime().llm.restart(); + }, + }, +}; + +export const PROMPT_QUEUE_ACTIONS: ActionDef[] = [ + { + name: "enqueue", + label: "Enqueue prompt", + description: "Submit work through the live durable prompt queue.", + target: "collection", + effect: "external", + execution: "sync", + roles: ["owner", "intelligence"], + confirmation: { required: true, ttlSeconds: 300 }, + inputSchema: { + type: "object", + additionalProperties: false, + properties: { + prompt: { type: "string", minLength: 1 }, + workflow_id: { type: "string", minLength: 1 }, + adapter_ids: { type: "array", items: { type: "string", minLength: 1 } }, + context: { type: "object" }, + priority: { type: "integer", minimum: -100, maximum: 100 }, + }, + anyOf: [{ required: ["prompt"] }, { required: ["workflow_id"] }], + }, + idempotency: { required: true, ttlSeconds: 86_400 }, + }, + { + name: "cancel", + label: "Cancel queued prompt", + target: "record", + effect: "destructive", + execution: "sync", + roles: ["owner", "intelligence"], + confirmation: { required: true, ttlSeconds: 300 }, + inputSchema: { + type: "object", + additionalProperties: false, + properties: {}, + }, + }, +]; + +function queueRecord(def: ObjectTypeDef, row: Record): RecordRow { + return record(def, String(row.id), { + status: row.status, + priority: row.priority, + workflow_id: row.workflow_id, + prompt: row.prompt, + error: row.error, + created_at: row.created_at, + started_at: row.started_at, + finished_at: row.finished_at, + }); +} + +export const promptQueueRuntimeAdapter: RecordAdapter = { + id: "prompt_queue_runtime", + policy: { + authorize(_operation, _def, ctx) { + runtimeOperator(ctx); + }, + }, + list(db, def, query) { + const rows = db + .prepare( + `SELECT id, status, priority, workflow_id, prompt, error, + created_at, started_at, finished_at + FROM ai_prompt_queue + ORDER BY CASE status WHEN 'running' THEN 0 WHEN 'pending' THEN 1 ELSE 2 END, + priority DESC, created_at ASC` + ) + .all() as Array>; + const result = page(rows, query); + return { + objectType: def.name, + records: result.rows.map((row) => queueRecord(def, row)), + total: result.total, + }; + }, + get(db, def, id) { + const row = db + .prepare( + `SELECT id, status, priority, workflow_id, prompt, error, + created_at, started_at, finished_at + FROM ai_prompt_queue WHERE id = ?` + ) + .get(id) as Record | undefined; + return row ? queueRecord(def, row) : null; + }, + actions: { + enqueue(_db, _def, _id, input, ctx) { + runtimeOperator(ctx); + const enqueueInput: EnqueueInput = { + prompt: typeof input.prompt === "string" ? input.prompt : undefined, + workflowId: + typeof input.workflow_id === "string" ? input.workflow_id : undefined, + adapterIds: Array.isArray(input.adapter_ids) + ? input.adapter_ids.filter( + (value): value is string => typeof value === "string" + ) + : undefined, + context: + input.context && typeof input.context === "object" + ? (input.context as Record) + : undefined, + priority: + typeof input.priority === "number" ? input.priority : undefined, + tenantId: ctx.tenantId, + }; + return { ok: true, jobId: runtime().queue.enqueue(enqueueInput) }; + }, + cancel(db, _def, id, _input, ctx) { + runtimeOperator(ctx); + const current = db + .prepare(`SELECT status FROM ai_prompt_queue WHERE id = ?`) + .get(id) as { status: string } | undefined; + if (!current) throw httpError(404, "Prompt queue job not found"); + const changed = db + .prepare( + `UPDATE ai_prompt_queue + SET status = 'cancelled', finished_at = datetime('now') + WHERE id = ? AND status IN ('pending', 'running')` + ) + .run(id).changes; + if (!changed) throw httpError(409, `Job cannot be cancelled (status=${current.status})`); + return { ok: true }; + }, + }, +}; + +export const DATASET_ACTIONS: ActionDef[] = [ + { + name: "build_dataset", + label: "Build dataset", + description: "Build a managed dataset from authorized platform records.", + target: "collection", + effect: "write", + execution: "sync", + roles: ["owner"], + confirmation: { required: true, ttlSeconds: 300 }, + inputSchema: { + type: "object", + additionalProperties: false, + required: ["name", "source"], + properties: { + name: { type: "string", minLength: 1, maxLength: 120 }, + domain: { type: "string", maxLength: 120 }, + source: { + type: "string", + enum: ["chats", "workflows", "queue", "comments"], + }, + chat_ids: { type: "array", items: { type: "string", minLength: 1 } }, + limit: { type: "integer", minimum: 1, maximum: 100_000 }, + }, + }, + idempotency: { required: true, ttlSeconds: 86_400 }, + }, +]; + +function datasetRecord(def: ObjectTypeDef, row: Record): RecordRow { + return record(def, String(row.id), { + name: row.name, + domain: row.domain, + row_count: row.row_count, + created_at: row.created_at, + updated_at: row.updated_at, + }); +} + +export const datasetRuntimeAdapter: RecordAdapter = { + id: "dataset_runtime", + policy: { + authorize(operation, _def, ctx) { + if (operation === "action") ownerOnly(ctx); + else requiredUser(ctx); + }, + }, + list(db, def, query) { + const rows = db + .prepare( + `SELECT id, name, domain, row_count, created_at, updated_at + FROM ai_datasets ORDER BY updated_at DESC` + ) + .all() as Array>; + const result = page(rows, query); + return { + objectType: def.name, + records: result.rows.map((row) => datasetRecord(def, row)), + total: result.total, + }; + }, + get(db, def, id) { + const row = db + .prepare( + `SELECT id, name, domain, row_count, created_at, updated_at + FROM ai_datasets WHERE id = ?` + ) + .get(id) as Record | undefined; + return row ? datasetRecord(def, row) : null; + }, + actions: { + build_dataset(db, def, _id, input, ctx) { + ownerOnly(ctx); + const row = new AiDatasetBuilder(db).buildDataset({ + name: requiredText(input, "name"), + domain: typeof input.domain === "string" ? input.domain : undefined, + source: requiredText(input, "source") as DatasetSource, + chatIds: Array.isArray(input.chat_ids) + ? input.chat_ids.filter( + (value): value is string => typeof value === "string" + ) + : undefined, + limit: typeof input.limit === "number" ? input.limit : undefined, + }); + return datasetRecord(def, row as unknown as Record); + }, + }, +}; + +export const TRAINING_JOB_ACTIONS: ActionDef[] = [ + { + name: "enqueue", + label: "Enqueue training", + description: "Start a managed training job through the live trainer.", + target: "collection", + effect: "external", + execution: "async", + roles: ["owner"], + confirmation: { required: true, ttlSeconds: 300 }, + inputSchema: { + type: "object", + additionalProperties: false, + required: ["adapter_name", "dataset_id"], + properties: { + adapter_name: { type: "string", minLength: 1, maxLength: 120 }, + dataset_id: { type: "string", minLength: 1 }, + domain: { type: "string", maxLength: 120 }, + description: { type: "string", maxLength: 1000 }, + base_model: { type: "string", minLength: 1 }, + epochs: { type: "integer", minimum: 1, maximum: 100 }, + learning_rate: { type: "number", exclusiveMinimum: 0 }, + lora_rank: { type: "integer", minimum: 1, maximum: 1024 }, + }, + }, + idempotency: { required: true, ttlSeconds: 86_400 }, + timeoutMs: 30_000, + cancellable: false, + }, + { + name: "cancel", + label: "Cancel training", + target: "record", + effect: "destructive", + execution: "sync", + roles: ["owner"], + confirmation: { required: true, ttlSeconds: 300 }, + inputSchema: { + type: "object", + additionalProperties: false, + properties: {}, + }, + }, +]; + +function trainingRecord(def: ObjectTypeDef, row: Record): RecordRow { + return record(def, String(row.id), { + adapter_id: row.adapter_id, + status: row.status, + progress: row.progress, + error: row.error, + created_at: row.created_at, + started_at: row.started_at, + finished_at: row.finished_at, + }); +} + +export const trainingJobRuntimeAdapter: RecordAdapter = { + id: "training_job_runtime", + policy: { + authorize(operation, _def, ctx) { + if (operation === "action") ownerOnly(ctx); + else requiredUser(ctx); + }, + }, + list(_db, def, query) { + const result = page(runtime().training.listJobs(500), query); + return { + objectType: def.name, + records: result.rows.map((row) => + trainingRecord(def, row as unknown as Record) + ), + total: result.total, + }; + }, + get(_db, def, id) { + const row = runtime().training.getJob(id); + return row + ? trainingRecord(def, row as unknown as Record) + : null; + }, + actions: { + async enqueue(_db, _def, _id, input, ctx) { + ownerOnly(ctx); + const config: TrainingJobConfig = { + adapterName: requiredText(input, "adapter_name"), + datasetId: requiredText(input, "dataset_id"), + domain: typeof input.domain === "string" ? input.domain : undefined, + description: + typeof input.description === "string" ? input.description : undefined, + baseModel: + typeof input.base_model === "string" ? input.base_model : undefined, + epochs: typeof input.epochs === "number" ? input.epochs : undefined, + learningRate: + typeof input.learning_rate === "number" + ? input.learning_rate + : undefined, + loraRank: + typeof input.lora_rank === "number" ? input.lora_rank : undefined, + }; + return { ok: true, jobId: await runtime().training.startJob(config) }; + }, + cancel(_db, _def, id, _input, ctx) { + ownerOnly(ctx); + const current = runtime().training.getJob(id); + if (!current) throw httpError(404, "Training job not found"); + if (current.status !== "pending" && current.status !== "running") { + throw httpError(409, `Training job cannot be cancelled (status=${current.status})`); + } + if (!runtime().training.cancelJob()) { + throw httpError(409, "Training job is not the active trainer job"); + } + return { ok: true }; + }, + }, +}; + +export const INFERENCE_RUNTIME_ACTIONS: ActionDef[] = [ + { + name: "run_inference", + label: "Run inference", + description: "Run metered inference through endpoint admission and credit policy.", + target: "collection", + effect: "external", + execution: "async", + roles: ["owner", "intelligence"], + confirmation: { required: true, ttlSeconds: 300 }, + inputSchema: { + type: "object", + additionalProperties: false, + required: ["endpoint_id", "messages"], + properties: { + endpoint_id: { type: "string", minLength: 1 }, + messages: { + type: "array", + minItems: 1, + maxItems: 200, + items: { + type: "object", + additionalProperties: false, + required: ["role", "content"], + properties: { + role: { + type: "string", + enum: ["system", "user", "assistant", "tool"], + }, + content: { type: "string" }, + }, + }, + }, + sampling: { type: "object" }, + priority: { type: "integer", minimum: -100, maximum: 100 }, + }, + }, + timeoutMs: 300_000, + cancellable: true, + }, +]; + +export const inferenceRuntimeAdapter: RecordAdapter = { + id: "inference_runtime", + policy: { + authorize(_operation, _def, ctx) { + runtimeOperator(ctx); + }, + }, + actions: { + run_inference(_db, _def, _id, input, ctx) { + runtimeOperator(ctx); + const active = runtime(); + const base = active.llm.getSamplingParams(); + const sampling: AgentSampling = { + ...base, + ...(input.sampling && typeof input.sampling === "object" + ? input.sampling + : {}), + } as AgentSampling; + const rawMessages = Array.isArray(input.messages) ? input.messages : []; + const messages: AgentMessage[] = rawMessages.map((value) => { + const item = value as Record; + return { + role: item.role as AgentMessage["role"], + content: String(item.content ?? ""), + }; + }); + return runRemoteInference(getCoreDb(), active.llm as LlmManager, { + endpointId: requiredText(input, "endpoint_id"), + buyerUserId: requiredUser(ctx), + buyerTenantId: + ctx.tenantId ?? (() => { throw httpError(401, "Tenant required"); })(), + messages, + sampling, + priority: typeof input.priority === "number" ? input.priority : undefined, + }).then((content) => ({ ok: true, content })); + }, + }, +}; + +export const INTEGRATION_RUNTIME_ACTIONS: ActionDef[] = [ + { + name: "sync", + label: "Sync integration", + description: "Request a sync through the configured provider connector.", + target: "record", + effect: "external", + execution: "async", + roles: ["owner"], + confirmation: { required: true, ttlSeconds: 300 }, + inputSchema: { + type: "object", + additionalProperties: false, + properties: {}, + }, + idempotency: { required: true, ttlSeconds: 300 }, + timeoutMs: 60_000, + cancellable: false, + }, +]; + +function integrationStatus( + db: AppDatabase, + kind: IntegrationKind +): { connected: boolean; lastSyncAt: string | null } { + const names = + kind === "calendar" + ? ["google_calendar_oauth"] + : ["gmail_oauth", "imap_credentials"]; + const placeholders = names.map(() => "?").join(","); + try { + const row = db + .prepare( + `SELECT created_at FROM ai_secrets + WHERE name IN (${placeholders}) ORDER BY created_at DESC LIMIT 1` + ) + .get(...names) as { created_at: string } | undefined; + return { connected: Boolean(row), lastSyncAt: row?.created_at ?? null }; + } catch { + return { connected: false, lastSyncAt: null }; + } +} + +export const integrationRuntimeAdapter: RecordAdapter = { + id: "integration_runtime", + policy: { + authorize(operation, _def, ctx) { + if (operation === "action") ownerOnly(ctx); + else requiredUser(ctx); + }, + }, + list(db, def, query) { + const rows = (["calendar", "email"] as const).map((kind) => { + const status = integrationStatus(db, kind); + return record(def, kind, { + kind, + connected: status.connected, + last_sync_at: status.lastSyncAt, + }); + }); + const result = page(rows, query); + return { objectType: def.name, records: result.rows, total: result.total }; + }, + get(db, def, id) { + if (id !== "calendar" && id !== "email") return null; + const status = integrationStatus(db, id); + return record(def, id, { + kind: id, + connected: status.connected, + last_sync_at: status.lastSyncAt, + }); + }, + actions: { + async sync(db, _def, id, _input, ctx) { + ownerOnly(ctx); + if (id !== "calendar" && id !== "email") { + throw httpError(404, "Integration not found"); + } + const status = integrationStatus(db, id); + if (!status.connected) throw httpError(400, `${id} integration is not connected`); + return runtime().syncIntegration({ db, kind: id, context: ctx }); + }, + }, +}; + +export const runtimeAdapters = [ + chatSessionRuntimeAdapter, + chatMessageRuntimeAdapter, + modelRuntimeAdapter, + promptQueueRuntimeAdapter, + datasetRuntimeAdapter, + trainingJobRuntimeAdapter, + inferenceRuntimeAdapter, + integrationRuntimeAdapter, +] as const; + +export const runtimeAdapterRegistrations = [ + { + objectType: "ChatSession", + adapterId: "chat_session_runtime", + database: "tenant", + operations: ["list", "get"], + fields: ["id", "title", "created_at", "updated_at"], + // Chat turn streaming remains a protocol exception. Session lifecycle + // actions are kernel-dispatched, while send_message stays on /api/ai/chat. + actions: CHAT_SESSION_ACTIONS.filter( + (action) => action.name !== "send_message" + ), + }, + { + objectType: "ChatMessage", + adapterId: "chat_message_runtime", + database: "tenant", + operations: ["list", "get"], + fields: ["id", "chat_id", "role", "content", "created_at"], + actions: [], + }, + { + objectType: "ModelRuntime", + adapterId: "model_runtime", + database: "tenant", + operations: ["list", "get"], + fields: ["id", "state", "health_ok", "pid", "port", "ctx_size", "error"], + actions: MODEL_RUNTIME_ACTIONS, + }, + { + objectType: "PromptQueueJob", + adapterId: "prompt_queue_runtime", + database: "tenant", + operations: ["list", "get"], + fields: [ + "id", + "status", + "priority", + "workflow_id", + "prompt", + "error", + "created_at", + "started_at", + "finished_at", + ], + actions: PROMPT_QUEUE_ACTIONS, + }, + { + objectType: "Dataset", + adapterId: "dataset_runtime", + database: "tenant", + operations: ["list", "get"], + fields: ["id", "name", "domain", "row_count", "created_at", "updated_at"], + actions: DATASET_ACTIONS, + }, + { + objectType: "TrainingJob", + adapterId: "training_job_runtime", + database: "tenant", + operations: ["list", "get"], + fields: [ + "id", + "adapter_id", + "status", + "progress", + "error", + "created_at", + "started_at", + "finished_at", + ], + actions: TRAINING_JOB_ACTIONS, + }, + { + objectType: "InferenceRuntime", + adapterId: "inference_runtime", + database: "core", + operations: [], + fields: ["id"], + actions: INFERENCE_RUNTIME_ACTIONS, + }, + { + objectType: "IntegrationRuntime", + adapterId: "integration_runtime", + database: "tenant", + operations: ["list", "get"], + fields: ["id", "kind", "connected", "last_sync_at"], + actions: INTEGRATION_RUNTIME_ACTIONS, + }, +] as const; diff --git a/apps/bridge/src/kernel/adapters/sql-read.ts b/apps/bridge/src/kernel/adapters/sql-read.ts new file mode 100644 index 0000000..d2d6be3 --- /dev/null +++ b/apps/bridge/src/kernel/adapters/sql-read.ts @@ -0,0 +1,147 @@ +import type { AppDatabase } from "../../db.js"; +import type { ObjectTypeDef, RecordData, RecordRow } from "@godmode/kernel"; +import { getCoreDb } from "../../core-db.js"; +import type { + OperationContext, + RecordAdapter, + RecordQuery, +} from "../adapter-registry.js"; + +export interface SqlReadAdapterOptions { + id: string; + table: string; + database?: "tenant" | "core"; + idColumn?: string; + /** Restrict rows to the active tenant or user when the column exists. */ + scope?: "tenant" | "user" | "admin"; + scopeColumn?: string; + defaultSort?: string; +} + +function ident(value: string): string { + if (!/^[a-z_][a-z0-9_]*$/i.test(value)) throw new Error(`Unsafe SQL identifier: ${value}`); + return `"${value}"`; +} + +function sourceDb( + tenantDb: AppDatabase, + options: SqlReadAdapterOptions +): AppDatabase { + return options.database === "core" ? getCoreDb() : tenantDb; +} + +function scopeClause( + options: SqlReadAdapterOptions, + ctx: OperationContext, + where: string[], + values: unknown[] +): void { + if (options.scope === "admin") { + if (!ctx.isAdmin) { + where.push("1=0"); + } + return; + } + if (options.scope === "tenant") { + if (!ctx.tenantId) { + where.push("1=0"); + return; + } + where.push(`${ident(options.scopeColumn ?? "tenant_id")}=?`); + values.push(ctx.tenantId); + } + if (options.scope === "user") { + if (!ctx.userId) { + where.push("1=0"); + return; + } + where.push(`${ident(options.scopeColumn ?? "user_id")}=?`); + values.push(ctx.userId); + } +} + +function decode(def: ObjectTypeDef, row: Record): RecordRow { + const id = String(row.__record_id ?? row.id); + const data: RecordData = { id }; + for (const field of def.fields) { + if (field.name === "id" || field.secret || !(field.name in row)) continue; + const raw = row[field.name]; + if (field.fieldType === "Check") data[field.name] = Boolean(raw); + else if (field.fieldType === "JSON" && typeof raw === "string") { + try { + data[field.name] = JSON.parse(raw); + } catch { + data[field.name] = raw; + } + } else data[field.name] = raw; + } + return { id, objectType: def.name, data }; +} + +export function createSqlReadAdapter( + options: SqlReadAdapterOptions +): RecordAdapter { + const table = ident(options.table); + const idColumn = options.idColumn ?? "id"; + const idSelect = `${ident(idColumn)} AS __record_id`; + return { + id: options.id, + policy: { + authorize(_operation, _def, ctx) { + if (options.scope === "admin") return ctx.isAdmin === true; + if (options.scope === "tenant") return Boolean(ctx.tenantId); + if (options.scope === "user") return Boolean(ctx.userId); + return true; + }, + }, + list(tenantDb, def, query, ctx) { + const db = sourceDb(tenantDb, options); + const where: string[] = []; + const values: unknown[] = []; + scopeClause(options, ctx, where, values); + const allowed = new Set(def.fields.map((field) => field.name)); + for (const [name, value] of Object.entries(query.filters ?? {})) { + if (!allowed.has(name) || name === "id") continue; + where.push(`${ident(name)}=?`); + values.push(value); + } + const sort = + query.sort && allowed.has(query.sort) + ? query.sort + : options.defaultSort && allowed.has(options.defaultSort) + ? options.defaultSort + : idColumn; + const direction = query.direction === "asc" ? "ASC" : "DESC"; + const predicate = where.length ? ` WHERE ${where.join(" AND ")}` : ""; + const total = ( + db.prepare(`SELECT COUNT(*) AS c FROM ${table}${predicate}`).get( + ...values + ) as { c: number } + ).c; + const limit = Math.min(Math.max(Number(query.limit) || 100, 1), 500); + const offset = Math.max(Number(query.offset) || 0, 0); + const rows = db + .prepare( + `SELECT ${idSelect}, * FROM ${table}${predicate} ORDER BY ${ident(sort)} ${direction} LIMIT ? OFFSET ?` + ) + .all(...values, limit, offset) as Record[]; + return { + objectType: def.name, + records: rows.map((row) => decode(def, row)), + total, + }; + }, + get(tenantDb, def, id, ctx) { + const db = sourceDb(tenantDb, options); + const where = [`${ident(idColumn)}=?`]; + const values: unknown[] = [id]; + scopeClause(options, ctx, where, values); + const row = db + .prepare( + `SELECT ${idSelect}, * FROM ${table} WHERE ${where.join(" AND ")} LIMIT 1` + ) + .get(...values) as Record | undefined; + return row ? decode(def, row) : null; + }, + }; +} diff --git a/apps/bridge/src/kernel/adapters/structure-node.ts b/apps/bridge/src/kernel/adapters/structure-node.ts new file mode 100644 index 0000000..9df02bd --- /dev/null +++ b/apps/bridge/src/kernel/adapters/structure-node.ts @@ -0,0 +1,235 @@ +import type { AppDatabase } from "../../db.js"; +import type { ListRecordsResult, RecordData, RecordRow } from "@godmode/kernel"; +import { + createNode, + deleteNode, + flattenStructureNodes, + readStructure, + reorderNodes, + setNodeAgent, + StructureError, + updateNode, +} from "../../services/structure.js"; +import type { RecordAdapter } from "../adapter-registry.js"; + +function nodeToRecord(n: { + id: string; + parentId: string | null; + label: string; + icon: string; + segment: string; + path: string; + kind: string; + objectType: string | null; + rightSidebar: string | null; + agentId: string | null; + builtIn: boolean; + sortOrder: number; + tabs: unknown; +}): RecordRow { + return { + id: n.id, + objectType: "StructureNode", + data: { + id: n.id, + parent_id: n.parentId, + label: n.label, + icon: n.icon, + segment: n.segment, + kind: n.kind, + object_type: n.objectType, + right_sidebar: n.rightSidebar, + agent_id: n.agentId, + built_in: n.builtIn, + sort_order: n.sortOrder, + tabs_json: n.tabs, + path: n.path, + }, + }; +} + +export function listStructureNodeRecords( + db: AppDatabase, + opts?: { parentId?: string | null; limit?: number; offset?: number } +): ListRecordsResult { + const flat = flattenStructureNodes(readStructure(db).nodes); + let rows = flat.map(nodeToRecord); + if (opts && "parentId" in (opts ?? {})) { + const pid = opts?.parentId ?? null; + rows = rows.filter((r) => (r.data.parent_id ?? null) === pid); + } + const limit = opts?.limit; + const offset = Math.max(Number(opts?.offset) || 0, 0); + const total = rows.length; + rows = rows.slice(offset, limit != null && limit > 0 ? offset + limit : undefined); + return { objectType: "StructureNode", records: rows, total }; +} + +export function getStructureNodeRecord( + db: AppDatabase, + id: string +): RecordRow | null { + const flat = flattenStructureNodes(readStructure(db).nodes); + const n = flat.find((x) => x.id === id); + return n ? nodeToRecord(n) : null; +} + +function slugFromId(id: string, parentId: string | null): string { + if (!parentId) return id; + const prefix = `${parentId}-`; + if (id.startsWith(prefix)) return id.slice(prefix.length); + return id; +} + +export function createStructureNodeRecord( + db: AppDatabase, + data: RecordData +): RecordRow { + const parentId = + data.parent_id === undefined || data.parent_id === null || data.parent_id === "" + ? null + : String(data.parent_id); + const rawId = data.id != null ? String(data.id) : ""; + const slug = parentId ? slugFromId(rawId || String(data.segment ?? ""), parentId) : rawId; + try { + const node = createNode(db, { + id: slug || rawId, + parentId, + label: String(data.label ?? ""), + icon: String(data.icon ?? "folder"), + segment: data.segment != null ? String(data.segment) : undefined, + kind: data.kind != null ? String(data.kind) : undefined, + objectType: + data.object_type === undefined + ? undefined + : data.object_type == null || data.object_type === "" + ? null + : String(data.object_type), + rightSidebar: + data.right_sidebar === undefined + ? undefined + : data.right_sidebar == null || data.right_sidebar === "" + ? null + : String(data.right_sidebar), + }); + return nodeToRecord(node); + } catch (err) { + if (err instanceof StructureError) throw err; + throw err; + } +} + +export function updateStructureNodeRecord( + db: AppDatabase, + id: string, + data: RecordData +): RecordRow { + const patch: Parameters[2] = {}; + if (data.label != null) patch.label = String(data.label); + if (data.icon != null) patch.icon = String(data.icon); + if (data.segment != null) patch.segment = String(data.segment); + if (data.kind != null) patch.kind = String(data.kind); + if (data.object_type !== undefined) { + patch.objectType = + data.object_type == null || data.object_type === "" + ? null + : String(data.object_type); + } + if (data.right_sidebar !== undefined) { + patch.rightSidebar = + data.right_sidebar == null || data.right_sidebar === "" + ? null + : String(data.right_sidebar); + } + if (data.parent_id !== undefined) { + patch.parentId = + data.parent_id == null || data.parent_id === "" + ? null + : String(data.parent_id); + } + try { + updateNode(db, id, patch); + } catch (err) { + if (err instanceof StructureError) throw err; + throw err; + } + const row = getStructureNodeRecord(db, id); + if (!row) throw new StructureError(404, "StructureNode not found after update"); + return row; +} + +export function deleteStructureNodeRecord(db: AppDatabase, id: string): void { + deleteNode(db, id); +} + +export const structureNodeAdapter: RecordAdapter = { + id: "structure_nodes", + policy: { + authorize() { + return true; + }, + }, + list: (db, _def, query) => { + const options: { + parentId?: string | null; + limit?: number; + offset?: number; + } = { + limit: query.limit, + offset: query.offset, + }; + if (query.parentId !== undefined) options.parentId = query.parentId; + return listStructureNodeRecords(db, options); + }, + get: (db, _def, id) => getStructureNodeRecord(db, id), + create: (db, _def, data, ctx) => { + const row = createStructureNodeRecord(db, data); + ctx.bus?.emit("structure.node.created", { nodeId: row.id }); + return row; + }, + update: (db, _def, id, data, ctx) => { + const row = updateStructureNodeRecord(db, id, data); + ctx.bus?.emit("structure.node.updated", { nodeId: id }); + return row; + }, + delete: (db, _def, id, ctx) => { + deleteStructureNodeRecord(db, id); + ctx.bus?.emit("structure.node.deleted", { nodeId: id }); + }, + actions: { + set_agent(db, _def, id, input, ctx) { + const agentId = + input.agent_id == null || input.agent_id === "" + ? null + : String(input.agent_id); + setNodeAgent(db, id, agentId); + ctx.bus?.emit("structure.node.updated", { nodeId: id }); + return getStructureNodeRecord(db, id); + }, + move(db, _def, id, input, ctx) { + const row = updateStructureNodeRecord(db, id, { + parent_id: + input.parent_id == null || input.parent_id === "" + ? null + : String(input.parent_id), + }); + ctx.bus?.emit("structure.node.updated", { nodeId: id }); + return row; + }, + reorder(db, _def, _id, input, ctx) { + const parentId = + input.parent_id == null || input.parent_id === "" + ? null + : String(input.parent_id); + const orderedIds = Array.isArray(input.ordered_ids) + ? input.ordered_ids.map(String) + : []; + reorderNodes(db, parentId, orderedIds); + ctx.bus?.emit("structure.reordered", { + kind: "node", + parentId, + }); + return { ok: true }; + }, + }, +}; diff --git a/apps/bridge/src/kernel/auto-tools.ts b/apps/bridge/src/kernel/auto-tools.ts new file mode 100644 index 0000000..6a61f7e --- /dev/null +++ b/apps/bridge/src/kernel/auto-tools.ts @@ -0,0 +1,270 @@ +import type { ObjectTypeDef } from "@godmode/kernel"; +import { fieldsToJsonSchema, perObjectTypeToolNames } from "@godmode/kernel"; +import { listObjectTypes } from "./registry.js"; + +/** Tool shape matches AiToolDef without importing the registry (avoids cycles). */ +export interface KernelToolDef { + name: string; + description: string; + mode: "auto" | "confirm"; + parameters?: Record; + category?: string; + write?: boolean; +} + +const GENERIC_OBJECT_TYPE_TOOLS: KernelToolDef[] = [ + { + name: "list_object_types", + description: + "List registered ObjectTypes (metadata definitions). Prefer these over inventing schemas. Vocabulary: ObjectType / Field / Record — not DocType.", + mode: "auto", + category: "platform", + }, + { + name: "list_records", + description: + "List Records for an ObjectType. For StructureNode, returns the shell page tree rows.", + mode: "auto", + category: "platform", + parameters: { + type: "object", + properties: { + objectType: { + type: "string", + description: "ObjectType name (e.g. StructureNode)", + }, + parent_id: { + type: "string", + description: "Optional parent filter for tree types", + }, + limit: { type: "integer" }, + offset: { type: "integer" }, + filters: { type: "object", additionalProperties: true }, + sort: { type: "string" }, + direction: { type: "string", enum: ["asc", "desc"] }, + }, + required: ["objectType"], + }, + }, + { + name: "get_record", + description: "Get one Record by ObjectType + id.", + mode: "auto", + category: "platform", + parameters: { + type: "object", + properties: { + objectType: { type: "string" }, + id: { type: "string" }, + }, + required: ["objectType", "id"], + }, + }, + { + name: "create_record", + description: + "Create a Record for an ObjectType. StructureNode: pass parent_id null for a department root. Requires confirmation.", + mode: "confirm", + category: "platform", + parameters: { + type: "object", + properties: { + objectType: { type: "string" }, + data: { + type: "object", + description: "Field values for the ObjectType", + additionalProperties: true, + }, + }, + required: ["objectType", "data"], + }, + }, + { + name: "update_record", + description: "Update a Record by ObjectType + id. Requires confirmation.", + mode: "confirm", + category: "platform", + parameters: { + type: "object", + properties: { + objectType: { type: "string" }, + id: { type: "string" }, + data: { type: "object", additionalProperties: true }, + }, + required: ["objectType", "id", "data"], + }, + }, + { + name: "delete_record", + description: "Delete a Record by ObjectType + id. Requires confirmation.", + mode: "confirm", + category: "platform", + parameters: { + type: "object", + properties: { + objectType: { type: "string" }, + id: { type: "string" }, + }, + required: ["objectType", "id"], + }, + }, + { + name: "run_record_action", + description: + "Run an explicit adapter action declared by an ObjectType (for example a move, approval, or transition). Requires confirmation.", + mode: "confirm", + category: "platform", + parameters: { + type: "object", + properties: { + objectType: { type: "string" }, + id: { type: "string" }, + action: { type: "string" }, + input: { type: "object", additionalProperties: true }, + }, + required: ["objectType", "action"], + }, + }, +]; + +/** Core ObjectType tools always registered (not derived per type). */ +export function genericObjectTypeToolDefs(): KernelToolDef[] { + return GENERIC_OBJECT_TYPE_TOOLS; +} + +function perTypeTools(def: ObjectTypeDef, existingNames: Set): KernelToolDef[] { + const names = perObjectTypeToolNames(def.name); + const out: KernelToolDef[] = []; + const createSchema = fieldsToJsonSchema(def.fields, { mode: "create", includeId: true }); + const updateSchema = fieldsToJsonSchema(def.fields, { mode: "update" }); + const listSchema = fieldsToJsonSchema(def.fields, { mode: "list" }); + + if (def.operations?.includes("list") && !existingNames.has(names.list)) { + out.push({ + name: names.list, + description: `List ${def.labelPlural ?? def.label} Records (ObjectType ${def.name}).`, + mode: "auto", + category: "platform", + parameters: listSchema, + }); + } + if (def.operations?.includes("get") && !existingNames.has(names.get)) { + out.push({ + name: names.get, + description: `Get one ${def.label} Record by id.`, + mode: "auto", + category: "platform", + parameters: { + type: "object", + properties: { id: { type: "string" } }, + required: ["id"], + }, + }); + } + if (def.operations?.includes("create") && !existingNames.has(names.create)) { + out.push({ + name: names.create, + description: `Create a ${def.label} Record. Requires confirmation.`, + mode: "confirm", + category: "platform", + parameters: createSchema, + }); + } + if (def.operations?.includes("update") && !existingNames.has(names.update)) { + out.push({ + name: names.update, + description: `Update a ${def.label} Record. Requires confirmation.`, + mode: "confirm", + category: "platform", + parameters: updateSchema, + }); + } + if (def.operations?.includes("delete") && !existingNames.has(names.delete)) { + out.push({ + name: names.delete, + description: `Delete a ${def.label} Record. Requires confirmation.`, + mode: "confirm", + category: "platform", + parameters: { + type: "object", + properties: { id: { type: "string" } }, + required: ["id"], + }, + }); + } + const base = def.name + .replace(/([a-z0-9])([A-Z])/g, "$1_$2") + .toLowerCase(); + for (const action of def.actions ?? []) { + const toolName = `${base}_${action.name}`; + if (existingNames.has(toolName)) continue; + const input = action.inputSchema ?? { + type: "object", + properties: {}, + additionalProperties: false, + }; + const properties = { + ...(input.properties && typeof input.properties === "object" + ? (input.properties as Record) + : {}), + ...(action.target === "collection" + ? {} + : { id: { type: "string", description: `${def.label} record id` } }), + }; + const required = new Set( + Array.isArray(input.required) + ? input.required.filter((item): item is string => typeof item === "string") + : [] + ); + if (action.target !== "collection") required.add("id"); + out.push({ + name: toolName, + description: + action.description ?? + `${action.label} for ${def.label}.`, + mode: + action.confirmation?.required || action.confirm + ? "confirm" + : "auto", + category: "platform", + write: action.effect !== "read", + parameters: { + ...input, + type: "object", + properties, + required: required.size ? [...required] : undefined, + }, + }); + } + return out; +} + +/** + * Per-ObjectType CRUD tools. Skips names already present in the static registry + * (e.g. StructureNode skips update_structure_node / delete_structure_node). + */ +export function objectTypeAutoToolDefs(existingCoreNames: Set): KernelToolDef[] { + const out: KernelToolDef[] = []; + const used = new Set(existingCoreNames); + // Plugin ObjectTypes use the generic Record tools. Per-type tools would leak + // their presence because the legacy tool registry is not tenant-aware. + for (const def of listObjectTypes().filter((item) => !item.pluginId)) { + const generated = perTypeTools(def, used); + for (const tool of generated) { + if (used.has(tool.name)) { + throw new Error(`Generated ObjectType tool collision: ${tool.name}`); + } + used.add(tool.name); + out.push(tool); + } + } + return out; +} + +export function allKernelToolDefs(existingCoreNames: Set): KernelToolDef[] { + return [...genericObjectTypeToolDefs(), ...objectTypeAutoToolDefs(existingCoreNames)]; +} + +export const KERNEL_GENERIC_TOOL_NAMES = new Set( + GENERIC_OBJECT_TYPE_TOOLS.map((t) => t.name) +); diff --git a/apps/bridge/src/kernel/core-object-types.ts b/apps/bridge/src/kernel/core-object-types.ts new file mode 100644 index 0000000..1bdceae --- /dev/null +++ b/apps/bridge/src/kernel/core-object-types.ts @@ -0,0 +1,244 @@ +import type { ObjectTypeDef } from "@godmode/kernel"; +import { registerRecordAdapter } from "./adapter-registry.js"; +import { createSqlReadAdapter } from "./adapters/sql-read.js"; +import { + agentServiceAdapter, + assignmentServiceAdapter, + hookServiceAdapter, + hookRunServiceAdapter, + scheduleServiceAdapter, + wikiPageServiceAdapter, + wikiRevisionServiceAdapter, + workflowServiceAdapter, + workflowRunServiceAdapter, +} from "./adapters/core-services.js"; +import { + calendarEventServiceAdapter, + cardCommentServiceAdapter, + taskCardServiceAdapter, +} from "./adapters/productivity.js"; +import { operationRunAdapter } from "./adapters/operation-runs.js"; +import { + artifactServiceAdapter, + memoryServiceAdapter, + notificationServiceAdapter, + reflectionProposalServiceAdapter, + ruleServiceAdapter, + skillServiceAdapter, + wikiProposalServiceAdapter, +} from "./adapters/content.js"; +import { platformActionAdapters } from "./adapters/platform-actions.js"; +import { AUTOMATION_SPECS } from "./domains/automation.js"; +import { COLLABORATION_SPECS } from "./domains/collaboration.js"; +import { CONNECTIVITY_SUPPORT_SPECS } from "./domains/connectivity-support.js"; +import { FINANCE_SPECS } from "./domains/finance.js"; +import { INTELLIGENCE_SPECS } from "./domains/intelligence.js"; +import { KNOWLEDGE_SPECS } from "./domains/knowledge.js"; +import { MARKETPLACE_SPECS } from "./domains/marketplace.js"; +import { PLATFORM_SPECS } from "./domains/platform.js"; +import { PRODUCTIVITY_SPECS } from "./domains/productivity.js"; +import { RUNTIME_SPECS } from "./domains/runtime.js"; +import { + buildFields, + READ_PERMISSIONS, + WRITE_PERMISSIONS, + type BuiltinSpec, +} from "./domains/shared.js"; +import { runtimeAdapters } from "./adapters/runtime.js"; +import { registerObjectType } from "./registry.js"; + +const DOMAIN_SPECS: BuiltinSpec[] = [ + ...PLATFORM_SPECS, + ...INTELLIGENCE_SPECS, + ...PRODUCTIVITY_SPECS, + ...KNOWLEDGE_SPECS, + ...AUTOMATION_SPECS, + ...COLLABORATION_SPECS, + ...MARKETPLACE_SPECS, + ...FINANCE_SPECS, + ...CONNECTIVITY_SUPPORT_SPECS, + ...RUNTIME_SPECS, +]; + +const SPEC_REGISTRATION_ORDER = [ + "Notification", + "Artifact", + "WorkflowRun", + "HookRun", + "PlatformEvent", + "ActionLog", + "OperationRun", + "FinanceConnection", + "BalanceSnapshot", + "BankLedgerEntry", + "Project", + "ProjectColumn", + "TaskCard", + "CardComment", + "CalendarEvent", + "WikiPage", + "WikiRevision", + "WikiProposal", + "Rule", + "Skill", + "PromptTemplate", + "Memory", + "KnowledgePack", + "ReflectionProposal", + "Agent", + "AgentAssignment", + "Workflow", + "Schedule", + "Hook", + "User", + "UserProfile", + "Tenant", + "TenantMembership", + "ShareGrant", + "MarketplaceListing", + "MarketplaceEntitlement", + "CatalogSource", + "CatalogInstall", + "BridgeConnection", + "PeerConnection", + "InferenceEndpoint", + "SupportTicket", + "SupportMessage", + "DirectConversation", + "DirectMessage", + "ChatSession", + "ChatMessage", + "ModelRuntime", + "PromptQueueJob", + "Dataset", + "TrainingJob", + "InferenceRuntime", + "IntegrationRuntime", +] as const; + +const SPECS_BY_NAME = new Map(DOMAIN_SPECS.map((spec) => [spec.name, spec])); +const SPECS = SPEC_REGISTRATION_ORDER.map((name) => { + const spec = SPECS_BY_NAME.get(name); + if (!spec) throw new Error(`Missing core object type spec: ${name}`); + return spec; +}); + +let registered = false; + +const SERVICE_ADAPTERS = new Map( + [ + agentServiceAdapter, + assignmentServiceAdapter, + workflowServiceAdapter, + workflowRunServiceAdapter, + scheduleServiceAdapter, + hookServiceAdapter, + hookRunServiceAdapter, + wikiPageServiceAdapter, + wikiRevisionServiceAdapter, + taskCardServiceAdapter, + cardCommentServiceAdapter, + calendarEventServiceAdapter, + operationRunAdapter, + artifactServiceAdapter, + memoryServiceAdapter, + notificationServiceAdapter, + reflectionProposalServiceAdapter, + ruleServiceAdapter, + skillServiceAdapter, + wikiProposalServiceAdapter, + ...platformActionAdapters, + ...runtimeAdapters, + ].map((adapter) => [adapter.id, adapter]) +); + +export function registerCoreObjectTypes(): void { + if (registered) return; + for (const spec of SPECS) { + let objectFields = buildFields( + spec.fields, + new Set(spec.writable ?? []), + new Set(spec.required ?? []) + ); + if (spec.name === "CalendarEvent") { + objectFields = objectFields.map((field) => + field.name === "kind" + ? { + ...field, + fieldType: "Select", + options: ["event", "task", "appointment"], + } + : field + ); + } + if (spec.name === "Agent") { + objectFields = objectFields.map((field) => + field.name === "backend" + ? { + ...field, + fieldType: "Select", + options: [ + "local", + "provider", + "cli", + "acp", + "remote", + "cursor", + "cursor_cloud", + ], + } + : field + ); + } + const def: ObjectTypeDef = { + name: spec.name, + label: spec.label, + labelPlural: `${spec.label}s`, + module: spec.module, + accessPolicy: + spec.accessPolicy ?? + (spec.scope === "admin" + ? "platform-admin" + : spec.scope === "user" + ? "user-private" + : spec.scope === "tenant" + ? "tenant-member" + : spec.database === "core" + ? "relationship-scoped" + : "tenant-local"), + database: spec.database ?? "tenant", + storage: { kind: "adapter", adapterId: spec.id }, + fields: objectFields, + contractVersion: 1, + permissions: + spec.permissions ?? + (spec.writable ? WRITE_PERMISSIONS : READ_PERMISSIONS), + operations: + spec.operations ?? + (spec.writable + ? ["list", "get", "create", "update", "delete"] + : ["list", "get"]), + schemaVersion: 1, + actions: spec.actions, + }; + const adapter = + SERVICE_ADAPTERS.get(spec.id) ?? createSqlReadAdapter(spec); + for (const operation of def.operations ?? []) { + if (typeof adapter[operation] !== "function") { + throw new Error( + `ObjectType ${def.name} declares ${operation} but adapter ${adapter.id} does not implement it` + ); + } + } + for (const action of def.actions ?? []) { + if (typeof adapter.actions?.[action.name] !== "function") { + throw new Error( + `ObjectType ${def.name} declares action ${action.name} but adapter ${adapter.id} does not implement it` + ); + } + } + registerRecordAdapter(adapter); + registerObjectType(def); + } + registered = true; +} diff --git a/apps/bridge/src/kernel/domains/automation.ts b/apps/bridge/src/kernel/domains/automation.ts new file mode 100644 index 0000000..cfa1a55 --- /dev/null +++ b/apps/bridge/src/kernel/domains/automation.ts @@ -0,0 +1,30 @@ +import type { BuiltinSpec } from "./shared.js"; +import type { ActionDef } from "@godmode/kernel"; + +const ownerRoles = ["owner", "intelligence"] as const; +const emptyInput = { type: "object", additionalProperties: false }; + +const WORKFLOW_ACTIONS: ActionDef[] = [ + { name: "run", label: "Run", target: "record", effect: "external", execution: "sync", roles: [...ownerRoles], confirmation: { required: true }, idempotency: { required: true }, inputSchema: { type: "object", properties: { input: {} }, additionalProperties: false } }, +]; +const WORKFLOW_RUN_ACTIONS: ActionDef[] = [ + { name: "resume", label: "Resume", target: "record", effect: "external", execution: "sync", roles: [...ownerRoles], confirmation: { required: true }, idempotency: { required: true }, inputSchema: { type: "object", properties: { input: {} }, additionalProperties: false } }, + { name: "cancel", label: "Cancel", target: "record", effect: "destructive", execution: "sync", roles: [...ownerRoles], confirmation: { required: true }, inputSchema: emptyInput }, +]; +const TOGGLE_ACTIONS: ActionDef[] = [ + { name: "enable", label: "Enable", target: "record", effect: "external", execution: "sync", roles: [...ownerRoles], confirmation: { required: true }, inputSchema: emptyInput }, + { name: "disable", label: "Disable", target: "record", effect: "write", execution: "sync", roles: [...ownerRoles], inputSchema: emptyInput }, +]; +const HOOK_RUN_ACTIONS: ActionDef[] = [ + { name: "approve", label: "Approve", target: "record", effect: "external", execution: "async", cancellable: false, roles: [...ownerRoles], confirmation: { required: true }, idempotency: { required: true }, inputSchema: emptyInput }, + { name: "reject", label: "Reject", target: "record", effect: "destructive", execution: "sync", roles: [...ownerRoles], confirmation: { required: true }, idempotency: { required: true }, inputSchema: emptyInput }, +]; + +export const AUTOMATION_SPECS: BuiltinSpec[] = [ + { name: "WorkflowRun", label: "Workflow Run", module: "automation", id: "workflow_run_read", table: "ai_workflow_runs", defaultSort: "updated_at", actions: WORKFLOW_RUN_ACTIONS, fields: ["id", "workflow_id", "status", "trigger_input", ["state_json", "JSON"], "awaiting_node_id", "card_id", ["result_json", "JSON"], "error", "created_at", "updated_at"] }, + { name: "HookRun", label: "Hook Run", module: "automation", id: "hook_run_read", table: "hook_runs", database: "core", defaultSort: "created_at", actions: HOOK_RUN_ACTIONS, fields: ["id", "hook_id", "event_id", "status", "detail", ["result_json", "JSON"], "created_at"] }, + { name: "PlatformEvent", label: "Platform Event", module: "automation", id: "platform_event_read", table: "events", database: "core", scope: "tenant", defaultSort: "created_at", fields: ["id", "type", "actor_kind", "actor_id", "tenant_id", ["payload_json", "JSON"], "created_at"] }, + { name: "Workflow", label: "Workflow", module: "automation", id: "workflow_service", table: "ai_workflows", defaultSort: "updated_at", writable: ["agent_id", "name", "config_json", "enabled"], required: ["name"], operations: ["list", "get", "create", "update", "delete"], actions: WORKFLOW_ACTIONS, fields: ["id", "agent_id", "name", ["config_json", "JSON"], ["enabled", "Check"], "created_at", "updated_at"] }, + { name: "Schedule", label: "Schedule", module: "automation", id: "schedule_service", table: "ai_schedules", defaultSort: "updated_at", writable: ["workflow_id", "cron_expr", "timezone", "enabled"], required: ["workflow_id", "cron_expr"], actions: TOGGLE_ACTIONS, fields: ["id", "workflow_id", "cron_expr", "timezone", ["enabled", "Check"], "last_run_at", "created_at", "updated_at"] }, + { name: "Hook", label: "Hook", module: "automation", id: "hook_service", table: "hooks", database: "core", scope: "tenant", scopeColumn: "owner_tenant_id", defaultSort: "updated_at", writable: ["owner_kind", "owner_id", "owner_tenant_id", "name", "enabled", "trigger_kind", "event_type", "schedule_cron", "condition_json", "action_kind", "action_config_json", "require_approval"], required: ["owner_kind", "owner_id", "name", "trigger_kind", "action_kind"], operations: ["list", "get", "create", "update", "delete"], actions: TOGGLE_ACTIONS, fields: ["id", "owner_kind", "owner_id", "owner_tenant_id", "name", ["enabled", "Check"], "trigger_kind", "event_type", "schedule_cron", ["condition_json", "JSON"], "action_kind", ["action_config_json", "JSON"], ["require_approval", "Check"], "created_at", "updated_at", "last_fired_at"] }, +]; diff --git a/apps/bridge/src/kernel/domains/collaboration.ts b/apps/bridge/src/kernel/domains/collaboration.ts new file mode 100644 index 0000000..d715a06 --- /dev/null +++ b/apps/bridge/src/kernel/domains/collaboration.ts @@ -0,0 +1,7 @@ +import type { BuiltinSpec } from "./shared.js"; +import { PLATFORM_ACTION_METADATA } from "../adapters/platform-actions.js"; + +export const COLLABORATION_SPECS: BuiltinSpec[] = [ + { name: "DirectConversation", label: "Direct Conversation", module: "messages", id: "dm_conversation_read", table: "dm_conversations", database: "core", defaultSort: "updated_at", accessPolicy: "relationship-scoped", writable: ["kind", "title", "member_user_ids"], operations: ["list", "get", "create"], actions: PLATFORM_ACTION_METADATA.DirectConversation, fields: ["id", "kind", "title", ["member_user_ids", "JSON"], "created_by_user_id", "created_at", "updated_at", "last_message_at", "last_message_preview"] }, + { name: "DirectMessage", label: "Direct Message", module: "messages", id: "dm_message_read", table: "dm_messages", database: "core", defaultSort: "created_at", accessPolicy: "relationship-scoped", writable: ["conversation_id", "body_text", "attachments"], required: ["conversation_id"], operations: ["list", "get", "create"], actions: PLATFORM_ACTION_METADATA.DirectMessage, fields: ["id", "conversation_id", "sender_user_id", "body_text", ["attachments", "JSON"], "created_at", "edited_at", "deleted_at"] }, +]; diff --git a/apps/bridge/src/kernel/domains/connectivity-support.ts b/apps/bridge/src/kernel/domains/connectivity-support.ts new file mode 100644 index 0000000..0233171 --- /dev/null +++ b/apps/bridge/src/kernel/domains/connectivity-support.ts @@ -0,0 +1,9 @@ +import type { BuiltinSpec } from "./shared.js"; +import { PLATFORM_ACTION_METADATA } from "../adapters/platform-actions.js"; + +export const CONNECTIVITY_SUPPORT_SPECS: BuiltinSpec[] = [ + { name: "BridgeConnection", label: "Bridge Connection", module: "platform", id: "bridge_connection_read", table: "bridge_connections", database: "core", scope: "tenant", scopeColumn: "owner_tenant_id", defaultSort: "updated_at", writable: ["label", "mode", "remote_bridge_url"], required: ["label", "mode"], operations: ["list", "get", "create", "delete"], actions: PLATFORM_ACTION_METADATA.BridgeConnection, fields: ["id", "owner_tenant_id", "owner_user_id", "label", "mode", "remote_bridge_url", "status", "last_seen_at", "created_at", "updated_at"] }, + { name: "PeerConnection", label: "Peer Connection", module: "platform", id: "peer_connection_read", table: "peer_connections", database: "core", scope: "user", scopeColumn: "local_user_id", defaultSort: "updated_at", actions: PLATFORM_ACTION_METADATA.PeerConnection, fields: ["id", "local_user_id", "remote_bridge_url", "remote_user_id", "remote_display_name", "remote_email", "status", "last_health_at", "created_at", "updated_at"] }, + { name: "SupportTicket", label: "Support Ticket", module: "support", id: "support_ticket_read", table: "support_tickets", database: "core", scope: "tenant", scopeColumn: "requester_tenant_id", defaultSort: "updated_at", accessPolicy: "relationship-scoped", writable: ["subject", "body", "category", "priority", "target_kind", "shared_grant_id", "owner_user_id"], required: ["subject"], operations: ["list", "get", "create"], actions: PLATFORM_ACTION_METADATA.SupportTicket, fields: ["id", "requester_kind", "requester_id", "requester_tenant_id", "subject", "body", "category", "status", "priority", "target_kind", "shared_grant_id", "owner_user_id", "created_at", "updated_at"] }, + { name: "SupportMessage", label: "Support Message", module: "support", id: "support_message_read", table: "support_messages", database: "core", defaultSort: "created_at", accessPolicy: "relationship-scoped", actions: PLATFORM_ACTION_METADATA.SupportMessage, fields: ["id", "ticket_id", "author_kind", "author_id", "body", "created_at"] }, +]; diff --git a/apps/bridge/src/kernel/domains/finance.ts b/apps/bridge/src/kernel/domains/finance.ts new file mode 100644 index 0000000..f2a84a3 --- /dev/null +++ b/apps/bridge/src/kernel/domains/finance.ts @@ -0,0 +1,8 @@ +import type { BuiltinSpec } from "./shared.js"; +import { PLATFORM_ACTION_METADATA } from "../adapters/platform-actions.js"; + +export const FINANCE_SPECS: BuiltinSpec[] = [ + { name: "FinanceConnection", label: "Finance Connection", module: "bank", id: "finance_connection_service", table: "holdings_connections", defaultSort: "created_at", writable: ["category", "provider", "label", "currency", "reference", "external_id", "balance", "balance_cad", "breakdown_json", "status"], required: ["category", "provider", "label", "currency"], operations: ["list", "get", "create", "delete"], actions: PLATFORM_ACTION_METADATA.FinanceConnection, fields: ["id", "category", "provider", "label", "currency", "reference", "external_id", ["balance", "Float"], ["balance_cad", "Float"], ["breakdown_json", "JSON"], "status", "last_synced_at", "created_at"] }, + { name: "BalanceSnapshot", label: "Balance Snapshot", module: "bank", id: "balance_snapshot_read", table: "holdings_balance_snapshots", defaultSort: "as_of", fields: ["id", "connection_id", ["balance", "Float"], "currency", ["balance_cad", "Float"], ["raw_json", "JSON"], "as_of"] }, + { name: "BankLedgerEntry", label: "Bank Ledger Entry", module: "bank", id: "bank_ledger_read", table: "bank_ledger_entries", defaultSort: "recorded_at", fields: ["id", "category", "label", ["amount", "Float"], "currency", "source", "recorded_at"] }, +]; diff --git a/apps/bridge/src/kernel/domains/intelligence.ts b/apps/bridge/src/kernel/domains/intelligence.ts new file mode 100644 index 0000000..8586118 --- /dev/null +++ b/apps/bridge/src/kernel/domains/intelligence.ts @@ -0,0 +1,23 @@ +import type { BuiltinSpec } from "./shared.js"; +import type { ActionDef } from "@godmode/kernel"; +import { CONTENT_LIFECYCLE_ACTIONS } from "../adapters/content.js"; +import { PLATFORM_ACTION_METADATA } from "../adapters/platform-actions.js"; + +const AGENT_ACTIONS: ActionDef[] = [ + { name: "clone", label: "Clone", target: "record", effect: "write", execution: "sync", roles: ["owner", "intelligence"], idempotency: { required: true }, inputSchema: { type: "object", required: ["name"], properties: { id: { type: "string" }, name: { type: "string" }, description: { type: "string" }, parent_id: { type: ["string", "null"] }, team: { type: ["string", "null"] } }, additionalProperties: false } }, + { name: "assign", label: "Assign", target: "record", effect: "write", execution: "sync", roles: ["owner", "intelligence"], confirmation: { required: true }, inputSchema: { type: "object", required: ["scope_type", "scope_id"], properties: { scope_type: { enum: ["department", "division", "page"] }, scope_id: { type: "string" }, role: { enum: ["viewer", "editor", "owner"] } }, additionalProperties: false } }, + { name: "configure_reflection", label: "Configure Reflection", target: "record", effect: "write", execution: "sync", roles: ["owner", "intelligence"], confirmation: { required: true }, inputSchema: { type: "object", properties: { enabled: { type: "boolean" }, mode: { enum: ["approval", "auto"] }, schedule: { type: "object" }, idle: { type: "object" } }, additionalProperties: false } }, + { name: "run_reflection", label: "Run Reflection", target: "record", effect: "external", execution: "sync", roles: ["owner", "intelligence"], confirmation: { required: true }, idempotency: { required: true }, inputSchema: { type: "object", additionalProperties: false } }, +]; + +export const INTELLIGENCE_SPECS: BuiltinSpec[] = [ + { name: "Artifact", label: "Artifact", module: "intelligence", id: "artifact_service", table: "ai_artifacts", defaultSort: "updated_at", writable: ["name", "content", "kind", "mime_type", "description", "source"], required: ["name"], operations: ["list", "get", "create", "update", "delete"], fields: ["id", "agent_id", "name", "content", "kind", "mime_type", ["size_bytes", "Int"], "description", "source", ["has_content", "Check"], "created_at", "updated_at"] }, + { name: "Rule", label: "Rule", module: "intelligence", id: "rule_service", table: "ai_rules", defaultSort: "updated_at", writable: ["id", "description", "body", "always_apply", "globs_json", "departments_json", "priority", "enabled", "status"], required: ["description", "body"], operations: ["list", "get", "create", "update", "delete"], actions: CONTENT_LIFECYCLE_ACTIONS, fields: ["id", "agent_id", "description", "body", ["always_apply", "Check"], ["globs_json", "JSON"], ["departments_json", "JSON"], ["priority", "Int"], ["enabled", "Check"], "status", ["version", "Int"], "created_at", "updated_at"] }, + { name: "Skill", label: "Skill", module: "intelligence", id: "skill_service", table: "ai_skills", defaultSort: "updated_at", writable: ["name", "description", "body", "tools_json", "departments_json", "enabled", "status"], required: ["name", "body"], operations: ["list", "get", "create", "update", "delete"], actions: CONTENT_LIFECYCLE_ACTIONS, fields: ["id", "agent_id", "name", "description", "body", ["tools_json", "JSON"], ["departments_json", "JSON"], ["enabled", "Check"], "status", ["version", "Int"], "created_at", "updated_at"] }, + { name: "PromptTemplate", label: "Prompt Template", module: "intelligence", id: "prompt_read", table: "ai_prompts", defaultSort: "updated_at", fields: ["id", "agent_id", "kind", "label", "content", ["version", "Int"], "created_at", "updated_at"] }, + { name: "Memory", label: "Memory", module: "intelligence", id: "memory_service", table: "ai_memories", defaultSort: "updated_at", writable: ["id", "scope", "chat_id", "text", "category", "source", "enabled", "status", "pack_id", "valid_from", "valid_until"], required: ["text"], operations: ["list", "get", "create", "update", "delete"], actions: CONTENT_LIFECYCLE_ACTIONS, fields: ["id", "agent_id", "scope", "chat_id", "text", "category", "source", ["enabled", "Check"], "status", "pack_id", "valid_from", "valid_until", "created_at", "updated_at"] }, + { name: "ReflectionProposal", label: "Reflection Proposal", module: "intelligence", id: "reflection_proposal_service", table: "ai_reflection_proposals", defaultSort: "updated_at", writable: ["kind", "target_id", "action", "payload_json"], required: ["kind", "action"], operations: ["list", "get", "create"], actions: CONTENT_LIFECYCLE_ACTIONS, fields: ["id", "agent_id", "kind", "target_id", "action", ["payload_json", "JSON"], "status", "created_at", "updated_at"] }, + { name: "Agent", label: "Agent", module: "intelligence", id: "agent_service", table: "ai_agents", defaultSort: "updated_at", writable: ["id", "name", "description", "icon", "backend", "enabled", "parent_id", "team"], required: ["name"], operations: ["list", "get", "create", "update", "delete"], actions: AGENT_ACTIONS, fields: ["id", "name", "description", "icon", "backend", ["enabled", "Check"], ["is_template", "Check"], "parent_id", "team", "created_at", "updated_at"] }, + { name: "AgentAssignment", label: "Agent Assignment", module: "intelligence", id: "agent_assignment_service", table: "ai_agent_assignments", idColumn: "scope_id", writable: ["scope_type", "scope_id", "agent_id", "role"], required: ["scope_type", "scope_id", "agent_id"], operations: ["list", "get", "create", "update", "delete"], fields: ["id", "scope_type", "scope_id", "agent_id", "role", "updated_at"] }, + { name: "InferenceEndpoint", label: "Inference Endpoint", module: "intelligence", id: "inference_endpoint_read", table: "inference_endpoints", database: "core", scope: "tenant", scopeColumn: "owner_tenant_id", defaultSort: "created_at", writable: ["name", "base_model_path", "adapter_ids_json", "meter_unit", "meter_rate", "capacity_hint"], required: ["name", "base_model_path"], operations: ["list", "get", "create"], actions: PLATFORM_ACTION_METADATA.InferenceEndpoint, fields: ["id", "owner_tenant_id", "owner_user_id", "name", "base_model_path", ["adapter_ids_json", "JSON"], "meter_unit", ["meter_rate", "Int"], ["capacity_hint", "Int"], "status", "created_at"] }, +]; diff --git a/apps/bridge/src/kernel/domains/knowledge.ts b/apps/bridge/src/kernel/domains/knowledge.ts new file mode 100644 index 0000000..d9079ab --- /dev/null +++ b/apps/bridge/src/kernel/domains/knowledge.ts @@ -0,0 +1,9 @@ +import type { BuiltinSpec } from "./shared.js"; +import { CONTENT_LIFECYCLE_ACTIONS } from "../adapters/content.js"; + +export const KNOWLEDGE_SPECS: BuiltinSpec[] = [ + { name: "WikiPage", label: "Wiki Page", module: "knowledge", id: "wiki_page_service", table: "wiki_pages", database: "core", scope: "tenant", defaultSort: "updated_at", writable: ["space", "slug", "title", "body_markdown", "visibility"], required: ["title"], fields: ["id", "tenant_id", "space", "slug", "title", "body_markdown", "visibility", "author_user_id", "created_at", "updated_at"] }, + { name: "WikiRevision", label: "Wiki Revision", module: "knowledge", id: "wiki_revision_service", table: "wiki_revisions", database: "core", defaultSort: "created_at", fields: ["id", "page_id", "title", "body_markdown", "author_user_id", "created_at"] }, + { name: "WikiProposal", label: "Wiki Proposal", module: "knowledge", id: "wiki_proposal_service", table: "wiki_page_proposals", database: "core", scope: "tenant", defaultSort: "updated_at", writable: ["action", "space", "slug", "title", "body_markdown", "target_page_id", "reason", "source"], required: ["title"], operations: ["list", "get", "create"], actions: CONTENT_LIFECYCLE_ACTIONS, fields: ["id", "tenant_id", "action", "space", "slug", "title", "body_markdown", "target_page_id", "status", "reason", "source", "created_at", "updated_at"] }, + { name: "KnowledgePack", label: "Knowledge Pack", module: "knowledge", id: "knowledge_pack_read", table: "ai_knowledge_packs", defaultSort: "updated_at", fields: ["id", "name", "description", "created_at", "updated_at"] }, +]; diff --git a/apps/bridge/src/kernel/domains/marketplace.ts b/apps/bridge/src/kernel/domains/marketplace.ts new file mode 100644 index 0000000..a7d6fb3 --- /dev/null +++ b/apps/bridge/src/kernel/domains/marketplace.ts @@ -0,0 +1,9 @@ +import type { BuiltinSpec } from "./shared.js"; +import { PLATFORM_ACTION_METADATA } from "../adapters/platform-actions.js"; + +export const MARKETPLACE_SPECS: BuiltinSpec[] = [ + { name: "MarketplaceListing", label: "Marketplace Listing", module: "marketplace", id: "marketplace_listing_read", table: "marketplace_listings", database: "core", defaultSort: "updated_at", accessPolicy: "relationship-scoped", actions: PLATFORM_ACTION_METADATA.MarketplaceListing, fields: ["id", "seller_user_id", "seller_tenant_id", "kind", "resource_id", "title", "description", ["price_credits", "Int"], "visibility", "status", "created_at", "updated_at"] }, + { name: "MarketplaceEntitlement", label: "Marketplace Entitlement", module: "marketplace", id: "marketplace_entitlement_read", table: "marketplace_entitlements", database: "core", scope: "tenant", scopeColumn: "buyer_tenant_id", defaultSort: "updated_at", actions: PLATFORM_ACTION_METADATA.MarketplaceEntitlement, fields: ["id", "listing_id", "buyer_user_id", "buyer_tenant_id", "kind", "owner_tenant_id", "resource_kind", "resource_id", "status", "expires_at", "created_at", "updated_at"] }, + { name: "CatalogSource", label: "Catalog Source", module: "marketplace", id: "catalog_source_read", table: "catalog_sources", database: "core", scope: "user", writable: ["name", "url"], required: ["name", "url"], operations: ["list", "get", "create", "delete"], actions: PLATFORM_ACTION_METADATA.CatalogSource, fields: ["id", "user_id", "name", "url", "created_at"] }, + { name: "CatalogInstall", label: "Catalog Install", module: "marketplace", id: "catalog_install_read", table: "catalog_installs", database: "core", scope: "tenant", defaultSort: "installed_at", fields: ["id", "tenant_id", "user_id", "entry_id", "entry_title", "install_type", "source_catalog", "installed_at"] }, +]; diff --git a/apps/bridge/src/kernel/domains/platform.ts b/apps/bridge/src/kernel/domains/platform.ts new file mode 100644 index 0000000..ae090e1 --- /dev/null +++ b/apps/bridge/src/kernel/domains/platform.ts @@ -0,0 +1,35 @@ +import type { BuiltinSpec } from "./shared.js"; +import { NOTIFICATION_ACTIONS } from "../adapters/content.js"; +import { PLATFORM_ACTION_METADATA } from "../adapters/platform-actions.js"; + +export const PLATFORM_SPECS: BuiltinSpec[] = [ + { name: "Notification", label: "Notification", module: "platform", id: "notification_service", table: "notifications", database: "core", scope: "tenant", scopeColumn: "recipient_tenant_id", defaultSort: "created_at", writable: ["recipient_kind", "recipient_id", "recipient_tenant_id", "category", "title", "body", "link", "resource_kind", "resource_id"], required: ["recipient_kind", "recipient_id", "title"], operations: ["list", "get", "create", "delete"], actions: NOTIFICATION_ACTIONS, fields: ["id", "recipient_kind", "recipient_id", "recipient_tenant_id", "category", "title", "body", "link", "resource_kind", "resource_id", "read_at", "created_at"] }, + { name: "ActionLog", label: "Action Log", module: "platform", id: "action_log_read", table: "platform_action_log", idColumn: "id", defaultSort: "created_at", fields: ["id", "agent_id", "action", "scope", "payload_hash", "result", "created_at"] }, + { + name: "OperationRun", + label: "Operation Run", + module: "platform", + id: "operation_run_service", + table: "kernel_operation_runs", + defaultSort: "updated_at", + accessPolicy: "tenant-member", + fields: ["id", "tenant_id", "actor_id", "object_type", "record_id", "action_name", "status", ["progress", "Float"], ["result_json", "JSON"], "error_code", "error_message", "created_at", "updated_at", "finished_at"], + actions: [ + { + name: "cancel", + label: "Cancel", + target: "record", + effect: "write", + execution: "sync", + roles: ["owner", "intelligence"], + confirmation: { required: true, ttlSeconds: 120 }, + inputSchema: { type: "object", additionalProperties: false }, + }, + ], + }, + { name: "User", label: "User", module: "platform", id: "user_read", table: "users", database: "core", scope: "admin", fields: ["id", "email", "display_name", "avatar_url", ["is_admin", "Check"], "created_at", "updated_at"] }, + { name: "UserProfile", label: "User Profile", module: "platform", id: "user_profile_read", table: "user_profiles", database: "core", idColumn: "user_id", scope: "user", scopeColumn: "user_id", fields: ["id", "user_id", "headline", "bio", "pronouns", "location", "timezone", "company", "job_title", "website", "github", "linkedin", "created_at", "updated_at"] }, + { name: "Tenant", label: "Tenant", module: "platform", id: "tenant_read", table: "tenants", database: "core", scope: "tenant", scopeColumn: "id", fields: ["id", "name", "slug", ["is_operator", "Check"], "owner_user_id", "created_at", "updated_at"] }, + { name: "TenantMembership", label: "Tenant Membership", module: "platform", id: "tenant_membership_read", table: "tenant_memberships", database: "core", idColumn: "user_id", scope: "tenant", fields: ["id", "user_id", "tenant_id", "role", "created_at"] }, + { name: "ShareGrant", label: "Share Grant", module: "platform", id: "share_grant_read", table: "share_grants", database: "core", scope: "tenant", scopeColumn: "owner_tenant_id", defaultSort: "updated_at", writable: ["resource_kind", "resource_id", "grantee_user_id", "grantee_tenant_id", "role"], required: ["resource_kind", "resource_id"], operations: ["list", "get", "create", "delete"], actions: PLATFORM_ACTION_METADATA.ShareGrant, fields: ["id", "owner_tenant_id", "owner_user_id", "resource_kind", "resource_id", "grantee_user_id", "grantee_tenant_id", "role", "created_at", "updated_at"] }, +]; diff --git a/apps/bridge/src/kernel/domains/productivity.ts b/apps/bridge/src/kernel/domains/productivity.ts new file mode 100644 index 0000000..933efb8 --- /dev/null +++ b/apps/bridge/src/kernel/domains/productivity.ts @@ -0,0 +1,14 @@ +import type { BuiltinSpec } from "./shared.js"; +import { + CALENDAR_EVENT_ACTIONS, + CARD_COMMENT_ACTIONS, + TASK_CARD_ACTIONS, +} from "../adapters/productivity.js"; + +export const PRODUCTIVITY_SPECS: BuiltinSpec[] = [ + { name: "Project", label: "Project", module: "productivity", id: "project_read", table: "ai_projects", defaultSort: "updated_at", fields: ["id", "name", "agent_id", "user_id", "created_at", "updated_at"] }, + { name: "ProjectColumn", label: "Project Column", module: "productivity", id: "project_column_read", table: "ai_project_columns", fields: ["id", "project_id", "name", ["sort_order", "Int"]] }, + { name: "TaskCard", label: "Task Card", module: "productivity", id: "task_card_service", table: "ai_project_cards", defaultSort: "updated_at", writable: ["id", "title", "description", "prompt", "context_json", "tags_json", "due_at", "linked_chat_id", "linked_workflow_id", "priority", "assigned_agent_id"], required: ["title"], operations: ["list", "get", "create", "update"], actions: TASK_CARD_ACTIONS, fields: ["id", "project_id", "column_id", "title", "description", "prompt", ["context_json", "JSON"], ["tags_json", "JSON"], "due_at", "linked_chat_id", "linked_workflow_id", ["priority", "Int"], "parent_card_id", "status", "assigned_agent_id", ["sort_order", "Int"], "created_at", "updated_at"] }, + { name: "CardComment", label: "Card Comment", module: "productivity", id: "card_comment_service", table: "ai_card_comments", defaultSort: "created_at", actions: CARD_COMMENT_ACTIONS, fields: ["id", "card_id", "author", "body", "created_at"] }, + { name: "CalendarEvent", label: "Calendar Event", module: "productivity", id: "calendar_event_service", table: "ai_calendar_events", defaultSort: "start_at", writable: ["id", "kind", "title", "description", "start_at", "end_at", "all_day", "location", "linked_card_id", "linked_run_id", "status"], required: ["title", "start_at"], actions: CALENDAR_EVENT_ACTIONS, fields: ["id", "agent_id", "kind", "title", "description", "start_at", "end_at", ["all_day", "Check"], "location", "linked_card_id", "linked_run_id", "status", "created_at", "updated_at"] }, +]; diff --git a/apps/bridge/src/kernel/domains/runtime.ts b/apps/bridge/src/kernel/domains/runtime.ts new file mode 100644 index 0000000..e2f94ee --- /dev/null +++ b/apps/bridge/src/kernel/domains/runtime.ts @@ -0,0 +1,43 @@ +import type { BuiltinSpec, FieldSpec } from "./shared.js"; +import { + runtimeAdapterRegistrations, +} from "../adapters/runtime.js"; + +const LABELS: Record = { + ChatSession: "Chat Session", + ChatMessage: "Chat Message", + ModelRuntime: "Model Runtime", + PromptQueueJob: "Prompt Queue Job", + Dataset: "Dataset", + TrainingJob: "Training Job", + InferenceRuntime: "Inference Runtime", + IntegrationRuntime: "Integration Runtime", +}; + +const FIELD_TYPES: Record = { + priority: ["priority", "Int"], + pid: ["pid", "Int"], + port: ["port", "Int"], + ctx_size: ["ctx_size", "Int"], + row_count: ["row_count", "Int"], + progress: ["progress", "Float"], + health_ok: ["health_ok", "Check"], + connected: ["connected", "Check"], +}; + +export const RUNTIME_SPECS: BuiltinSpec[] = + runtimeAdapterRegistrations.map((registration) => ({ + name: registration.objectType, + label: LABELS[registration.objectType] ?? registration.objectType, + module: "runtime", + id: registration.adapterId, + table: `kernel_${registration.adapterId}`, + database: registration.database, + operations: [...registration.operations], + actions: [...registration.actions], + accessPolicy: + registration.database === "core" ? "platform-admin" : "tenant-local", + fields: registration.fields.map( + (field) => FIELD_TYPES[field] ?? field + ), + })); diff --git a/apps/bridge/src/kernel/domains/shared.ts b/apps/bridge/src/kernel/domains/shared.ts new file mode 100644 index 0000000..2efe207 --- /dev/null +++ b/apps/bridge/src/kernel/domains/shared.ts @@ -0,0 +1,59 @@ +import type { FieldDef, FieldType, ObjectTypeDef } from "@godmode/kernel"; +import type { SqlReadAdapterOptions } from "../adapters/sql-read.js"; + +export type FieldSpec = string | [string, FieldType]; + +export interface BuiltinSpec extends SqlReadAdapterOptions { + name: string; + label: string; + module: string; + fields: FieldSpec[]; + writable?: string[]; + required?: string[]; + operations?: ObjectTypeDef["operations"]; + actions?: ObjectTypeDef["actions"]; + permissions?: ObjectTypeDef["permissions"]; + accessPolicy?: string; +} + +export const READ_PERMISSIONS: ObjectTypeDef["permissions"] = [ + { role: "viewer", read: true }, + { role: "editor", read: true }, + { role: "owner", read: true }, + { role: "intelligence", read: true }, +]; + +export const WRITE_PERMISSIONS: ObjectTypeDef["permissions"] = [ + { role: "viewer", read: true }, + { role: "editor", read: true, create: true, update: true, delete: true }, + { role: "owner", read: true, create: true, update: true, delete: true }, + { + role: "intelligence", + read: true, + create: true, + update: true, + delete: true, + }, +]; + +export function buildFields( + specs: FieldSpec[], + writable: ReadonlySet, + required: ReadonlySet +): FieldDef[] { + return specs.map((spec, index) => { + const [name, fieldType] = + typeof spec === "string" ? [spec, "Data" as FieldType] : spec; + return { + name, + label: name + .split("_") + .map((part) => part[0]?.toUpperCase() + part.slice(1)) + .join(" "), + fieldType, + inList: index < 6, + inForm: writable.has(name), + required: required.has(name), + }; + }); +} diff --git a/apps/bridge/src/kernel/index.ts b/apps/bridge/src/kernel/index.ts new file mode 100644 index 0000000..c46de8e --- /dev/null +++ b/apps/bridge/src/kernel/index.ts @@ -0,0 +1,61 @@ +export { + registerObjectType, + registerObjectTypes, + replaceObjectTypesByPlugin, + unregisterObjectType, + getObjectType, + listObjectTypes, + hasObjectType, + bootstrapBuiltInObjectTypes, +} from "./registry.js"; + +export { + registerPageKind, + registerPageKinds, + listPageKinds, + isRegisteredPageKind, + pageKindJsonSchema, +} from "./kind-registry.js"; + +export { + KernelError, + listRecords, + getRecord, + createRecord, + updateRecord, + deleteRecord, + executeRecordAction, + executeCollectionAction, + createSystemOperationContext, + cancelOperationRun, + recoverInterruptedOperationRuns, + seedRecords, + materializeAllNativeTypes, + ensureObjectTypeStorage, +} from "./record-api.js"; + +export { + genericObjectTypeToolDefs, + objectTypeAutoToolDefs, + allKernelToolDefs, + KERNEL_GENERIC_TOOL_NAMES, +} from "./auto-tools.js"; + +export { createKernelRouter } from "./routes.js"; + +export { + registerRecordAdapter, + unregisterRecordAdapter, + getRecordAdapter, + setKernelEventBus, + type RecordAdapter, + type OperationContext, + type RecordQuery, +} from "./adapter-registry.js"; + +export { + registerPluginObjectTypes, + applyPluginObjectTypeSeeds, +} from "./plugin-object-types.js"; + +export { registerCoreObjectTypes } from "./core-object-types.js"; diff --git a/apps/bridge/src/kernel/kind-registry.ts b/apps/bridge/src/kernel/kind-registry.ts new file mode 100644 index 0000000..1837b0a --- /dev/null +++ b/apps/bridge/src/kernel/kind-registry.ts @@ -0,0 +1,36 @@ +import { PAGE_KINDS } from "../services/page-kinds.js"; + +/** + * Shared Kind registry for StructureNode.kind / page renderers. + * Bridge starts with built-in PAGE_KINDS; plugins and web sync add more. + */ +const kinds = new Set(PAGE_KINDS); + +export function registerPageKind(kind: string): void { + const k = kind.trim(); + if (!k) return; + kinds.add(k); +} + +export function registerPageKinds(list: string[]): void { + for (const k of list) registerPageKind(k); +} + +export function unregisterPageKind(kind: string): void { + if ((PAGE_KINDS as readonly string[]).includes(kind)) return; + kinds.delete(kind); +} + +export function listPageKinds(): string[] { + return [...kinds].sort(); +} + +export function isRegisteredPageKind(kind: string): boolean { + return kinds.has(kind); +} + +export function pageKindJsonSchema(): { type: "string"; enum?: string[] } { + const enumVals = listPageKinds(); + if (enumVals.length === 0) return { type: "string" }; + return { type: "string", enum: enumVals }; +} diff --git a/apps/bridge/src/kernel/native-storage.ts b/apps/bridge/src/kernel/native-storage.ts new file mode 100644 index 0000000..6c76b20 --- /dev/null +++ b/apps/bridge/src/kernel/native-storage.ts @@ -0,0 +1,261 @@ +import type { AppDatabase } from "../db.js"; +import type { FieldDef, ObjectTypeDef, RecordData, RecordRow } from "@godmode/kernel"; +import { defaultNativeTableName } from "@godmode/kernel"; + +function sqlType(field: FieldDef): string { + switch (field.fieldType) { + case "Int": + case "Check": + return "INTEGER"; + case "Float": + return "REAL"; + default: + return "TEXT"; + } +} + +export function ensureNativeTable(db: AppDatabase, def: ObjectTypeDef): string { + if (def.storage.kind !== "native") { + throw new Error(`${def.name} is not a native ObjectType`); + } + const table = + def.storage.tableName?.trim() || defaultNativeTableName(def.name); + const cols = def.fields + .filter((f) => f.fieldType !== "ReadOnly" && f.name !== "id") + .map((f) => { + if (["created_at", "updated_at"].includes(f.name)) { + throw new Error(`Reserved native field: ${f.name}`); + } + const required = f.required ? " NOT NULL" : ""; + return `${quoteIdent(f.name)} ${sqlType(f)}${required}`; + }); + cols.unshift(`id TEXT PRIMARY KEY`); + cols.push(`created_at TEXT NOT NULL DEFAULT (datetime('now'))`); + cols.push(`updated_at TEXT NOT NULL DEFAULT (datetime('now'))`); + db.exec( + `CREATE TABLE IF NOT EXISTS ${quoteIdent(table)} (\n ${cols.join(",\n ")}\n)` + ); + const existing = new Set( + ( + db.prepare(`PRAGMA table_info(${quoteIdent(table)})`).all() as Array<{ + name: string; + }> + ).map((r) => r.name) + ); + for (const field of def.fields) { + if ( + field.name === "id" || + field.fieldType === "ReadOnly" || + existing.has(field.name) + ) { + continue; + } + const defaultSql = + field.default === undefined + ? "" + : ` DEFAULT ${sqliteLiteral(field.default, field)}`; + // SQLite cannot add a required column without a usable default. + const required = field.required && field.default !== undefined ? " NOT NULL" : ""; + db.exec( + `ALTER TABLE ${quoteIdent(table)} ADD COLUMN ${quoteIdent(field.name)} ${sqlType(field)}${required}${defaultSql}` + ); + } + return table; +} + +function quoteIdent(name: string): string { + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) { + throw new Error(`Invalid table name: ${name}`); + } + return `"${name.replaceAll('"', '""')}"`; +} + +function sqliteLiteral(value: unknown, field: FieldDef): string { + const encoded = + field.fieldType === "JSON" && typeof value !== "string" + ? JSON.stringify(value) + : field.fieldType === "Check" + ? value + ? 1 + : 0 + : value; + if (encoded === null) return "NULL"; + if (typeof encoded === "number") return String(encoded); + return `'${String(encoded).replaceAll("'", "''")}'`; +} + +function decodeValue(field: FieldDef, value: unknown): unknown { + if (value == null) return value; + if (field.fieldType === "Check") return Boolean(value); + if (field.fieldType === "JSON" && typeof value === "string") { + try { + return JSON.parse(value); + } catch { + return value; + } + } + return value; +} + +function rowToRecord( + def: ObjectTypeDef, + row: Record +): RecordRow { + const { id, created_at: _c, updated_at: _u, ...rest } = row; + const data: RecordData = { id: String(id) }; + for (const field of def.fields) { + if (field.name === "id" || field.secret || !(field.name in rest)) continue; + data[field.name] = decodeValue(field, rest[field.name]); + } + return { + id: String(id), + objectType: def.name, + data, + }; +} + +export function listNativeRecords( + db: AppDatabase, + def: ObjectTypeDef, + opts?: { + limit?: number; + offset?: number; + filters?: Record; + sort?: string; + direction?: "asc" | "desc"; + } +): { records: RecordRow[]; total: number; table: string } { + const table = ensureNativeTable(db, def); + const limit = Math.min(Math.max(Number(opts?.limit) || 100, 1), 500); + const offset = Math.max(Number(opts?.offset) || 0, 0); + const fields = new Map(def.fields.map((field) => [field.name, field])); + const where: string[] = []; + const values: unknown[] = []; + for (const [name, raw] of Object.entries(opts?.filters ?? {})) { + const field = fields.get(name); + if (!field || field.secret || field.fieldType === "ReadOnly") continue; + let value = raw; + if (field.fieldType === "JSON" && raw != null && typeof raw !== "string") { + value = JSON.stringify(raw); + } else if (field.fieldType === "Check") { + value = raw ? 1 : 0; + } + where.push(`${quoteIdent(name)}=?`); + values.push(value); + } + const predicate = where.length ? ` WHERE ${where.join(" AND ")}` : ""; + const sort = + opts?.sort && + (["id", "created_at", "updated_at"].includes(opts.sort) || + (fields.has(opts.sort) && !fields.get(opts.sort)?.secret)) + ? opts.sort + : "updated_at"; + const direction = opts?.direction === "asc" ? "ASC" : "DESC"; + const rows = db + .prepare( + `SELECT * FROM ${quoteIdent(table)}${predicate} ORDER BY ${quoteIdent(sort)} ${direction} LIMIT ? OFFSET ?` + ) + .all(...values, limit, offset) as Record[]; + const total = ( + db + .prepare(`SELECT COUNT(*) AS c FROM ${quoteIdent(table)}${predicate}`) + .get(...values) as { + c: number; + } + ).c; + return { + table, + total, + records: rows.map((r) => rowToRecord(def, r)), + }; +} + +export function getNativeRecord( + db: AppDatabase, + def: ObjectTypeDef, + id: string +): RecordRow | null { + const table = ensureNativeTable(db, def); + const row = db + .prepare(`SELECT * FROM ${quoteIdent(table)} WHERE id=?`) + .get(id) as Record | undefined; + return row ? rowToRecord(def, row) : null; +} + +export function createNativeRecord( + db: AppDatabase, + def: ObjectTypeDef, + data: RecordData +): RecordRow { + const table = ensureNativeTable(db, def); + const id = + data.id != null && String(data.id).trim() + ? String(data.id).trim() + : crypto.randomUUID(); + const writable = def.fields.filter( + (f) => f.fieldType !== "ReadOnly" && f.name !== "id" + ); + const cols = ["id", ...writable.map((f) => f.name)]; + const vals: unknown[] = [id]; + for (const f of writable) { + let v = data[f.name]; + if (v === undefined) v = f.default ?? null; + if (f.fieldType === "JSON" && v != null && typeof v !== "string") { + v = JSON.stringify(v); + } + if (f.fieldType === "Check") v = v ? 1 : 0; + vals.push(v ?? null); + } + const placeholders = cols.map(() => "?").join(", "); + db.prepare( + `INSERT INTO ${quoteIdent(table)} (${cols.map(quoteIdent).join(", ")}) VALUES (${placeholders})` + ).run(...vals); + const row = getNativeRecord(db, def, id); + if (!row) throw new Error("failed to read created record"); + return row; +} + +export function updateNativeRecord( + db: AppDatabase, + def: ObjectTypeDef, + id: string, + data: RecordData +): RecordRow { + const table = ensureNativeTable(db, def); + const existing = getNativeRecord(db, def, id); + if (!existing) throw Object.assign(new Error("record not found"), { status: 404 }); + const sets: string[] = []; + const vals: unknown[] = []; + for (const f of def.fields) { + if (f.name === "id" || f.fieldType === "ReadOnly") continue; + if (!(f.name in data)) continue; + let v = data[f.name]; + if (f.fieldType === "JSON" && v != null && typeof v !== "string") { + v = JSON.stringify(v); + } + if (f.fieldType === "Check") v = v ? 1 : 0; + sets.push(`${quoteIdent(f.name)}=?`); + vals.push(v ?? null); + } + if (sets.length === 0) return existing; + sets.push(`updated_at=datetime('now')`); + vals.push(id); + db.prepare( + `UPDATE ${quoteIdent(table)} SET ${sets.join(", ")} WHERE id=?` + ).run(...vals); + const row = getNativeRecord(db, def, id); + if (!row) throw new Error("failed to read updated record"); + return row; +} + +export function deleteNativeRecord( + db: AppDatabase, + def: ObjectTypeDef, + id: string +): void { + const table = ensureNativeTable(db, def); + const info = db.prepare(`DELETE FROM ${quoteIdent(table)} WHERE id=?`).run(id); + if (info.changes === 0) { + throw Object.assign(new Error("record not found"), { status: 404 }); + } +} diff --git a/apps/bridge/src/kernel/plugin-object-types.ts b/apps/bridge/src/kernel/plugin-object-types.ts new file mode 100644 index 0000000..04343bb --- /dev/null +++ b/apps/bridge/src/kernel/plugin-object-types.ts @@ -0,0 +1,38 @@ +import type { GodmodePluginManifest } from "@godmode/plugin-api"; +import type { AppDatabase } from "../db.js"; +import { + createSystemOperationContext, + ensureObjectTypeStorage, + seedRecords, +} from "./record-api.js"; +import { replaceObjectTypesByPlugin } from "./registry.js"; +import { registerPageKinds } from "../kernel/kind-registry.js"; + +/** Register ObjectTypes from a plugin manifest (idempotent overwrite by name). */ +export function registerPluginObjectTypes(manifest: GodmodePluginManifest): void { + const defs = (manifest.objectTypes ?? []).map((ot) => ({ + ...ot, + pluginId: ot.pluginId ?? manifest.id, + })); + replaceObjectTypesByPlugin(manifest.id, defs); + for (const ot of defs) { + // Collect Select options that look like page kinds from Structure seeds — no-op for most. + if (ot.name === "StructureNode") { + const kindField = ot.fields.find((f) => f.name === "kind"); + if (kindField?.options?.length) registerPageKinds(kindField.options); + } + } +} + +/** Materialize native tables + apply declarative Record seeds for a tenant. */ +export function applyPluginObjectTypeSeeds( + db: AppDatabase, + manifest: GodmodePluginManifest +): void { + for (const ot of manifest.objectTypes ?? []) { + ensureObjectTypeStorage(db, ot); + } + if (manifest.records?.length) { + seedRecords(db, manifest.records, createSystemOperationContext()); + } +} diff --git a/apps/bridge/src/kernel/protocol-exceptions.ts b/apps/bridge/src/kernel/protocol-exceptions.ts new file mode 100644 index 0000000..fbdca2b --- /dev/null +++ b/apps/bridge/src/kernel/protocol-exceptions.ts @@ -0,0 +1,52 @@ +export interface ProtocolException { + id: string; + methods: string[]; + pathPattern: string; + rationale: string; + authenticatedDomainMutations: "none" | "kernel-delegated"; +} + +export const PROTOCOL_EXCEPTIONS: readonly ProtocolException[] = [ + { + id: "health", + methods: ["GET"], + pathPattern: "/api/health", + rationale: "Unauthenticated process and deployment readiness.", + authenticatedDomainMutations: "none", + }, + { + id: "authentication", + methods: ["POST", "GET"], + pathPattern: "/api/auth/*", + rationale: "Session cookies, signup/login bootstrap, and OAuth callbacks.", + authenticatedDomainMutations: "kernel-delegated", + }, + { + id: "websocket", + methods: ["GET"], + pathPattern: "/ws", + rationale: "WebSocket negotiation and transport for kernel-authorized work.", + authenticatedDomainMutations: "none", + }, + { + id: "streams", + methods: ["GET", "POST"], + pathPattern: "/api/*/stream", + rationale: "Streaming transport for an authorized OperationRun.", + authenticatedDomainMutations: "kernel-delegated", + }, + { + id: "binary-transfer", + methods: ["GET", "POST"], + pathPattern: "/api/*/(upload|download)", + rationale: "Multipart and binary transfer cannot be represented as JSON Records.", + authenticatedDomainMutations: "kernel-delegated", + }, + { + id: "ephemeral-presence", + methods: ["POST"], + pathPattern: "/api/dm/*/typing", + rationale: "Ephemeral typing signal with no durable domain mutation.", + authenticatedDomainMutations: "none", + }, +] as const; diff --git a/apps/bridge/src/kernel/record-api.ts b/apps/bridge/src/kernel/record-api.ts new file mode 100644 index 0000000..58a81d4 --- /dev/null +++ b/apps/bridge/src/kernel/record-api.ts @@ -0,0 +1,1332 @@ +import type { AppDatabase } from "../db.js"; +import { createHash, randomUUID } from "node:crypto"; +import { + Ajv2020, + type ErrorObject, + type ValidateFunction, +} from "ajv/dist/2020.js"; +import type { + ActionDef, + ListRecordsResult, + ObjectTypeDef, + RecordData, + RecordRow, +} from "@godmode/kernel"; +import { getCoreDb } from "../core-db.js"; +import { insertEvent } from "../services/data-management-migration.js"; +import { getObjectType, listObjectTypes } from "./registry.js"; +import { + getRecordAdapter, + hasRecordAdapter, + registerRecordAdapter, + withKernelEventBus, + type OperationContext, + type RecordOperation, + type RecordQuery, +} from "./adapter-registry.js"; +import { structureNodeAdapter } from "./adapters/structure-node.js"; +import { + createNativeRecord, + deleteNativeRecord, + ensureNativeTable, + getNativeRecord, + listNativeRecords, + updateNativeRecord, +} from "./native-storage.js"; + +export class KernelError extends Error { + status: number; + code: string; + details?: unknown; + retryable: boolean; + constructor( + status: number, + message: string, + opts: { code?: string; details?: unknown; retryable?: boolean } = {} + ) { + super(message); + this.status = status; + this.code = opts.code ?? `KERNEL_${status}`; + this.details = opts.details; + this.retryable = opts.retryable ?? false; + } +} + +const SYSTEM_CAPABILITY = Symbol("godmode.kernel.system"); +const ajv = new Ajv2020({ allErrors: true, strict: false }); +const compiledSchemas = new WeakMap(); + +export function createSystemOperationContext( + overrides: Partial = {} +): OperationContext { + return { + role: "owner", + source: "system", + agentId: "system", + ...overrides, + systemCapability: SYSTEM_CAPABILITY, + }; +} + +function requireContext(ctx: OperationContext | undefined): OperationContext { + if (!ctx) { + throw new KernelError(500, "Explicit OperationContext required", { + code: "KERNEL_CONTEXT_REQUIRED", + }); + } + if ( + ctx.source === "system" && + ctx.systemCapability !== SYSTEM_CAPABILITY + ) { + throw new KernelError(403, "Invalid system capability", { + code: "KERNEL_INVALID_SYSTEM_CAPABILITY", + }); + } + if (ctx.source !== "system" && !ctx.userId && !ctx.agentId) { + throw new KernelError(401, "Authenticated principal required", { + code: "KERNEL_PRINCIPAL_REQUIRED", + }); + } + return ctx; +} + +function withDataContext( + db: AppDatabase, + def: ObjectTypeDef, + ctx: OperationContext +): OperationContext { + return { + ...ctx, + data: { + tenantDb: db, + coreDb: getCoreDb(), + declaredDatabase: def.database ?? "tenant", + }, + }; +} + +function auditMutation( + db: AppDatabase, + ctx: OperationContext, + action: string, + payload: unknown, + result = "ok" +): void { + db.exec(` + CREATE TABLE IF NOT EXISTS platform_action_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + agent_id TEXT NOT NULL, + action TEXT NOT NULL, + scope TEXT, + payload_hash TEXT, + result TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS events ( + id TEXT PRIMARY KEY, + seq INTEGER NOT NULL, + ts TEXT NOT NULL DEFAULT (datetime('now')), + type TEXT NOT NULL, + actor_agent_id TEXT, + subject TEXT, + payload_json TEXT NOT NULL DEFAULT '{}', + dispatched INTEGER NOT NULL DEFAULT 0 + ); + `); + const agentId = ctx.agentId ?? (ctx.userId ? `user:${ctx.userId}` : "system"); + const payloadHash = createHash("sha256") + .update(stableJson(payload)) + .digest("hex") + .slice(0, 16); + db.prepare( + `INSERT INTO platform_action_log (agent_id, action, scope, payload_hash, result) + VALUES (?, ?, ?, ?, ?)` + ).run(agentId, action, ctx.tenantId ?? null, payloadHash, result); + insertEvent(db, { + id: randomUUID(), + type: "platform.action", + actorAgentId: agentId, + subject: ctx.tenantId ?? null, + payload: { + action, + result, + payloadHash, + requestId: ctx.requestId, + }, + }); +} + +function stableJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; + if (value && typeof value === "object") { + return `{${Object.entries(value as Record) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, item]) => `${JSON.stringify(key)}:${stableJson(item)}`) + .join(",")}}`; + } + return JSON.stringify(value) ?? "null"; +} + +function auditKernelFailure( + db: AppDatabase, + ctx: OperationContext, + action: string, + payload: unknown, + error: unknown +): KernelError { + const normalized = normalizeKernelError(error); + try { + db.transaction(() => { + auditMutation( + db, + requireContext(ctx), + action, + { payload, errorCode: normalized.code }, + normalized.status === 401 || normalized.status === 403 + ? "denied" + : "error" + ); + })(); + } catch { + // Preserve the original operation error if the audit store is unavailable. + } + return normalized; +} + +function inputHash(input: unknown): string { + return createHash("sha256").update(stableJson(input)).digest("hex"); +} + +function validateSchema( + schema: Record | undefined, + value: unknown, + label: string +): void { + if (!schema) return; + const validate: ValidateFunction = + compiledSchemas.get(schema) ?? ajv.compile(schema); + compiledSchemas.set(schema, validate); + if (!validate(value)) { + throw new KernelError(400, `${label} failed schema validation`, { + code: "KERNEL_SCHEMA_INVALID", + details: (validate.errors ?? []).map((error: ErrorObject) => ({ + path: error.instancePath, + keyword: error.keyword, + message: error.message, + })), + }); + } +} + +function projectRow( + def: ObjectTypeDef, + row: RecordRow, + ctx: OperationContext, + adapter?: ReturnType +): RecordRow { + const policyRow = adapter?.policy?.project?.(def, row, ctx) ?? row; + const fields = new Map(def.fields.map((field) => [field.name, field])); + const data: RecordData = {}; + for (const [name, value] of Object.entries(policyRow.data ?? {})) { + const field = fields.get(name); + if (field && !field.secret) data[name] = value; + } + return { id: String(policyRow.id), objectType: def.name, data }; +} + +function resourceVersion(row: RecordRow | null | undefined): string | undefined { + const value = + row?.data.version ?? + row?.data.updated_at ?? + row?.data.revision ?? + undefined; + return value == null ? undefined : String(value); +} + +function requireExpectedVersion( + row: RecordRow | null | undefined, + ctx: OperationContext +): void { + if (!ctx.expectedVersion) return; + const expected = ctx.expectedVersion.replace(/^W\//, "").replace(/^"|"$/g, ""); + const actual = resourceVersion(row); + if (!actual || actual !== expected) { + throw new KernelError(412, "Resource version does not match If-Match", { + code: "KERNEL_VERSION_CONFLICT", + details: { expected, actual }, + }); + } +} + +function authorize( + adapter: ReturnType, + operation: RecordOperation | "action", + def: ObjectTypeDef, + ctx: OperationContext, + row?: RecordRow | null, + action?: ActionDef +): void { + const decision = adapter?.policy?.authorize?.( + operation, + def, + ctx, + row, + action + ); + if (decision === false) { + throw new KernelError(403, "Resource policy denied the operation", { + code: "KERNEL_POLICY_DENIED", + }); + } +} + +if (!hasRecordAdapter(structureNodeAdapter.id)) { + registerRecordAdapter(structureNodeAdapter); +} + +function isVisible(def: ObjectTypeDef, ctx: OperationContext): boolean { + return ( + !def.pluginId || + ctx.source === "system" || + ctx.installedPluginIds?.has(def.pluginId) === true + ); +} + +function requireOt(name: string, ctx: OperationContext): ObjectTypeDef { + const def = getObjectType(name); + if (!def || !isVisible(def, ctx)) { + throw new KernelError(404, `Unknown ObjectType: ${name}`); + } + return def; +} + +function requireOperation( + def: ObjectTypeDef, + operation: RecordOperation, + ctx: OperationContext +): void { + if (!def.operations?.includes(operation)) { + throw new KernelError(405, `${operation} is disabled for ${def.name}`); + } + if ( + ctx.source === "system" && + ctx.systemCapability === SYSTEM_CAPABILITY + ) { + return; + } + if ( + def.accessPolicy === "platform-admin" && + !ctx.isAdmin && + ctx.role !== "intelligence" + ) { + throw new KernelError(403, "Platform administrator access required", { + code: "KERNEL_ADMIN_REQUIRED", + }); + } + if ( + ["tenant-member", "tenant-local"].includes(def.accessPolicy ?? "") && + !ctx.tenantId + ) { + throw new KernelError(403, "Tenant membership context required", { + code: "KERNEL_TENANT_REQUIRED", + }); + } + if (def.accessPolicy === "user-private" && !ctx.userId) { + throw new KernelError(403, "User principal required", { + code: "KERNEL_USER_REQUIRED", + }); + } + if ( + def.accessPolicy === "relationship-scoped" && + !ctx.userId && + !ctx.agentId + ) { + throw new KernelError(403, "Relationship principal required", { + code: "KERNEL_RELATIONSHIP_PRINCIPAL_REQUIRED", + }); + } + const permission = def.permissions?.find((p) => p.role === ctx.role); + const permissionKey = + operation === "list" || operation === "get" ? "read" : operation; + if (!permission || permission[permissionKey] !== true) { + throw new KernelError(403, `${ctx.role} cannot ${operation} ${def.name}`); + } +} + +function validateRecordData( + def: ObjectTypeDef, + data: RecordData, + mode: "create" | "update" +): RecordData { + const fields = new Map(def.fields.map((f) => [f.name, f])); + const clean: RecordData = {}; + for (const [name, value] of Object.entries(data)) { + const field = fields.get(name); + if (!field) throw new KernelError(400, `Unknown field ${def.name}.${name}`); + if (field.secret) { + throw new KernelError(400, `${field.label} cannot be written through Record API`); + } + if (field.fieldType === "ReadOnly" || field.inForm === false) { + if (name === "id" && mode === "create") clean[name] = value; + else throw new KernelError(400, `${field.label} is read-only`); + continue; + } + if (value != null) { + if (field.fieldType === "Int" && !Number.isInteger(value)) { + throw new KernelError(400, `${field.label} must be an integer`); + } + if ( + field.fieldType === "Float" && + (typeof value !== "number" || !Number.isFinite(value)) + ) { + throw new KernelError(400, `${field.label} must be a finite number`); + } + if (field.fieldType === "Check" && typeof value !== "boolean") { + throw new KernelError(400, `${field.label} must be true or false`); + } + if ( + ["Data", "Select", "Link"].includes(field.fieldType) && + typeof value !== "string" + ) { + throw new KernelError(400, `${field.label} must be text`); + } + } + if ( + field.fieldType === "Select" && + value != null && + value !== "" && + field.options?.length && + !field.options.includes(String(value)) + ) { + throw new KernelError(400, `Invalid ${field.label}: ${String(value)}`); + } + if ( + field.fieldType === "Link" && + value != null && + value !== "" && + field.linkTo && + !getObjectType(field.linkTo) + ) { + throw new KernelError(400, `Unknown Link target ${field.linkTo}`); + } + clean[name] = value; + } + if (mode === "create") { + for (const field of def.fields) { + if ( + field.required && + field.name !== "id" && + (clean[field.name] === undefined || + clean[field.name] === null || + clean[field.name] === "") + ) { + throw new KernelError(400, `${field.label} is required`); + } + if (clean[field.name] === undefined && field.default !== undefined) { + clean[field.name] = field.default; + } + } + } + return clean; +} + +export function ensureObjectTypeStorage(db: AppDatabase, def: ObjectTypeDef): void { + if (def.storage.kind === "native") { + ensureNativeTable(db, def); + } +} + +export function materializeAllNativeTypes( + db: AppDatabase, + ctx: OperationContext +): void { + ctx = requireContext(ctx); + for (const def of listObjectTypes().filter((d) => isVisible(d, ctx))) { + ensureObjectTypeStorage(db, def); + } +} + +export function listRecords( + db: AppDatabase, + objectType: string, + opts: RecordQuery = {}, + ctx: OperationContext +): ListRecordsResult { + ctx = withKernelEventBus(requireContext(ctx)); + const def = requireOt(objectType, ctx); + ctx = withDataContext(db, def, ctx); + requireOperation(def, "list", ctx); + const query = { + ...opts, + limit: Math.min(Math.max(Number(opts.limit) || 100, 1), 500), + offset: Math.max(Number(opts.offset) || 0, 0), + }; + if (def.storage.kind === "adapter") { + const adapter = getRecordAdapter(def.storage.adapterId); + if (!adapter?.list) throw new KernelError(405, `list is not supported for ${objectType}`); + authorize(adapter, "list", def, ctx); + const result = adapter.list(db, def, query, ctx); + return { + ...result, + records: result.records.map((row) => projectRow(def, row, ctx, adapter)), + }; + } + if (def.storage.kind === "native") { + const { records, total } = listNativeRecords(db, def, query); + return { + objectType: def.name, + records: records.map((row) => projectRow(def, row, ctx)), + total, + }; + } + throw new KernelError(501, `No adapter for ObjectType ${objectType}`); +} + +export function getRecord( + db: AppDatabase, + objectType: string, + id: string, + ctx: OperationContext +): RecordRow { + ctx = withKernelEventBus(requireContext(ctx)); + const def = requireOt(objectType, ctx); + ctx = withDataContext(db, def, ctx); + requireOperation(def, "get", ctx); + let row: RecordRow | null = null; + if (def.storage.kind === "adapter") { + const adapter = getRecordAdapter(def.storage.adapterId); + if (!adapter?.get) throw new KernelError(405, `get is not supported for ${objectType}`); + row = adapter.get(db, def, id, ctx); + authorize(adapter, "get", def, ctx, row); + if (row) row = projectRow(def, row, ctx, adapter); + } else if (def.storage.kind === "native") { + row = getNativeRecord(db, def, id); + if (row) row = projectRow(def, row, ctx); + } else { + throw new KernelError(501, `No adapter for ObjectType ${objectType}`); + } + if (!row) throw new KernelError(404, `${objectType} record not found: ${id}`); + return row; +} + +function createRecordImpl( + db: AppDatabase, + objectType: string, + data: RecordData, + ctx: OperationContext +): RecordRow { + ctx = withKernelEventBus(requireContext(ctx)); + const def = requireOt(objectType, ctx); + ctx = withDataContext(db, def, ctx); + requireOperation(def, "create", ctx); + const validated = validateRecordData(def, data, "create"); + try { + if (def.storage.kind === "adapter") { + const adapter = getRecordAdapter(def.storage.adapterId); + if (!adapter?.create) throw new KernelError(405, `create is not supported for ${objectType}`); + authorize(adapter, "create", def, ctx); + const row = db.transaction(() => { + const created = adapter.create!(db, def, validated, ctx); + auditMutation(db, ctx, `object.create.${objectType}`, { + recordId: created.id, + }); + return created; + })(); + ctx.bus?.emit("object.record.created", { + objectType, + recordId: row.id, + tenantId: ctx.tenantId, + actorId: ctx.agentId ?? ctx.userId, + source: ctx.source, + }); + return projectRow(def, row, ctx, adapter); + } + if (def.storage.kind === "native") { + const row = db.transaction(() => { + const created = createNativeRecord(db, def, validated); + auditMutation(db, ctx, `object.create.${objectType}`, { + recordId: created.id, + }); + return created; + })(); + ctx.bus?.emit("object.record.created", { + objectType, + recordId: row.id, + tenantId: ctx.tenantId, + actorId: ctx.agentId ?? ctx.userId, + source: ctx.source, + }); + return projectRow(def, row, ctx); + } + } catch (err) { + if (err && typeof err === "object" && "status" in err) { + throw new KernelError( + Number((err as { status: number }).status), + err instanceof Error ? err.message : "error" + ); + } + throw err; + } + throw new KernelError(501, `No adapter for ObjectType ${objectType}`); +} + +export function createRecord( + db: AppDatabase, + objectType: string, + data: RecordData, + ctx: OperationContext +): RecordRow { + try { + return createRecordImpl(db, objectType, data, ctx); + } catch (error) { + throw auditKernelFailure( + db, + ctx, + `object.create.${objectType}`, + { data }, + error + ); + } +} + +function updateRecordImpl( + db: AppDatabase, + objectType: string, + id: string, + data: RecordData, + ctx: OperationContext +): RecordRow { + ctx = withKernelEventBus(requireContext(ctx)); + const def = requireOt(objectType, ctx); + ctx = withDataContext(db, def, ctx); + requireOperation(def, "update", ctx); + const validated = validateRecordData(def, data, "update"); + try { + if (def.storage.kind === "adapter") { + const adapter = getRecordAdapter(def.storage.adapterId); + if (!adapter?.update) throw new KernelError(405, `update is not supported for ${objectType}`); + const current = adapter.get?.(db, def, id, ctx) ?? null; + authorize(adapter, "update", def, ctx, current); + requireExpectedVersion(current, ctx); + const row = db.transaction(() => { + const updated = adapter.update!(db, def, id, validated, ctx); + auditMutation(db, ctx, `object.update.${objectType}`, { recordId: id }); + return updated; + })(); + ctx.bus?.emit("object.record.updated", { + objectType, + recordId: id, + tenantId: ctx.tenantId, + actorId: ctx.agentId ?? ctx.userId, + source: ctx.source, + }); + return projectRow(def, row, ctx, adapter); + } + if (def.storage.kind === "native") { + requireExpectedVersion(getNativeRecord(db, def, id), ctx); + const row = db.transaction(() => { + const updated = updateNativeRecord(db, def, id, validated); + auditMutation(db, ctx, `object.update.${objectType}`, { recordId: id }); + return updated; + })(); + ctx.bus?.emit("object.record.updated", { + objectType, + recordId: id, + tenantId: ctx.tenantId, + actorId: ctx.agentId ?? ctx.userId, + source: ctx.source, + }); + return projectRow(def, row, ctx); + } + } catch (err) { + if (err && typeof err === "object" && "status" in err) { + throw new KernelError( + Number((err as { status: number }).status), + err instanceof Error ? err.message : "error" + ); + } + throw err; + } + throw new KernelError(501, `No adapter for ObjectType ${objectType}`); +} + +export function updateRecord( + db: AppDatabase, + objectType: string, + id: string, + data: RecordData, + ctx: OperationContext +): RecordRow { + try { + return updateRecordImpl(db, objectType, id, data, ctx); + } catch (error) { + throw auditKernelFailure( + db, + ctx, + `object.update.${objectType}`, + { recordId: id, data }, + error + ); + } +} + +function deleteRecordImpl( + db: AppDatabase, + objectType: string, + id: string, + ctx: OperationContext +): void { + ctx = withKernelEventBus(requireContext(ctx)); + const def = requireOt(objectType, ctx); + ctx = withDataContext(db, def, ctx); + requireOperation(def, "delete", ctx); + try { + if (def.storage.kind === "adapter") { + const adapter = getRecordAdapter(def.storage.adapterId); + if (!adapter?.delete) throw new KernelError(405, `delete is not supported for ${objectType}`); + const current = adapter.get?.(db, def, id, ctx) ?? null; + authorize(adapter, "delete", def, ctx, current); + requireExpectedVersion(current, ctx); + db.transaction(() => { + adapter.delete!(db, def, id, ctx); + auditMutation(db, ctx, `object.delete.${objectType}`, { recordId: id }); + })(); + ctx.bus?.emit("object.record.deleted", { + objectType, + recordId: id, + tenantId: ctx.tenantId, + actorId: ctx.agentId ?? ctx.userId, + source: ctx.source, + }); + return; + } + if (def.storage.kind === "native") { + requireExpectedVersion(getNativeRecord(db, def, id), ctx); + db.transaction(() => { + deleteNativeRecord(db, def, id); + auditMutation(db, ctx, `object.delete.${objectType}`, { recordId: id }); + })(); + ctx.bus?.emit("object.record.deleted", { + objectType, + recordId: id, + tenantId: ctx.tenantId, + actorId: ctx.agentId ?? ctx.userId, + source: ctx.source, + }); + return; + } + } catch (err) { + if (err && typeof err === "object" && "status" in err) { + throw new KernelError( + Number((err as { status: number }).status), + err instanceof Error ? err.message : "error" + ); + } + throw err; + } + throw new KernelError(501, `No adapter for ObjectType ${objectType}`); +} + +export function deleteRecord( + db: AppDatabase, + objectType: string, + id: string, + ctx: OperationContext +): void { + try { + deleteRecordImpl(db, objectType, id, ctx); + } catch (error) { + throw auditKernelFailure( + db, + ctx, + `object.delete.${objectType}`, + { recordId: id }, + error + ); + } +} + +const confirmationGrants = new Map< + string, + { + actorId: string; + objectType: string; + recordId: string; + action: string; + inputHash: string; + resourceVersion?: string; + expiresAt: number; + } +>(); +const operationControllers = new Map(); + +function actorId(ctx: OperationContext): string { + return ctx.agentId ?? (ctx.userId ? `user:${ctx.userId}` : "system"); +} + +function ensureActionTables(db: AppDatabase): void { + db.exec(` + CREATE TABLE IF NOT EXISTS kernel_action_idempotency ( + key TEXT NOT NULL, + actor_id TEXT NOT NULL, + object_type TEXT NOT NULL, + record_id TEXT NOT NULL, + action_name TEXT NOT NULL, + input_hash TEXT NOT NULL, + status TEXT NOT NULL, + result_json TEXT, + expires_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (key, actor_id, object_type, record_id, action_name) + ); + CREATE TABLE IF NOT EXISTS kernel_operation_runs ( + id TEXT PRIMARY KEY, + tenant_id TEXT, + actor_id TEXT NOT NULL, + object_type TEXT NOT NULL, + record_id TEXT, + action_name TEXT NOT NULL, + status TEXT NOT NULL, + progress REAL, + result_json TEXT, + error_code TEXT, + error_message TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + finished_at TEXT + ); + `); +} + +function requireActionPermission( + action: ActionDef, + def: ObjectTypeDef, + ctx: OperationContext +): void { + if ( + ctx.source === "system" && + ctx.systemCapability === SYSTEM_CAPABILITY + ) { + return; + } + if (!action.roles?.includes(ctx.role)) { + throw new KernelError(403, `${ctx.role} cannot run ${def.name}.${action.name}`, { + code: "KERNEL_ACTION_FORBIDDEN", + }); + } +} + +function requireConfirmation( + action: ActionDef, + objectType: string, + recordId: string, + input: RecordData, + ctx: OperationContext, + version?: string +): void { + const policy = action.confirmation ?? { + required: action.confirm === true, + ttlSeconds: 300, + }; + if ( + !policy.required || + ctx.source === "system" || + (ctx.source === "agent" && ctx.trustedConfirmation === true) + ) { + return; + } + const hash = inputHash(input); + const id = ctx.confirmationId; + const grant = id ? confirmationGrants.get(id) : undefined; + if ( + grant && + grant.actorId === actorId(ctx) && + grant.objectType === objectType && + grant.recordId === recordId && + grant.action === action.name && + grant.inputHash === hash && + grant.resourceVersion === version && + grant.expiresAt > Date.now() + ) { + confirmationGrants.delete(id!); + return; + } + const confirmationId = randomUUID(); + confirmationGrants.set(confirmationId, { + actorId: actorId(ctx), + objectType, + recordId, + action: action.name, + inputHash: hash, + resourceVersion: version, + expiresAt: Date.now() + (policy.ttlSeconds ?? 300) * 1000, + }); + throw new KernelError(428, "Action confirmation required", { + code: "KERNEL_CONFIRMATION_REQUIRED", + details: { confirmationId, expiresAt: new Date(confirmationGrants.get(confirmationId)!.expiresAt).toISOString() }, + }); +} + +async function executeRecordActionImpl( + db: AppDatabase, + objectType: string, + id: string, + actionName: string, + input: RecordData, + ctx: OperationContext +): Promise { + ctx = withKernelEventBus(requireContext(ctx)); + const def = requireOt(objectType, ctx); + ctx = withDataContext(db, def, ctx); + const action = def.actions?.find((item) => item.name === actionName); + if (!action) { + throw new KernelError(404, `Unknown ${objectType} action: ${actionName}`); + } + if (def.storage.kind !== "adapter") { + throw new KernelError(405, `${objectType} actions require a service adapter`); + } + const adapter = getRecordAdapter(def.storage.adapterId); + const handler = adapter?.actions?.[actionName]; + if (!handler) { + throw new KernelError(501, `${objectType} action is not implemented: ${actionName}`); + } + requireActionPermission(action, def, ctx); + const current = action.target === "collection" ? null : adapter?.get?.(db, def, id, ctx) ?? null; + if (action.target !== "collection" && !current) { + throw new KernelError(404, `${objectType} record not found: ${id}`); + } + authorize(adapter, "action", def, ctx, current, action); + if (action.concurrency?.required && !ctx.expectedVersion) { + throw new KernelError(428, "If-Match required for this action", { + code: "KERNEL_IF_MATCH_REQUIRED", + }); + } + if (action.concurrency?.required) requireExpectedVersion(current, ctx); + validateSchema(action.inputSchema, input, `${objectType}.${actionName} input`); + ensureActionTables(db); + + const hash = inputHash(input); + const idem = action.idempotency; + if (idem?.required && !ctx.idempotencyKey) { + throw new KernelError(400, "Idempotency-Key required", { + code: "KERNEL_IDEMPOTENCY_REQUIRED", + }); + } + if (ctx.idempotencyKey) { + const existing = db + .prepare( + `SELECT input_hash, status, result_json FROM kernel_action_idempotency + WHERE key=? AND actor_id=? AND object_type=? AND record_id=? AND action_name=?` + ) + .get(ctx.idempotencyKey, actorId(ctx), objectType, id, actionName) as + | { input_hash: string; status: string; result_json: string | null } + | undefined; + if (existing) { + if (existing.input_hash !== hash) { + throw new KernelError(409, "Idempotency key reused with different input", { + code: "KERNEL_IDEMPOTENCY_CONFLICT", + }); + } + if (existing.status === "succeeded") { + return existing.result_json ? JSON.parse(existing.result_json) : null; + } + throw new KernelError(409, "Action already in progress", { + code: "KERNEL_ACTION_IN_PROGRESS", + retryable: true, + }); + } + requireConfirmation( + action, + objectType, + id, + input, + ctx, + resourceVersion(current) + ); + db.prepare( + `INSERT INTO kernel_action_idempotency + (key, actor_id, object_type, record_id, action_name, input_hash, status, expires_at) + VALUES (?, ?, ?, ?, ?, ?, 'running', datetime('now', ?))` + ).run( + ctx.idempotencyKey, + actorId(ctx), + objectType, + id, + actionName, + hash, + `+${idem?.ttlSeconds ?? 86400} seconds` + ); + } else { + requireConfirmation( + action, + objectType, + id, + input, + ctx, + resourceVersion(current) + ); + } + + if (action.execution === "async") { + const operationRunId = randomUUID(); + const controller = new AbortController(); + operationControllers.set(operationRunId, controller); + db.prepare( + `INSERT INTO kernel_operation_runs + (id, tenant_id, actor_id, object_type, record_id, action_name, status) + VALUES (?, ?, ?, ?, ?, ?, 'pending')` + ).run(operationRunId, ctx.tenantId ?? null, actorId(ctx), objectType, id || null, actionName); + queueMicrotask(async () => { + try { + db.prepare( + `UPDATE kernel_operation_runs SET status='running', updated_at=datetime('now') WHERE id=?` + ).run(operationRunId); + const raw = await handler(db, def, id, input, { + ...ctx, + signal: controller.signal, + }); + if (controller.signal.aborted) return; + validateSchema(action.outputSchema, raw, `${objectType}.${actionName} output`); + const result = redactActionOutput(raw, action); + db.transaction(() => { + db.prepare( + `UPDATE kernel_operation_runs + SET status='succeeded', result_json=?, updated_at=datetime('now'), finished_at=datetime('now') + WHERE id=?` + ).run(JSON.stringify(result ?? null), operationRunId); + if (ctx.idempotencyKey) { + db.prepare( + `UPDATE kernel_action_idempotency + SET status='succeeded', result_json=?, updated_at=datetime('now') + WHERE key=? AND actor_id=? AND object_type=? AND record_id=? AND action_name=?` + ).run( + JSON.stringify({ status: "accepted", operationRunId }), + ctx.idempotencyKey, + actorId(ctx), + objectType, + id, + actionName + ); + } + auditMutation(db, ctx, `object.action.${objectType}.${actionName}`, { + recordId: id, + operationRunId, + input: redactActionInput(input, action), + }); + appendDeclaredActionEvents( + db, + ctx, + action, + objectType, + id, + result + ); + })(); + emitAction(ctx, objectType, id, actionName, operationRunId); + } catch (error) { + if (controller.signal.aborted) return; + const normalized = normalizeKernelError(error); + db.transaction(() => { + db.prepare( + `UPDATE kernel_operation_runs + SET status='failed', error_code=?, error_message=?, updated_at=datetime('now'), finished_at=datetime('now') + WHERE id=?` + ).run(normalized.code, normalized.message, operationRunId); + if (ctx.idempotencyKey) { + db.prepare( + `UPDATE kernel_action_idempotency + SET status='failed', updated_at=datetime('now') + WHERE key=? AND actor_id=? AND object_type=? AND record_id=? AND action_name=?` + ).run( + ctx.idempotencyKey, + actorId(ctx), + objectType, + id, + actionName + ); + } + auditMutation( + db, + ctx, + `object.action.${objectType}.${actionName}`, + { recordId: id, operationRunId, errorCode: normalized.code }, + "error" + ); + })(); + } finally { + operationControllers.delete(operationRunId); + } + }); + return { status: "accepted", operationRunId }; + } + + try { + const raw = await handler(db, def, id, input, ctx); + validateSchema(action.outputSchema, raw, `${objectType}.${actionName} output`); + const result = redactActionOutput(raw, action); + db.transaction(() => { + auditMutation(db, ctx, `object.action.${objectType}.${actionName}`, { + recordId: id, + input: redactActionInput(input, action), + }); + appendDeclaredActionEvents(db, ctx, action, objectType, id, result); + if (ctx.idempotencyKey) { + db.prepare( + `UPDATE kernel_action_idempotency + SET status='succeeded', result_json=?, updated_at=datetime('now') + WHERE key=? AND actor_id=? AND object_type=? AND record_id=? AND action_name=?` + ).run( + JSON.stringify(result ?? null), + ctx.idempotencyKey, + actorId(ctx), + objectType, + id, + actionName + ); + } + })(); + emitAction(ctx, objectType, id, actionName); + return result; + } catch (error) { + if (ctx.idempotencyKey) { + db.prepare( + `UPDATE kernel_action_idempotency SET status='failed', updated_at=datetime('now') + WHERE key=? AND actor_id=? AND object_type=? AND record_id=? AND action_name=?` + ).run(ctx.idempotencyKey, actorId(ctx), objectType, id, actionName); + } + throw normalizeKernelError(error); + } +} + +export async function executeRecordAction( + db: AppDatabase, + objectType: string, + id: string, + actionName: string, + input: RecordData, + ctx: OperationContext +): Promise { + try { + return await executeRecordActionImpl( + db, + objectType, + id, + actionName, + input, + ctx + ); + } catch (error) { + const normalized = normalizeKernelError(error); + try { + db.transaction(() => { + auditMutation( + db, + requireContext(ctx), + `object.action.${objectType}.${actionName}`, + { + recordId: id, + input, + errorCode: normalized.code, + }, + normalized.status === 401 || normalized.status === 403 + ? "denied" + : "error" + ); + })(); + } catch { + // Preserve the operation error if the audit store is unavailable. + } + throw normalized; + } +} + +export function cancelOperationRun( + db: AppDatabase, + operationRunId: string, + ctx: OperationContext +): boolean { + ctx = requireContext(ctx); + ensureActionTables(db); + const row = db + .prepare( + `SELECT tenant_id, actor_id, status FROM kernel_operation_runs WHERE id=?` + ) + .get(operationRunId) as + | { tenant_id: string | null; actor_id: string; status: string } + | undefined; + if (!row) throw new KernelError(404, `OperationRun not found: ${operationRunId}`); + if ( + ctx.source !== "system" && + ctx.role !== "intelligence" && + ctx.role !== "owner" && + row.actor_id !== actorId(ctx) + ) { + throw new KernelError(403, "Cannot cancel another principal's operation", { + code: "KERNEL_OPERATION_CANCEL_FORBIDDEN", + }); + } + if (ctx.tenantId && row.tenant_id && ctx.tenantId !== row.tenant_id) { + throw new KernelError(404, `OperationRun not found: ${operationRunId}`); + } + if (!["pending", "running"].includes(row.status)) return false; + operationControllers.get(operationRunId)?.abort(); + db.transaction(() => { + db.prepare( + `UPDATE kernel_operation_runs + SET status='cancelled', updated_at=datetime('now'), finished_at=datetime('now') + WHERE id=?` + ).run(operationRunId); + auditMutation(db, ctx, "object.action.OperationRun.cancel", { + operationRunId, + }); + })(); + return true; +} + +export function recoverInterruptedOperationRuns(db: AppDatabase): number { + ensureActionTables(db); + return db + .prepare( + `UPDATE kernel_operation_runs + SET status='failed', + error_code='KERNEL_RESTART_INTERRUPTED', + error_message='Bridge restarted before this operation completed', + updated_at=datetime('now'), + finished_at=datetime('now') + WHERE status IN ('pending', 'running')` + ) + .run().changes; +} + +export function executeCollectionAction( + db: AppDatabase, + objectType: string, + actionName: string, + input: RecordData, + ctx: OperationContext +): Promise { + return executeRecordAction(db, objectType, "", actionName, input, ctx); +} + +function emitAction( + ctx: OperationContext, + objectType: string, + recordId: string, + action: string, + operationRunId?: string +): void { + ctx.bus?.emit("object.record.action", { + objectType, + recordId: recordId || undefined, + action, + operationRunId, + tenantId: ctx.tenantId, + actorId: ctx.agentId ?? ctx.userId, + source: ctx.source, + }); +} + +function appendDeclaredActionEvents( + db: AppDatabase, + ctx: OperationContext, + action: ActionDef, + objectType: string, + recordId: string, + result: unknown +): void { + for (const event of action.events ?? []) { + const payload = { + objectType, + recordId: recordId || null, + action: action.name, + result, + }; + validateSchema(event.schema, payload, `${objectType}.${action.name} event`); + insertEvent(db, { + id: randomUUID(), + type: event.type, + actorAgentId: actorId(ctx), + subject: recordId || objectType, + payload, + }); + } +} + +function redactPaths(value: unknown, paths: string[] | undefined): unknown { + if (!paths?.length || !value || typeof value !== "object") return value; + const copy = structuredClone(value); + for (const path of paths) { + const parts = path.replace(/^\$\.?/, "").split(".").filter(Boolean); + let cursor: unknown = copy; + for (let index = 0; index < parts.length - 1; index += 1) { + cursor = + cursor && typeof cursor === "object" + ? (cursor as Record)[parts[index]!] + : undefined; + } + if (cursor && typeof cursor === "object" && parts.length) { + (cursor as Record)[parts.at(-1)!] = "[REDACTED]"; + } + } + return copy; +} + +function redactActionInput(input: unknown, action: ActionDef): unknown { + return redactPaths(input, action.sensitiveInputPaths); +} + +function redactActionOutput(output: unknown, action: ActionDef): unknown { + return redactPaths(output, action.sensitiveOutputPaths); +} + +function normalizeKernelError(error: unknown): KernelError { + if (error instanceof KernelError) return error; + if (error && typeof error === "object" && "status" in error) { + return new KernelError( + Number((error as { status: number }).status), + error instanceof Error ? error.message : "Kernel operation failed", + { code: "KERNEL_ADAPTER_ERROR" } + ); + } + return new KernelError( + 500, + error instanceof Error ? error.message : "Kernel operation failed", + { code: "KERNEL_INTERNAL_ERROR", retryable: true } + ); +} + +/** Seed Records from plugin manifest after ObjectTypes are registered. */ +export function seedRecords( + db: AppDatabase, + seeds: Array<{ objectType: string; data: RecordData }>, + ctx: OperationContext +): RecordRow[] { + ctx = requireContext(ctx); + const tx = db.transaction(() => { + const out: RecordRow[] = []; + for (const seed of seeds) { + if (seed.data.id == null || !String(seed.data.id).trim()) { + throw new KernelError(400, `Seed for ${seed.objectType} requires deterministic data.id`); + } + let existing: RecordRow | null = null; + try { + existing = getRecord(db, seed.objectType, String(seed.data.id), ctx); + } catch (err) { + if (!(err instanceof KernelError) || err.status !== 404) throw err; + } + if (existing) { + out.push(updateRecord(db, seed.objectType, existing.id, seed.data, ctx)); + } else { + out.push(createRecord(db, seed.objectType, seed.data, ctx)); + } + } + return out; + }); + return tx(); +} + +export function listVisibleObjectTypes(ctx: OperationContext): ObjectTypeDef[] { + ctx = requireContext(ctx); + return listObjectTypes().filter((def) => isVisible(def, ctx)); +} diff --git a/apps/bridge/src/kernel/registry.ts b/apps/bridge/src/kernel/registry.ts new file mode 100644 index 0000000..d72c87b --- /dev/null +++ b/apps/bridge/src/kernel/registry.ts @@ -0,0 +1,109 @@ +import type { ObjectTypeDef } from "@godmode/kernel"; +import { + STRUCTURE_NODE_OBJECT_TYPE, + validateObjectTypeDef, +} from "@godmode/kernel"; +import { listPageKinds } from "./kind-registry.js"; + +const byName = new Map(); + +function resolveRuntimeMetadata(def: ObjectTypeDef): ObjectTypeDef { + return { + ...def, + fields: def.fields.map((f) => + f.optionsSource === "pageKinds" + ? { ...f, fieldType: "Select" as const, options: listPageKinds() } + : f + ), + }; +} + +export function registerObjectType(def: ObjectTypeDef): void { + const resolved = resolveRuntimeMetadata(def); + const errors = validateObjectTypeDef(resolved); + if (errors.length) { + throw new Error(`Invalid ObjectType ${def.name}: ${errors.join("; ")}`); + } + const existing = byName.get(def.name); + if (existing && existing.pluginId !== def.pluginId) { + throw new Error( + `ObjectType ${def.name} is owned by ${existing.pluginId ?? "core"}` + ); + } + byName.set(def.name, def); +} + +export function registerObjectTypes(defs: ObjectTypeDef[]): void { + for (const def of defs) { + const errors = validateObjectTypeDef(resolveRuntimeMetadata(def)); + if (errors.length) { + throw new Error(`Invalid ObjectType ${def.name}: ${errors.join("; ")}`); + } + const existing = byName.get(def.name); + if (existing && existing.pluginId !== def.pluginId) { + throw new Error( + `ObjectType ${def.name} is owned by ${existing.pluginId ?? "core"}` + ); + } + } + for (const def of defs) byName.set(def.name, def); +} + +export function replaceObjectTypesByPlugin( + pluginId: string, + defs: ObjectTypeDef[] +): void { + if (defs.some((def) => def.pluginId !== pluginId)) { + throw new Error(`All replacement ObjectTypes must be owned by ${pluginId}`); + } + // Validate the complete replacement before changing the live registry. + for (const def of defs) { + const errors = validateObjectTypeDef(resolveRuntimeMetadata(def)); + if (errors.length) { + throw new Error(`Invalid ObjectType ${def.name}: ${errors.join("; ")}`); + } + const existing = byName.get(def.name); + if (existing && existing.pluginId !== pluginId) { + throw new Error( + `ObjectType ${def.name} is owned by ${existing.pluginId ?? "core"}` + ); + } + } + for (const [name, def] of byName) { + if (def.pluginId === pluginId) byName.delete(name); + } + for (const def of defs) byName.set(def.name, def); +} + +export function unregisterObjectType(name: string): void { + if (name === "StructureNode") return; + byName.delete(name); +} + +export function getObjectType(name: string): ObjectTypeDef | undefined { + const def = byName.get(name); + return def ? resolveRuntimeMetadata(def) : undefined; +} + +export function listObjectTypes(): ObjectTypeDef[] { + return [...byName.values()].map(resolveRuntimeMetadata); +} + +export function unregisterObjectTypesByPlugin(pluginId: string): void { + for (const [name, def] of byName) { + if (def.pluginId === pluginId) byName.delete(name); + } +} + +export function hasObjectType(name: string): boolean { + return byName.has(name); +} + +/** Register built-ins. Call once at Bridge boot. */ +export function bootstrapBuiltInObjectTypes(): void { + if (!byName.has("StructureNode")) { + registerObjectType(STRUCTURE_NODE_OBJECT_TYPE); + } +} + +bootstrapBuiltInObjectTypes(); diff --git a/apps/bridge/src/kernel/routes.ts b/apps/bridge/src/kernel/routes.ts new file mode 100644 index 0000000..f3ec72c --- /dev/null +++ b/apps/bridge/src/kernel/routes.ts @@ -0,0 +1,246 @@ +import { randomUUID } from "node:crypto"; +import { Router, type Request } from "express"; +import type { AppDatabase } from "../db.js"; +import { requireTenantRole } from "../services/auth/middleware.js"; +import { getCoreDb } from "../core-db.js"; +import { installedPluginIdsForTenant } from "../plugins/plugin-install.js"; +import { listPageKinds, isRegisteredPageKind } from "./kind-registry.js"; +import { + createRecord, + deleteRecord, + executeCollectionAction, + executeRecordAction, + getRecord, + KernelError, + listRecords, + updateRecord, + listVisibleObjectTypes, +} from "./record-api.js"; +import { StructureError } from "../services/structure.js"; +import type { EventEmitter } from "node:events"; +import type { OperationContext } from "./adapter-registry.js"; +import { PROTOCOL_EXCEPTIONS } from "./protocol-exceptions.js"; + +export function createKernelRouter( + operatorDb: AppDatabase, + deps: { bus?: EventEmitter } = {} +): Router { + const router = Router(); + const tdb = (req: Request): AppDatabase => req.tenantDb ?? operatorDb; + const requireEditor = requireTenantRole("editor"); + const context = (req: Request): OperationContext => ({ + tenantId: req.tenantId, + userId: req.user?.id, + isAdmin: req.user?.isAdmin, + role: req.tenantRole ?? "viewer", + source: "http", + requestId: req.get("X-Request-Id") || randomUUID(), + idempotencyKey: req.get("Idempotency-Key") || undefined, + expectedVersion: req.get("If-Match") || undefined, + confirmationId: req.get("X-Kernel-Confirmation") || undefined, + bus: deps.bus, + installedPluginIds: new Set( + req.tenantId ? installedPluginIdsForTenant(getCoreDb(), req.tenantId) : [] + ), + }); + + router.use((req, res, next) => { + if (req.method === "GET" || req.method === "HEAD" || req.method === "OPTIONS") { + next(); + return; + } + requireEditor(req, res, next); + }); + + const handleErr = (err: unknown, res: import("express").Response): void => { + if (err instanceof KernelError || err instanceof StructureError) { + res.status(err.status).json({ + error: { + code: err instanceof KernelError ? err.code : "STRUCTURE_ERROR", + message: err.message, + details: err instanceof KernelError ? err.details : undefined, + retryable: err instanceof KernelError ? err.retryable : false, + }, + }); + return; + } + console.error("[kernel]", err); + res.status(500).json({ error: "internal error" }); + }; + + router.get("/object-types", (req, res) => { + res.json({ objectTypes: listVisibleObjectTypes(context(req)) }); + }); + + router.get("/kernel/capabilities", (req, res) => { + res.json({ + contractVersion: 1, + objectTypes: listVisibleObjectTypes(context(req)), + protocolExceptions: PROTOCOL_EXCEPTIONS, + }); + }); + + router.get("/object-types/:name", (req, res) => { + const def = listVisibleObjectTypes(context(req)).find( + (item) => item.name === req.params.name + ); + if (!def) { + res.status(404).json({ error: "ObjectType not found" }); + return; + } + res.json(def); + }); + + router.get("/page-kinds", (_req, res) => { + res.json({ kinds: listPageKinds() }); + }); + + router.get("/records/:objectType", (req, res) => { + try { + const parentRaw = req.query.parent_id; + const parentId = + parentRaw === undefined + ? undefined + : parentRaw === "" || parentRaw === "null" + ? null + : String(parentRaw); + const limit = req.query.limit != null ? Number(req.query.limit) : undefined; + const offset = req.query.offset != null ? Number(req.query.offset) : undefined; + const filters = + req.query.filters && typeof req.query.filters === "object" + ? (req.query.filters as Record) + : undefined; + const direction = + req.query.direction === "asc" || req.query.direction === "desc" + ? req.query.direction + : undefined; + res.json( + listRecords(tdb(req), req.params.objectType, { + parentId, + limit: Number.isFinite(limit) ? limit : undefined, + offset: Number.isFinite(offset) ? offset : undefined, + filters, + sort: typeof req.query.sort === "string" ? req.query.sort : undefined, + direction, + }, context(req)) + ); + } catch (err) { + handleErr(err, res); + } + }); + + router.get("/records/:objectType/:id", (req, res) => { + try { + res.json(getRecord(tdb(req), req.params.objectType, req.params.id, context(req))); + } catch (err) { + handleErr(err, res); + } + }); + + router.post("/records/:objectType", (req, res) => { + try { + const body = (req.body ?? {}) as Record; + const data = + body.data && typeof body.data === "object" + ? (body.data as Record) + : body; + if ( + req.params.objectType === "StructureNode" && + data.kind != null && + !isRegisteredPageKind(String(data.kind)) + ) { + throw new KernelError(400, `Unknown page kind: ${String(data.kind)}`); + } + const row = createRecord(tdb(req), req.params.objectType, data, context(req)); + res.status(201).json(row); + } catch (err) { + handleErr(err, res); + } + }); + + router.put("/records/:objectType/:id", (req, res) => { + try { + const body = (req.body ?? {}) as Record; + const data = + body.data && typeof body.data === "object" + ? (body.data as Record) + : body; + if ( + req.params.objectType === "StructureNode" && + data.kind != null && + !isRegisteredPageKind(String(data.kind)) + ) { + throw new KernelError(400, `Unknown page kind: ${String(data.kind)}`); + } + res.json( + updateRecord(tdb(req), req.params.objectType, req.params.id, data, context(req)) + ); + } catch (err) { + handleErr(err, res); + } + }); + + router.delete("/records/:objectType/:id", (req, res) => { + try { + deleteRecord(tdb(req), req.params.objectType, req.params.id, context(req)); + res.json({ ok: true }); + } catch (err) { + handleErr(err, res); + } + }); + + router.post("/records/:objectType/actions/:action", async (req, res) => { + try { + const input = + req.body && typeof req.body === "object" + ? (req.body as Record) + : {}; + const result = await executeCollectionAction( + tdb(req), + req.params.objectType, + req.params.action, + input, + context(req) + ); + res.status( + result && + typeof result === "object" && + "status" in result && + result.status === "accepted" + ? 202 + : 200 + ).json({ result }); + } catch (err) { + handleErr(err, res); + } + }); + + router.post("/records/:objectType/:id/actions/:action", async (req, res) => { + try { + const input = + req.body && typeof req.body === "object" + ? (req.body as Record) + : {}; + const result = await executeRecordAction( + tdb(req), + req.params.objectType, + req.params.id, + req.params.action, + input, + context(req) + ); + res.status( + result && + typeof result === "object" && + "status" in result && + result.status === "accepted" + ? 202 + : 200 + ).json({ result }); + } catch (err) { + handleErr(err, res); + } + }); + + return router; +} diff --git a/apps/bridge/src/kernel/tool-exec.ts b/apps/bridge/src/kernel/tool-exec.ts new file mode 100644 index 0000000..1a239ee --- /dev/null +++ b/apps/bridge/src/kernel/tool-exec.ts @@ -0,0 +1,224 @@ +import type { AppDatabase } from "../db.js"; +import { perObjectTypeToolNames } from "@godmode/kernel"; +import { listObjectTypes } from "./registry.js"; +import type { OperationContext, RecordQuery } from "./adapter-registry.js"; +import { listVisibleObjectTypes } from "./record-api.js"; +import { + createRecord, + deleteRecord, + executeCollectionAction, + executeRecordAction, + getRecord, + KernelError, + listRecords, + updateRecord, +} from "./record-api.js"; + +function asData(args: Record): Record { + const { data, ...rest } = args; + if (data && typeof data === "object" && !Array.isArray(data)) { + return { ...(data as Record) }; + } + const { id: _id, objectType: _ot, ...fields } = rest; + return fields; +} + +function asQuery(args: Record): RecordQuery { + const parentId = + args.parent_id === undefined + ? undefined + : args.parent_id == null || args.parent_id === "" + ? null + : String(args.parent_id); + return { + parentId, + limit: args.limit != null ? Number(args.limit) : undefined, + offset: args.offset != null ? Number(args.offset) : undefined, + filters: + args.filters && typeof args.filters === "object" && !Array.isArray(args.filters) + ? (args.filters as Record) + : undefined, + sort: typeof args.sort === "string" ? args.sort : undefined, + direction: + args.direction === "asc" || args.direction === "desc" + ? args.direction + : undefined, + }; +} + +export function executeKernelTool( + db: AppDatabase, + name: string, + args: Record, + ctx: OperationContext +): unknown | Promise | undefined { + if (name === "list_object_types") { + return { + objectTypes: listVisibleObjectTypes(ctx).map((d) => ({ + name: d.name, + label: d.label, + labelPlural: d.labelPlural, + description: d.description, + storage: d.storage, + module: d.module, + pluginId: d.pluginId, + contractVersion: d.contractVersion, + operations: d.operations, + actions: d.actions, + fields: d.fields.map((f) => ({ + name: f.name, + label: f.label, + fieldType: f.fieldType, + required: f.required, + })), + })), + }; + } + + if (name === "list_records") { + const objectType = String(args.objectType ?? ""); + return listRecords(db, objectType, asQuery(args), ctx); + } + + if (name === "get_record") { + return getRecord(db, String(args.objectType ?? ""), String(args.id ?? ""), ctx); + } + + if (name === "create_record") { + return createRecord(db, String(args.objectType ?? ""), asData(args), ctx); + } + + if (name === "update_record") { + return updateRecord( + db, + String(args.objectType ?? ""), + String(args.id ?? ""), + asData(args), + ctx + ); + } + + if (name === "delete_record") { + deleteRecord(db, String(args.objectType ?? ""), String(args.id ?? ""), ctx); + return { ok: true }; + } + + if (name === "run_record_action") { + const objectType = String(args.objectType ?? ""); + const actionName = String(args.action ?? ""); + const def = listVisibleObjectTypes(ctx).find((item) => item.name === objectType); + const action = def?.actions?.find((item) => item.name === actionName); + const input = + args.input && typeof args.input === "object" && !Array.isArray(args.input) + ? (args.input as Record) + : {}; + return action?.target === "collection" + ? executeCollectionAction(db, objectType, actionName, input, ctx) + : executeRecordAction( + db, + objectType, + String(args.id ?? ""), + actionName, + input, + ctx + ); + } + + for (const def of listVisibleObjectTypes(ctx)) { + const names = perObjectTypeToolNames(def.name); + if (name === names.list) { + return listRecords(db, def.name, asQuery(args), ctx); + } + if (name === names.get) { + return getRecord(db, def.name, String(args.id ?? ""), ctx); + } + if (name === names.create) { + return createRecord(db, def.name, asData(args), ctx); + } + if (name === names.update) { + return updateRecord(db, def.name, String(args.id ?? ""), asData(args), ctx); + } + if (name === names.delete) { + deleteRecord(db, def.name, String(args.id ?? ""), ctx); + return { ok: true }; + } + for (const action of def.actions ?? []) { + const base = def.name + .replace(/([a-z0-9])([A-Z])/g, "$1_$2") + .toLowerCase(); + if (name !== `${base}_${action.name}`) continue; + const { id: rawId, ...input } = args; + return action.target === "collection" + ? executeCollectionAction(db, def.name, action.name, input, ctx) + : executeRecordAction( + db, + def.name, + String(rawId ?? ""), + action.name, + input, + ctx + ); + } + } + + return undefined; +} + +export function isKernelToolName(name: string): boolean { + if ( + [ + "list_object_types", + "list_records", + "get_record", + "create_record", + "update_record", + "delete_record", + "run_record_action", + ].includes(name) + ) { + return true; + } + for (const def of listObjectTypes()) { + const names = perObjectTypeToolNames(def.name); + if (Object.values(names).includes(name)) return true; + const base = def.name + .replace(/([a-z0-9])([A-Z])/g, "$1_$2") + .toLowerCase(); + if (def.actions?.some((action) => `${base}_${action.name}` === name)) { + return true; + } + } + return false; +} + +export function objectTypeForKernelTool( + name: string, + args: Record +): string | undefined { + if ( + [ + "list_records", + "get_record", + "create_record", + "update_record", + "delete_record", + "run_record_action", + ].includes(name) + ) { + return args.objectType != null ? String(args.objectType) : undefined; + } + for (const def of listObjectTypes()) { + if (Object.values(perObjectTypeToolNames(def.name)).includes(name)) { + return def.name; + } + const base = def.name + .replace(/([a-z0-9])([A-Z])/g, "$1_$2") + .toLowerCase(); + if (def.actions?.some((action) => `${base}_${action.name}` === name)) { + return def.name; + } + } + return undefined; +} + +export { KernelError }; diff --git a/apps/bridge/src/plugins/loader.ts b/apps/bridge/src/plugins/loader.ts index bbb839f..a7e5c1c 100644 --- a/apps/bridge/src/plugins/loader.ts +++ b/apps/bridge/src/plugins/loader.ts @@ -11,6 +11,11 @@ import { } from "@godmode/plugin-api"; import { getCoreDb } from "../core-db.js"; import { pluginRuntime } from "./runtime.js"; +import { registerPluginObjectTypes } from "../kernel/plugin-object-types.js"; +import { + listObjectTypes, + replaceObjectTypesByPlugin, +} from "../kernel/registry.js"; const hostRequire = createRequire(import.meta.url); @@ -150,7 +155,16 @@ export async function loadPluginsFromEnv(): Promise { try { const manifest = readGodmodePluginManifest(pluginRoot); assertEngineCompatible(manifest); + registerPluginObjectTypes(manifest); if (!manifest.bridge?.entry) { + if ((manifest.objectTypes?.length ?? 0) > 0 || (manifest.records?.length ?? 0) > 0) { + pluginRuntime.registerManifestOnly(manifest, pluginRoot); + loaded.push(manifest.id); + console.log( + `[plugins] registered ObjectTypes for ${manifest.name} (${manifest.id}) from ${pluginRoot}` + ); + continue; + } errors.push({ path: pluginRoot, error: "manifest missing bridge.entry" }); continue; } @@ -180,23 +194,38 @@ export async function loadPluginFromRoot( ): Promise<{ pluginId: string; pluginRoot: string; reloaded: boolean }> { const manifest = readGodmodePluginManifest(pluginRoot); assertEngineCompatible(manifest); - if (!manifest.bridge?.entry) { - throw new Error("manifest missing bridge.entry"); - } - const already = pluginRuntime.hasPlugin(manifest.id); - const shouldReload = already && (opts?.reload !== false); + const shouldReload = already && opts?.reload !== false; if (already && !shouldReload) { return { pluginId: manifest.id, pluginRoot, reloaded: false }; } - if (already) { - pluginRuntime.unregister(manifest.id); + const previousDefs = listObjectTypes().filter( + (def) => def.pluginId === manifest.id + ); + registerPluginObjectTypes(manifest); + if (!manifest.bridge?.entry) { + if ((manifest.objectTypes?.length ?? 0) > 0 || (manifest.records?.length ?? 0) > 0) { + pluginRuntime.registerManifestOnly(manifest, pluginRoot); + console.log( + `[plugins] registered ObjectTypes for ${manifest.name} (${manifest.id}) from ${pluginRoot}` + ); + return { pluginId: manifest.id, pluginRoot, reloaded: false }; + } + throw new Error("manifest missing bridge.entry"); } - ensureHostGodmodePackageLinks(pluginRoot); - const entryPath = resolveBridgeEntry(pluginRoot, manifest.bridge.entry); - const registerFn = await importBridgeRegister(entryPath, already); - await Promise.resolve(pluginRuntime.register(manifest, pluginRoot, registerFn)); + try { + ensureHostGodmodePackageLinks(pluginRoot); + const entryPath = resolveBridgeEntry(pluginRoot, manifest.bridge.entry); + const registerFn = await importBridgeRegister(entryPath, already); + if (already) { + pluginRuntime.unregister(manifest.id); + } + await Promise.resolve(pluginRuntime.register(manifest, pluginRoot, registerFn)); + } catch (error) { + replaceObjectTypesByPlugin(manifest.id, previousDefs); + throw error; + } console.log( `[plugins] runtime-${already ? "reloaded" : "loaded"} ${manifest.name} (${manifest.id}) from ${pluginRoot}` ); diff --git a/apps/bridge/src/plugins/plugin-install.ts b/apps/bridge/src/plugins/plugin-install.ts index ee8f3fa..65e0b19 100644 --- a/apps/bridge/src/plugins/plugin-install.ts +++ b/apps/bridge/src/plugins/plugin-install.ts @@ -8,6 +8,8 @@ import { removePluginKnowledge, } from "../services/knowledge-store.js"; import { getTenantDb } from "../tenant-registry.js"; +import { applyPluginObjectTypeSeeds, registerPluginObjectTypes } from "../kernel/plugin-object-types.js"; +import { unregisterObjectTypesByPlugin } from "../kernel/registry.js"; export interface TenantPluginRow { tenant_id: string; @@ -15,6 +17,9 @@ export interface TenantPluginRow { version: string; installed_at: string; plugin_root: string | null; + state: "installing" | "active" | "uninstalling" | "failed"; + last_error: string | null; + updated_at: string; } export function ensureTenantPluginsTable(core: CoreDatabase): void { @@ -29,6 +34,27 @@ export function ensureTenantPluginsTable(core: CoreDatabase): void { ); CREATE INDEX IF NOT EXISTS tenant_plugins_tenant_idx ON tenant_plugins(tenant_id); `); + const columns = new Set( + ( + core.prepare(`PRAGMA table_info(tenant_plugins)`).all() as Array<{ + name: string; + }> + ).map((column) => column.name) + ); + if (!columns.has("state")) { + core.exec( + `ALTER TABLE tenant_plugins ADD COLUMN state TEXT NOT NULL DEFAULT 'active'` + ); + } + if (!columns.has("last_error")) { + core.exec(`ALTER TABLE tenant_plugins ADD COLUMN last_error TEXT`); + } + if (!columns.has("updated_at")) { + core.exec(`ALTER TABLE tenant_plugins ADD COLUMN updated_at TEXT`); + core.exec( + `UPDATE tenant_plugins SET updated_at=COALESCE(updated_at, installed_at)` + ); + } } export function listInstalledPlugins( @@ -37,8 +63,11 @@ export function listInstalledPlugins( ): TenantPluginRow[] { return core .prepare( - `SELECT tenant_id, plugin_id, version, installed_at, plugin_root - FROM tenant_plugins WHERE tenant_id=? ORDER BY installed_at` + `SELECT tenant_id, plugin_id, version, installed_at, plugin_root, + state, last_error, COALESCE(updated_at, installed_at) AS updated_at + FROM tenant_plugins + WHERE tenant_id=? AND state='active' + ORDER BY installed_at` ) .all(tenantId) as TenantPluginRow[]; } @@ -88,16 +117,45 @@ export async function installPluginForTenant( ); } const root = pluginRoot ?? loaded.pluginRoot; + registerPluginObjectTypes(loaded.manifest); + ensureTenantPluginsTable(core); core.prepare( - `INSERT INTO tenant_plugins (tenant_id, plugin_id, version, plugin_root) - VALUES (?, ?, ?, ?) + `INSERT INTO tenant_plugins + (tenant_id, plugin_id, version, plugin_root, state, last_error, updated_at) + VALUES (?, ?, ?, ?, 'installing', NULL, datetime('now')) ON CONFLICT(tenant_id, plugin_id) DO UPDATE SET version=excluded.version, plugin_root=excluded.plugin_root, - installed_at=datetime('now')` + state='installing', + last_error=NULL, + installed_at=datetime('now'), + updated_at=datetime('now')` ).run(tenantId, pluginId, loaded.manifest.version, root); - await pluginRuntime.installPluginForTenant(pluginId, tenantId); - syncPluginKnowledgeForTenant(core, tenantId, pluginId, root); + try { + const tenantDb = getTenantDb(tenantId); + applyPluginObjectTypeSeeds(tenantDb, loaded.manifest); + await pluginRuntime.installPluginForTenant(pluginId, tenantId); + syncPluginKnowledgeForTenant(core, tenantId, pluginId, root); + core.prepare( + `UPDATE tenant_plugins + SET state='active', last_error=NULL, updated_at=datetime('now') + WHERE tenant_id=? AND plugin_id=?` + ).run(tenantId, pluginId); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + try { + removePluginKnowledge(getTenantDb(tenantId), pluginId); + await pluginRuntime.uninstallPluginForTenant(pluginId, tenantId); + } catch { + // Reconciliation will retry compensation from the durable failed state. + } + core.prepare( + `UPDATE tenant_plugins + SET state='failed', last_error=?, updated_at=datetime('now') + WHERE tenant_id=? AND plugin_id=?` + ).run(message, tenantId, pluginId); + throw error; + } } function syncPluginKnowledgeForTenant( @@ -129,6 +187,23 @@ export function syncInstalledPluginKnowledge( } } +/** Reconcile declarative ObjectType storage/seeds for every installed tenant. */ +export function reconcileInstalledPluginObjectTypes(core: CoreDatabase): void { + ensureTenantPluginsTable(core); + const rows = core + .prepare( + `SELECT tenant_id, plugin_id FROM tenant_plugins + WHERE state='active' ORDER BY tenant_id, plugin_id` + ) + .all() as Array<{ tenant_id: string; plugin_id: string }>; + for (const row of rows) { + const loaded = pluginRuntime.getPlugin(row.plugin_id); + if (!loaded) continue; + registerPluginObjectTypes(loaded.manifest); + applyPluginObjectTypeSeeds(getTenantDb(row.tenant_id), loaded.manifest); + } +} + export function isPluginEnabledForTenant( core: CoreDatabase, tenantId: string, @@ -136,7 +211,10 @@ export function isPluginEnabledForTenant( ): boolean { if (!pluginRuntime.hasPlugin(pluginId)) return false; const row = core - .prepare(`SELECT 1 FROM tenant_plugins WHERE tenant_id=? AND plugin_id=?`) + .prepare( + `SELECT 1 FROM tenant_plugins + WHERE tenant_id=? AND plugin_id=? AND state='active'` + ) .get(tenantId, pluginId); return Boolean(row); } @@ -151,11 +229,29 @@ export async function uninstallPluginForTenant( pluginId: string ): Promise { const db = getTenantDb(tenantId); - removePluginKnowledge(db, pluginId); - await pluginRuntime.uninstallPluginForTenant(pluginId, tenantId); core.prepare( - `DELETE FROM tenant_plugins WHERE tenant_id=? AND plugin_id=?` + `UPDATE tenant_plugins + SET state='uninstalling', last_error=NULL, updated_at=datetime('now') + WHERE tenant_id=? AND plugin_id=?` ).run(tenantId, pluginId); + try { + removePluginKnowledge(db, pluginId); + await pluginRuntime.uninstallPluginForTenant(pluginId, tenantId); + core.prepare( + `DELETE FROM tenant_plugins WHERE tenant_id=? AND plugin_id=?` + ).run(tenantId, pluginId); + } catch (error) { + core.prepare( + `UPDATE tenant_plugins + SET state='failed', last_error=?, updated_at=datetime('now') + WHERE tenant_id=? AND plugin_id=?` + ).run(error instanceof Error ? error.message : String(error), tenantId, pluginId); + throw error; + } + const remaining = core + .prepare(`SELECT 1 FROM tenant_plugins WHERE plugin_id=? LIMIT 1`) + .get(pluginId); + if (!remaining) unregisterObjectTypesByPlugin(pluginId); } export async function ensureOperatorPluginsInstalled( @@ -167,7 +263,8 @@ export async function ensureOperatorPluginsInstalled( for (const plugin of pluginRuntime.loaded) { const row = core .prepare( - `SELECT 1 FROM tenant_plugins WHERE tenant_id=? AND plugin_id=?` + `SELECT 1 FROM tenant_plugins + WHERE tenant_id=? AND plugin_id=? AND state='active'` ) .get(operatorTenantId, plugin.manifest.id); if (row) continue; diff --git a/apps/bridge/src/plugins/plugin-tools.ts b/apps/bridge/src/plugins/plugin-tools.ts index 517faa1..1998d06 100644 --- a/apps/bridge/src/plugins/plugin-tools.ts +++ b/apps/bridge/src/plugins/plugin-tools.ts @@ -1,5 +1,7 @@ import type { AiToolDef, ToolMode } from "../services/ai-tools-registry.js"; import { pluginRuntime } from "./runtime.js"; +import { getCoreDb } from "../core-db.js"; +import { installedPluginIdsForTenant } from "./plugin-install.js"; export interface PluginToolExecContext { tenantId?: string; @@ -35,6 +37,15 @@ export async function executePluginTool( ): Promise { const def = pluginRuntime.getToolHandler(name); if (!def?.handler) return undefined; + if ( + !execCtx?.tenantId || + !def.pluginId || + !installedPluginIdsForTenant(getCoreDb(), execCtx.tenantId).includes( + def.pluginId + ) + ) { + throw new Error(`Plugin tool is not installed for the active tenant: ${name}`); + } const ctx = pluginRuntime.buildToolContext({ tenantId: execCtx?.tenantId, userId: execCtx?.userId, diff --git a/apps/bridge/src/plugins/runtime.ts b/apps/bridge/src/plugins/runtime.ts index 9e1fe13..bc521e1 100644 --- a/apps/bridge/src/plugins/runtime.ts +++ b/apps/bridge/src/plugins/runtime.ts @@ -1,5 +1,7 @@ import type { Express, IRouter, RequestHandler } from "express"; import type { EventEmitter } from "node:events"; +import type { ObjectTypeDef, RecordData } from "@godmode/kernel"; +import type { AppDatabase } from "../db.js"; import type { GodModePluginApi, GodModePluginRegister, @@ -8,8 +10,21 @@ import type { PluginHookName, PluginTenantContext, PluginToolDef, + PluginRecordAdapter, + PluginRecordContext, } from "@godmode/plugin-api"; import { getPluginHost } from "@godmode/plugin-host"; +import { registerPageKinds } from "../kernel/kind-registry.js"; +import { + registerRecordAdapter, + unregisterRecordAdapter, + type OperationContext, + type RecordAdapter, +} from "../kernel/adapter-registry.js"; +import { + registerObjectType, + unregisterObjectType, +} from "../kernel/registry.js"; type HookHandler = (ctx: PluginBootContext & PluginTenantContext) => void | Promise; @@ -34,6 +49,10 @@ export class PluginRuntime { private readonly routers: Array<{ path: string; router: IRouter }> = []; private readonly middleware: RequestHandler[] = []; private readonly tools: PluginToolDef[] = []; + private readonly objectTypeDisposers: Array<{ + pluginId: string; + dispose: () => void; + }> = []; readonly loaded: LoadedPlugin[] = []; private config: PluginRuntimeConfig | null = null; @@ -47,6 +66,15 @@ export class PluginRuntime { this.loaded.push({ manifest, pluginRoot, api }); } + registerManifestOnly( + manifest: GodmodePluginManifest, + pluginRoot: string + ): void { + if (this.hasPlugin(manifest.id)) this.unregister(manifest.id); + const api = this.createApi(manifest, pluginRoot); + this.loaded.push({ manifest, pluginRoot, api }); + } + /** * Drop a loaded plugin's tools/hooks/loaded entry so it can be re-registered * after a rebuild. Does not unmount Express routes already attached at boot. @@ -66,6 +94,12 @@ export class PluginRuntime { if (next.length) this.hooks.set(name, next); else this.hooks.delete(name); } + for (let index = this.objectTypeDisposers.length - 1; index >= 0; index -= 1) { + const entry = this.objectTypeDisposers[index]!; + if (entry.pluginId !== pluginId) continue; + entry.dispose(); + this.objectTypeDisposers.splice(index, 1); + } return this.loaded.length < before; } @@ -177,6 +211,133 @@ export class PluginRuntime { } }, }, + pageKinds: { + register(kinds: string[]) { + registerPageKinds(kinds); + }, + }, + objectTypes: { + register(definition, pluginAdapter: PluginRecordAdapter) { + const adapterId = `plugin:${pluginId}:${definition.name}`; + const actions = definition.actions ?? []; + for (const action of actions) { + if (!pluginAdapter.actions?.[action.name]) { + throw new Error( + `Plugin ${pluginId} did not implement ${definition.name}.${action.name}` + ); + } + } + const operationMethod = { + list: pluginAdapter.list, + get: pluginAdapter.get, + create: pluginAdapter.create, + update: pluginAdapter.update, + delete: pluginAdapter.delete, + }; + for (const operation of definition.operations ?? []) { + if (!operationMethod[operation]) { + throw new Error( + `Plugin ${pluginId} did not implement ${definition.name}.${operation}` + ); + } + } + const pluginContext = (ctx: OperationContext): PluginRecordContext => ({ + tenantId: ctx.tenantId ?? self.config?.operatorTenantId ?? "", + userId: ctx.userId, + activeAgentId: ctx.agentId, + role: ctx.role, + source: ctx.source, + requestId: ctx.requestId, + signal: ctx.signal, + }); + const adapter: RecordAdapter = { + id: adapterId, + list: pluginAdapter.list + ? (_db, _def, query, ctx) => + pluginAdapter.list!(query, pluginContext(ctx)) + : undefined, + get: pluginAdapter.get + ? (_db, _def, id, ctx) => + pluginAdapter.get!(id, pluginContext(ctx)) + : undefined, + create: pluginAdapter.create + ? (_db, _def, data, ctx) => + pluginAdapter.create!(data, pluginContext(ctx)) + : undefined, + update: pluginAdapter.update + ? (_db, _def, id, data, ctx) => + pluginAdapter.update!(id, data, pluginContext(ctx)) + : undefined, + delete: pluginAdapter.delete + ? (_db, _def, id, ctx) => + pluginAdapter.delete!(id, pluginContext(ctx)) + : undefined, + actions: Object.fromEntries( + Object.entries(pluginAdapter.actions ?? {}).map( + ([name, handler]) => [ + name, + ( + _db: AppDatabase, + _def: ObjectTypeDef, + id: string, + input: RecordData, + ctx: OperationContext + ) => + handler(id, input, pluginContext(ctx)), + ] + ) + ), + }; + registerRecordAdapter(adapter); + const permissions = + definition.permissions ?? + [ + { role: "viewer" as const, read: true }, + { + role: "editor" as const, + read: true, + create: Boolean(pluginAdapter.create), + update: Boolean(pluginAdapter.update), + delete: Boolean(pluginAdapter.delete), + }, + { + role: "owner" as const, + read: true, + create: Boolean(pluginAdapter.create), + update: Boolean(pluginAdapter.update), + delete: Boolean(pluginAdapter.delete), + }, + { + role: "intelligence" as const, + read: true, + create: Boolean(pluginAdapter.create), + update: Boolean(pluginAdapter.update), + delete: Boolean(pluginAdapter.delete), + }, + ]; + try { + registerObjectType({ + ...definition, + contractVersion: definition.contractVersion ?? 1, + permissions, + pluginId, + storage: { kind: "adapter", adapterId }, + }); + } catch (error) { + unregisterRecordAdapter(adapterId); + throw error; + } + let disposed = false; + const dispose = () => { + if (disposed) return; + disposed = true; + unregisterObjectType(definition.name); + unregisterRecordAdapter(adapterId); + }; + self.objectTypeDisposers.push({ pluginId, dispose }); + return { dispose }; + }, + }, hooks: { on(name: PluginHookName, handler: HookHandler) { const list = self.hooks.get(name) ?? []; diff --git a/apps/bridge/src/routes/ai.ts b/apps/bridge/src/routes/ai.ts index 3bb3e07..facc193 100644 --- a/apps/bridge/src/routes/ai.ts +++ b/apps/bridge/src/routes/ai.ts @@ -58,7 +58,19 @@ import { HISTORY_CHAR_BUDGET_RATIO, type HistoryTurn, } from "../services/chat-history.js"; -import type { AiScheduler } from "../services/ai-scheduler.js"; +import { + createSchedule, + deleteSchedule, + listSchedules, + updateSchedule, + type AiScheduler, +} from "../services/ai-scheduler.js"; +import { + createWorkflow, + listWorkflows, + updateWorkflow, + type AiWorkflow, +} from "../services/ai-workflows.js"; import type { AiQueueWorker } from "../services/ai-queue-worker.js"; import { AUTONOMOUS_RUNNER_ID } from "../services/ai-queue-worker.js"; import type { AiTrainingManager } from "../services/ai-training-manager.js"; @@ -2366,46 +2378,49 @@ export function createAiRouter( res.json({ ok: true }); }); + const workflowApiRow = (workflow: AiWorkflow) => ({ + id: workflow.id, + agent_id: workflow.agent_id, + name: workflow.name, + config_json: JSON.stringify(workflow.config), + enabled: workflow.enabled, + created_at: workflow.created_at, + updated_at: workflow.updated_at, + }); + router.get("/workflows", (req, res) => { const agentId = String(req.query.agentId ?? "intelligence"); res.json({ - workflows: tdb(req) - .prepare(`SELECT * FROM ai_workflows WHERE agent_id = ? ORDER BY updated_at DESC`) - .all(agentId), + workflows: listWorkflows(tdb(req)) + .filter((workflow) => workflow.agent_id === agentId) + .map(workflowApiRow), }); }); router.post("/workflows", (req, res) => { - const id = uuidv4(); const name = String(req.body?.name ?? "Workflow"); const agentId = String(req.body?.agentId ?? req.query.agentId ?? "intelligence"); const config = req.body?.config ?? { nodes: [], edges: [] }; - tdb(req).prepare( - `INSERT INTO ai_workflows (id, name, config_json, agent_id) VALUES (?, ?, ?, ?)` - ).run(id, name, JSON.stringify(config), agentId); - res.status(201).json(tdb(req).prepare(`SELECT * FROM ai_workflows WHERE id = ?`).get(id)); + const workflow = createWorkflow(tdb(req), { + name, + agentId, + config, + }); + res.status(201).json(workflowApiRow(workflow)); }); router.put("/workflows/:id", (req, res) => { const { name, config, enabled } = req.body ?? {}; - if (name != null) { - tdb(req).prepare(`UPDATE ai_workflows SET name = ?, updated_at = datetime('now') WHERE id = ?`).run( - String(name), - req.params.id - ); - } - if (config != null) { - tdb(req).prepare( - `UPDATE ai_workflows SET config_json = ?, updated_at = datetime('now') WHERE id = ?` - ).run(JSON.stringify(config), req.params.id); - } - if (enabled != null) { - tdb(req).prepare(`UPDATE ai_workflows SET enabled = ?, updated_at = datetime('now') WHERE id = ?`).run( - enabled ? 1 : 0, - req.params.id - ); + const workflow = updateWorkflow(tdb(req), req.params.id, { + name: name == null ? undefined : String(name), + config: config ?? undefined, + enabled: enabled == null ? undefined : Boolean(enabled), + }); + if (!workflow) { + res.status(404).json({ error: "Workflow not found" }); + return; } - res.json(tdb(req).prepare(`SELECT * FROM ai_workflows WHERE id = ?`).get(req.params.id)); + res.json(workflowApiRow(workflow)); }); // Delete a workflow and detach anything that references it so no dangling @@ -2568,52 +2583,44 @@ export function createAiRouter( }); router.get("/schedules", (req, res) => { - res.json({ schedules: tdb(req).prepare(`SELECT * FROM ai_schedules ORDER BY created_at DESC`).all() }); + res.json({ schedules: listSchedules(tdb(req)) }); }); router.post("/schedules", (req, res) => { - const id = uuidv4(); const { workflowId, cronExpr, timezone, enabled } = req.body ?? {}; if (!workflowId || !cronExpr) { res.status(400).json({ error: "workflowId and cronExpr required" }); return; } - tdb(req).prepare( - `INSERT INTO ai_schedules (id, workflow_id, cron_expr, timezone, enabled) - VALUES (?, ?, ?, ?, ?)` - ).run( - id, - String(workflowId), - String(cronExpr), - timezone ? String(timezone) : "America/Denver", - enabled === false ? 0 : 1 - ); + const schedule = createSchedule(tdb(req), { + workflowId: String(workflowId), + cronExpr: String(cronExpr), + timezone: timezone ? String(timezone) : undefined, + enabled: enabled == null ? undefined : Boolean(enabled), + }); scheduler.reload(); - res.status(201).json(tdb(req).prepare(`SELECT * FROM ai_schedules WHERE id = ?`).get(id)); + res.status(201).json(schedule); }); router.put("/schedules/:id", (req, res) => { const { cronExpr, timezone, enabled } = req.body ?? {}; - if (cronExpr != null) - tdb(req).prepare( - `UPDATE ai_schedules SET cron_expr = ?, updated_at = datetime('now') WHERE id = ?` - ).run(String(cronExpr), req.params.id); - if (timezone != null) - tdb(req).prepare( - `UPDATE ai_schedules SET timezone = ?, updated_at = datetime('now') WHERE id = ?` - ).run(String(timezone), req.params.id); - if (enabled != null) - tdb(req).prepare( - `UPDATE ai_schedules SET enabled = ?, updated_at = datetime('now') WHERE id = ?` - ).run(enabled ? 1 : 0, req.params.id); + const schedule = updateSchedule(tdb(req), req.params.id, { + cronExpr: cronExpr == null ? undefined : String(cronExpr), + timezone: timezone == null ? undefined : String(timezone), + enabled: enabled == null ? undefined : Boolean(enabled), + }); + if (!schedule) { + res.status(404).json({ error: "Schedule not found" }); + return; + } scheduler.reload(); - res.json(tdb(req).prepare(`SELECT * FROM ai_schedules WHERE id = ?`).get(req.params.id)); + res.json(schedule); }); router.delete("/schedules/:id", (req, res) => { - const r = tdb(req).prepare(`DELETE FROM ai_schedules WHERE id = ?`).run(req.params.id); + const deleted = deleteSchedule(tdb(req), req.params.id); scheduler.reload(); - res.json({ ok: r.changes > 0 }); + res.json({ ok: deleted }); }); // Resolve (or lazily create) the single board project owned by an agent. The diff --git a/apps/bridge/src/routes/api-core.ts b/apps/bridge/src/routes/api-core.ts index daac68d..3301000 100644 --- a/apps/bridge/src/routes/api-core.ts +++ b/apps/bridge/src/routes/api-core.ts @@ -8,23 +8,9 @@ import { getTimeseriesStore } from "../services/timeseries-store.js"; import { requirePlatformAdmin } from "../services/auth/middleware.js"; import type { AppDatabase } from "../db.js"; import { - createDepartment, - createDivision, - createNode, - createPage, - deleteDepartment, - deleteDivision, - deleteNode, - deletePage, readStructure, - reorder, - reorderNodes, - setNodeAgent, + structureNodesToLegacy, StructureError, - updateDepartment, - updateDivision, - updateNode, - updatePage, type ReorderKind, } from "../services/structure.js"; import { @@ -32,6 +18,15 @@ import { writeStructureGraphLayout, } from "../services/structure-graph-service.js"; import { requireTenantRole } from "../services/auth/middleware.js"; +import { + createRecord, + deleteRecord, + executeCollectionAction, + executeRecordAction, + KernelError, + updateRecord, +} from "../kernel/record-api.js"; +import type { OperationContext } from "../kernel/adapter-registry.js"; export interface CoreApiDeps { bus?: EventEmitter; @@ -44,6 +39,14 @@ export function createCoreApiRouter( const router = Router(); const tdb = (req: Request): AppDatabase => req.tenantDb ?? operatorDb; const requireEditor = requireTenantRole("editor"); + const kernelContext = (req: Request): OperationContext => ({ + tenantId: req.tenantId, + userId: req.user?.id, + isAdmin: req.user?.isAdmin, + role: req.tenantRole ?? "viewer", + source: "http", + bus: deps.bus, + }); router.use((req, res, next) => { if (req.method === "GET" || req.method === "HEAD" || req.method === "OPTIONS") { @@ -65,7 +68,7 @@ export function createCoreApiRouter( err: unknown, res: import("express").Response ): void => { - if (err instanceof StructureError) { + if (err instanceof StructureError || err instanceof KernelError) { res.status(err.status).json({ error: err.message }); return; } @@ -105,12 +108,21 @@ export function createCoreApiRouter( router.post("/departments", (req, res) => { try { - const dept = createDepartment(tdb(req), req.body ?? {}); - deps.bus?.emit("structure.department.created", { - departmentId: dept.id, - label: dept.label, - icon: dept.icon, - }); + const body = req.body ?? {}; + createRecord( + tdb(req), + "StructureNode", + { + id: String(body.id ?? ""), + parent_id: null, + label: String(body.label ?? ""), + icon: String(body.icon ?? ""), + }, + kernelContext(req) + ); + const dept = structureNodesToLegacy(readStructure(tdb(req))).departments.find( + (item) => item.id === String(body.id ?? "") + ); res.json(dept); } catch (err) { handleStructureError(err, res); @@ -119,8 +131,13 @@ export function createCoreApiRouter( router.put("/departments/:id", (req, res) => { try { - updateDepartment(tdb(req), req.params.id, req.body ?? {}); - deps.bus?.emit("structure.department.updated", { departmentId: req.params.id }); + updateRecord( + tdb(req), + "StructureNode", + req.params.id, + req.body ?? {}, + kernelContext(req) + ); res.json({ ok: true }); } catch (err) { handleStructureError(err, res); @@ -129,8 +146,7 @@ export function createCoreApiRouter( router.delete("/departments/:id", (req, res) => { try { - deleteDepartment(tdb(req), req.params.id); - deps.bus?.emit("structure.department.deleted", { departmentId: req.params.id }); + deleteRecord(tdb(req), "StructureNode", req.params.id, kernelContext(req)); res.json({ ok: true }); } catch (err) { handleStructureError(err, res); @@ -139,11 +155,22 @@ export function createCoreApiRouter( router.post("/departments/:dept/divisions", (req, res) => { try { - const div = createDivision(tdb(req), req.params.dept, req.body ?? {}); - deps.bus?.emit("structure.division.created", { - departmentId: req.params.dept, - divisionId: div.id, - }); + const body = req.body ?? {}; + createRecord( + tdb(req), + "StructureNode", + { + id: String(body.id ?? ""), + parent_id: req.params.dept, + label: String(body.label ?? ""), + icon: String(body.icon ?? ""), + right_sidebar: body.rightSidebar, + }, + kernelContext(req) + ); + const div = structureNodesToLegacy(readStructure(tdb(req))) + .departments.find((item) => item.id === req.params.dept) + ?.divisions.find((item) => item.id === String(body.id ?? "")); res.json(div); } catch (err) { handleStructureError(err, res); @@ -152,11 +179,18 @@ export function createCoreApiRouter( router.put("/divisions/:dept/:id", (req, res) => { try { - updateDivision(tdb(req), req.params.dept, req.params.id, req.body ?? {}); - deps.bus?.emit("structure.division.updated", { - departmentId: req.params.dept, - divisionId: req.params.id, - }); + const body = req.body ?? {}; + updateRecord( + tdb(req), + "StructureNode", + `${req.params.dept}-${req.params.id}`, + { + label: body.label, + icon: body.icon, + right_sidebar: body.rightSidebar, + }, + kernelContext(req) + ); res.json({ ok: true }); } catch (err) { handleStructureError(err, res); @@ -165,11 +199,12 @@ export function createCoreApiRouter( router.delete("/divisions/:dept/:id", (req, res) => { try { - deleteDivision(tdb(req), req.params.dept, req.params.id); - deps.bus?.emit("structure.division.deleted", { - departmentId: req.params.dept, - divisionId: req.params.id, - }); + deleteRecord( + tdb(req), + "StructureNode", + `${req.params.dept}-${req.params.id}`, + kernelContext(req) + ); res.json({ ok: true }); } catch (err) { handleStructureError(err, res); @@ -178,17 +213,24 @@ export function createCoreApiRouter( router.post("/divisions/:dept/:div/pages", (req, res) => { try { - const page = createPage( + const body = req.body ?? {}; + createRecord( tdb(req), - req.params.dept, - req.params.div, - req.body ?? {} + "StructureNode", + { + id: String(body.id ?? ""), + parent_id: `${req.params.dept}-${req.params.div}`, + label: String(body.label ?? ""), + icon: String(body.icon ?? ""), + segment: String(body.segment ?? ""), + kind: body.kind ?? body.pageKind, + }, + kernelContext(req) ); - deps.bus?.emit("structure.page.created", { - departmentId: req.params.dept, - divisionId: req.params.div, - pageId: page.id, - }); + const page = structureNodesToLegacy(readStructure(tdb(req))) + .departments.find((item) => item.id === req.params.dept) + ?.divisions.find((item) => item.id === req.params.div) + ?.pages.find((item) => item.id === String(body.id ?? "")); res.json(page); } catch (err) { handleStructureError(err, res); @@ -197,18 +239,19 @@ export function createCoreApiRouter( router.put("/pages/:dept/:div/:id", (req, res) => { try { - updatePage( + const body = req.body ?? {}; + updateRecord( tdb(req), - req.params.dept, - req.params.div, - req.params.id, - req.body ?? {} + "StructureNode", + `${req.params.dept}-${req.params.div}-${req.params.id}`, + { + label: body.label, + icon: body.icon, + segment: body.segment, + kind: body.kind ?? body.pageKind, + }, + kernelContext(req) ); - deps.bus?.emit("structure.page.updated", { - departmentId: req.params.dept, - divisionId: req.params.div, - pageId: req.params.id, - }); res.json({ ok: true }); } catch (err) { handleStructureError(err, res); @@ -217,12 +260,12 @@ export function createCoreApiRouter( router.delete("/pages/:dept/:div/:id", (req, res) => { try { - deletePage(tdb(req), req.params.dept, req.params.div, req.params.id); - deps.bus?.emit("structure.page.deleted", { - departmentId: req.params.dept, - divisionId: req.params.div, - pageId: req.params.id, - }); + deleteRecord( + tdb(req), + "StructureNode", + `${req.params.dept}-${req.params.div}-${req.params.id}`, + kernelContext(req) + ); res.json({ ok: true }); } catch (err) { handleStructureError(err, res); @@ -232,9 +275,9 @@ export function createCoreApiRouter( router.post("/nodes", (req, res) => { try { const body = req.body ?? {}; - const node = createNode(tdb(req), { + const node = createRecord(tdb(req), "StructureNode", { id: String(body.id ?? ""), - parentId: + parent_id: body.parentId === null || body.parentId === undefined ? null : String(body.parentId), @@ -242,11 +285,30 @@ export function createCoreApiRouter( icon: String(body.icon ?? ""), segment: body.segment != null ? String(body.segment) : undefined, kind: body.kind != null ? String(body.kind) : undefined, - rightSidebar: + object_type: + body.objectType === null + ? null + : body.objectType != null + ? String(body.objectType) + : undefined, + right_sidebar: body.rightSidebar != null ? String(body.rightSidebar) : undefined, + }, kernelContext(req)); + res.json({ + id: node.id, + parentId: node.data.parent_id ?? null, + label: node.data.label, + icon: node.data.icon, + segment: node.data.segment, + kind: node.data.kind, + objectType: node.data.object_type ?? null, + rightSidebar: node.data.right_sidebar ?? null, + agentId: node.data.agent_id ?? null, + builtIn: node.data.built_in, + sortOrder: node.data.sort_order, + tabs: node.data.tabs_json, + path: node.data.path, }); - deps.bus?.emit("structure.node.created", { nodeId: node.id }); - res.json(node); } catch (err) { handleStructureError(err, res); } @@ -254,8 +316,22 @@ export function createCoreApiRouter( router.put("/nodes/:id", (req, res) => { try { - updateNode(tdb(req), req.params.id, req.body ?? {}); - deps.bus?.emit("structure.node.updated", { nodeId: req.params.id }); + const body = req.body ?? {}; + updateRecord( + tdb(req), + "StructureNode", + req.params.id, + { + label: body.label, + icon: body.icon, + segment: body.segment, + kind: body.kind, + parent_id: body.parentId, + object_type: body.objectType, + right_sidebar: body.rightSidebar, + }, + kernelContext(req) + ); res.json({ ok: true }); } catch (err) { handleStructureError(err, res); @@ -264,67 +340,101 @@ export function createCoreApiRouter( router.delete("/nodes/:id", (req, res) => { try { - deleteNode(tdb(req), req.params.id); - deps.bus?.emit("structure.node.deleted", { nodeId: req.params.id }); + deleteRecord(tdb(req), "StructureNode", req.params.id, kernelContext(req)); res.json({ ok: true }); } catch (err) { handleStructureError(err, res); } }); - router.post("/nodes/:id/agent", (req, res) => { + router.post("/nodes/:id/agent", async (req, res) => { try { const agentId = req.body?.agentId === null || req.body?.agentId === undefined ? null : String(req.body.agentId); - setNodeAgent(tdb(req), req.params.id, agentId); + await executeRecordAction( + tdb(req), + "StructureNode", + req.params.id, + "set_agent", + { agent_id: agentId }, + kernelContext(req) + ); res.json({ ok: true }); } catch (err) { handleStructureError(err, res); } }); - router.delete("/nodes/:id/agent", (req, res) => { + router.delete("/nodes/:id/agent", async (req, res) => { try { - setNodeAgent(tdb(req), req.params.id, null); + await executeRecordAction( + tdb(req), + "StructureNode", + req.params.id, + "set_agent", + { agent_id: null }, + kernelContext(req) + ); res.json({ ok: true }); } catch (err) { handleStructureError(err, res); } }); - router.post("/structure/reorder", (req, res) => { + router.post("/structure/reorder", async (req, res) => { try { const body = req.body ?? {}; const orderedIds = Array.isArray(body.orderedIds) ? (body.orderedIds as string[]) : []; if (typeof body.parentId === "string" || body.parentId === null) { - reorderNodes( + await executeCollectionAction( tdb(req), - body.parentId === null ? null : String(body.parentId), - orderedIds + "StructureNode", + "reorder", + { + parent_id: + body.parentId === null ? null : String(body.parentId), + ordered_ids: orderedIds, + }, + kernelContext(req) ); - deps.bus?.emit("structure.changed", { kind: "reorder" }); return res.json({ ok: true }); } const kind = body.kind as ReorderKind; if (kind !== "department" && kind !== "division" && kind !== "page") { return res.status(400).json({ error: "invalid kind" }); } - reorder( + const departmentId = + typeof body.departmentId === "string" ? body.departmentId : undefined; + const divisionId = + typeof body.divisionId === "string" ? body.divisionId : undefined; + const parentId = + kind === "department" + ? null + : kind === "division" + ? departmentId + : departmentId && divisionId + ? `${departmentId}-${divisionId}` + : undefined; + if (parentId === undefined) { + return res.status(400).json({ error: "parent scope required" }); + } + const normalizedIds = + kind === "department" + ? orderedIds + : orderedIds.map((id) => + id.startsWith(`${parentId}-`) ? id : `${parentId}-${id}` + ); + await executeCollectionAction( tdb(req), - kind, - { - departmentId: - typeof body.departmentId === "string" ? body.departmentId : undefined, - divisionId: - typeof body.divisionId === "string" ? body.divisionId : undefined, - }, - orderedIds + "StructureNode", + "reorder", + { parent_id: parentId, ordered_ids: normalizedIds }, + kernelContext(req) ); - deps.bus?.emit("structure.reordered", { kind }); res.json({ ok: true }); } catch (err) { handleStructureError(err, res); diff --git a/apps/bridge/src/services/agents/cursor-cloud-backend.ts b/apps/bridge/src/services/agents/cursor-cloud-backend.ts index ec6ac1d..e0e606c 100644 --- a/apps/bridge/src/services/agents/cursor-cloud-backend.ts +++ b/apps/bridge/src/services/agents/cursor-cloud-backend.ts @@ -165,6 +165,7 @@ function buildCustomTools( try { const result = await executeTool(name, args as Record, { ...toolCtx, + confirmationApproved: true, activeToolCallId: context.toolCallId, onTerminalOutput: req.onTerminalOutput ? (chunk) => diff --git a/apps/bridge/src/services/agents/provider-backend.ts b/apps/bridge/src/services/agents/provider-backend.ts index 011c67d..044f91c 100644 --- a/apps/bridge/src/services/agents/provider-backend.ts +++ b/apps/bridge/src/services/agents/provider-backend.ts @@ -147,6 +147,7 @@ async function executeOneTool( try { result = await executeTool(fnName, args, { ...toolCtx, + confirmationApproved: true, activeToolCallId: tc.id, onTerminalOutput: req.onTerminalOutput ? (chunk) => req.onTerminalOutput!(tc.id, chunk) diff --git a/apps/bridge/src/services/ai-tool-executor.ts b/apps/bridge/src/services/ai-tool-executor.ts index cb48747..df4c591 100644 --- a/apps/bridge/src/services/ai-tool-executor.ts +++ b/apps/bridge/src/services/ai-tool-executor.ts @@ -19,8 +19,15 @@ import { runSubagent } from "./agents/runner.js"; import { runCursorAgent } from "./agents/cursor-backend.js"; import { buildContractorContextBundle } from "./contractor-context.js"; import { createAgent, getAgent, listAgents } from "./agents/agents-db.js"; -import { setNodeAgent } from "./structure.js"; -import { isValidPageKind } from "./page-kinds.js"; +import { objectTypeAutoToolDefs } from "../kernel/auto-tools.js"; +import type { OperationContext } from "../kernel/adapter-registry.js"; +import { + executeKernelTool, + isKernelToolName, + KernelError, + objectTypeForKernelTool, +} from "../kernel/tool-exec.js"; +import { isRegisteredPageKind } from "../kernel/kind-registry.js"; import { getCoreDb } from "../core-db.js"; import { broadcastCardActivity } from "../ws-broker.js"; import { @@ -121,6 +128,7 @@ import { installCatalogEntry } from "./marketplace-catalog.js"; import { listAvailablePlugins, listInstalledPlugins, + installedPluginIdsForTenant, } from "../plugins/plugin-install.js"; import { activatePluginForTenant } from "../plugins/activate-plugin.js"; import { scaffoldPlugin, prepareMarketplaceSubmission, defaultPluginRoot } from "./plugin-scaffold.js"; @@ -166,6 +174,8 @@ export interface ToolExecContext { sessionAutonomy?: import("./agents/agents-db.js").CodeAutonomyLevel; /** Active tool call id for streaming terminal output. */ activeToolCallId?: string; + /** The agent backend's confirmation policy approved this exact tool call. */ + confirmationApproved?: boolean; onTerminalOutput?: (chunk: { stream: "stdout" | "stderr"; text: string; @@ -183,9 +193,44 @@ function pluginExecCtx(ctx: ToolExecContext): PluginToolExecContext { }; } +function kernelOperationContext(ctx: ToolExecContext): OperationContext { + return { + tenantId: ctx.tenantId, + userId: ctx.userId, + role: + (ctx.activeAgentId ?? "intelligence") === "intelligence" + ? "intelligence" + : "editor", + agentId: ctx.activeAgentId ?? "intelligence", + source: "agent", + requestId: ctx.activeToolCallId, + idempotencyKey: ctx.activeToolCallId, + trustedConfirmation: ctx.confirmationApproved === true, + installedPluginIds: new Set( + ctx.tenantId + ? installedPluginIdsForTenant(getCoreDb(), ctx.tenantId) + : [] + ), + }; +} + function toolMode(name: string): "auto" | "confirm" | null { const core = AI_TOOL_REGISTRY.find((t) => t.name === name); if (core) return core.mode; + if (isKernelToolName(name)) { + if ( + name.startsWith("create_") || + name.startsWith("update_") || + name.startsWith("delete_") + ) { + return "confirm"; + } + const auto = objectTypeAutoToolDefs( + new Set(AI_TOOL_REGISTRY.map((t) => t.name)) + ).find((t) => t.name === name); + if (auto) return auto.mode; + return "auto"; + } const plugin = pluginToolsAsAiDefs().find((t) => t.name === name); return plugin ? plugin.mode : null; } @@ -1381,11 +1426,16 @@ export async function executeTool( } /* -------------------- Platform Builder: Structure (Phase A) ---------- */ case "list_structure": - return bridgeFetch(ctx, "/structure", tenantInit(ctx, "GET")); + return executeKernelTool( + ctx.db, + "list_records", + { objectType: "StructureNode" }, + kernelOperationContext(ctx) + ); case "create_department": { const kind = args.kind != null ? String(args.kind) : undefined; - if (kind && !isValidPageKind(kind)) throw new Error(`invalid kind: ${kind}`); + if (kind && !isRegisteredPageKind(kind)) throw new Error(`invalid kind: ${kind}`); const body = { id: String(args.id ?? ""), parentId: null, @@ -1394,14 +1444,30 @@ export async function executeTool( kind, }; return runPlatform(ctx, "create_department", undefined, body, () => - bridgeFetch(ctx, "/nodes", tenantInit(ctx, "POST", body)) + Promise.resolve( + executeKernelTool( + ctx.db, + "create_record", + { + objectType: "StructureNode", + data: { + id: body.id, + parent_id: null, + label: body.label, + icon: body.icon, + kind: body.kind, + }, + }, + kernelOperationContext(ctx) + ) + ) ); } case "create_division": { const departmentId = String(args.departmentId ?? ""); const kind = args.kind != null ? String(args.kind) : undefined; - if (kind && !isValidPageKind(kind)) throw new Error(`invalid kind: ${kind}`); + if (kind && !isRegisteredPageKind(kind)) throw new Error(`invalid kind: ${kind}`); const body = { id: String(args.id ?? ""), parentId: departmentId, @@ -1412,7 +1478,25 @@ export async function executeTool( segment: args.segment != null ? String(args.segment) : undefined, }; return runPlatform(ctx, "create_division", { departmentId }, body, () => - bridgeFetch(ctx, "/nodes", tenantInit(ctx, "POST", body)) + Promise.resolve( + executeKernelTool( + ctx.db, + "create_record", + { + objectType: "StructureNode", + data: { + id: body.id, + parent_id: body.parentId, + label: body.label, + icon: body.icon, + right_sidebar: body.rightSidebar, + kind: body.kind, + segment: body.segment, + }, + }, + kernelOperationContext(ctx) + ) + ) ); } case "create_page": { @@ -1420,7 +1504,7 @@ export async function executeTool( const divisionId = String(args.divisionId ?? ""); const kind = args.kind != null ? String(args.kind) : undefined; - if (kind && !isValidPageKind(kind)) throw new Error(`invalid kind: ${kind}`); + if (kind && !isRegisteredPageKind(kind)) throw new Error(`invalid kind: ${kind}`); const body = { id: String(args.id ?? ""), parentId: `${departmentId}-${divisionId}`, @@ -1434,7 +1518,25 @@ export async function executeTool( "create_page", { departmentId, divisionId }, body, - () => bridgeFetch(ctx, "/nodes", tenantInit(ctx, "POST", body)) + () => + Promise.resolve( + executeKernelTool( + ctx.db, + "create_record", + { + objectType: "StructureNode", + data: { + id: body.id, + parent_id: body.parentId, + label: body.label, + icon: body.icon, + segment: body.segment, + kind: body.kind, + }, + }, + kernelOperationContext(ctx) + ) + ) ); } case "update_structure_node": { @@ -1449,27 +1551,44 @@ export async function executeTool( if (args.rightSidebar != null) patch.rightSidebar = String(args.rightSidebar); if (args.kind != null) { const kind = String(args.kind); - if (!isValidPageKind(kind)) throw new Error(`invalid kind: ${kind}`); + if (!isRegisteredPageKind(kind)) throw new Error(`invalid kind: ${kind}`); patch.kind = kind; } let scope: PlatformScope; - let path: string; + let nodeId: string; if (nodeType === "department") { scope = { departmentId }; - path = `/departments/${encodeURIComponent(departmentId)}`; + nodeId = departmentId; } else if (nodeType === "division") { if (!divisionId) throw new Error("divisionId required for division"); scope = { departmentId, divisionId }; - path = `/divisions/${encodeURIComponent(departmentId)}/${encodeURIComponent(divisionId)}`; + nodeId = `${departmentId}-${divisionId}`; } else if (nodeType === "page") { if (!divisionId || !pageId) throw new Error("divisionId and pageId required for page"); scope = { departmentId, divisionId, pageId }; - path = `/pages/${encodeURIComponent(departmentId)}/${encodeURIComponent(divisionId)}/${encodeURIComponent(pageId)}`; + nodeId = `${departmentId}-${divisionId}-${pageId}`; } else { throw new Error(`invalid nodeType: ${nodeType}`); } return runPlatform(ctx, "update_structure_node", scope, { nodeType, patch }, () => - bridgeFetch(ctx, path, tenantInit(ctx, "PUT", patch)) + Promise.resolve( + executeKernelTool( + ctx.db, + "update_record", + { + objectType: "StructureNode", + id: nodeId, + data: { + label: patch.label, + icon: patch.icon, + segment: patch.segment, + kind: patch.kind, + right_sidebar: patch.rightSidebar, + }, + }, + kernelOperationContext(ctx) + ) + ) ); } case "delete_structure_node": { @@ -1478,23 +1597,30 @@ export async function executeTool( const divisionId = args.divisionId != null ? String(args.divisionId) : undefined; const pageId = args.pageId != null ? String(args.pageId) : undefined; let scope: PlatformScope; - let path: string; + let nodeId: string; if (nodeType === "department") { scope = { departmentId }; - path = `/departments/${encodeURIComponent(departmentId)}`; + nodeId = departmentId; } else if (nodeType === "division") { if (!divisionId) throw new Error("divisionId required for division"); scope = { departmentId, divisionId }; - path = `/divisions/${encodeURIComponent(departmentId)}/${encodeURIComponent(divisionId)}`; + nodeId = `${departmentId}-${divisionId}`; } else if (nodeType === "page") { if (!divisionId || !pageId) throw new Error("divisionId and pageId required for page"); scope = { departmentId, divisionId, pageId }; - path = `/pages/${encodeURIComponent(departmentId)}/${encodeURIComponent(divisionId)}/${encodeURIComponent(pageId)}`; + nodeId = `${departmentId}-${divisionId}-${pageId}`; } else { throw new Error(`invalid nodeType: ${nodeType}`); } return runPlatform(ctx, "delete_structure_node", scope, { nodeType }, () => - bridgeFetch(ctx, path, tenantInit(ctx, "DELETE")) + Promise.resolve( + executeKernelTool( + ctx.db, + "delete_record", + { objectType: "StructureNode", id: nodeId }, + kernelOperationContext(ctx) + ) + ) ); } case "assign_agent": { @@ -1568,10 +1694,20 @@ export async function executeTool( "attach_node_agent", scope, { nodeId, agentId }, - () => { - setNodeAgent(ctx.db, nodeId, agentId); - return Promise.resolve({ ok: true, nodeId, agentId }); - } + () => + Promise.resolve( + executeKernelTool( + ctx.db, + "run_record_action", + { + objectType: "StructureNode", + id: nodeId, + action: "set_agent", + input: { agent_id: agentId }, + }, + kernelOperationContext(ctx) + ) + ) ); } @@ -2456,6 +2592,58 @@ export async function executeTool( } default: { + if (isKernelToolName(name)) { + try { + const installedPluginIds = new Set( + ctx.tenantId + ? installedPluginIdsForTenant(getCoreDb(), ctx.tenantId) + : [] + ); + const runKernel = () => + Promise.resolve( + executeKernelTool( + ctx.db, + name, + args, + { + ...kernelOperationContext(ctx), + installedPluginIds, + } + ) + ); + const objectType = objectTypeForKernelTool(name, args); + const isMutation = + name.startsWith("create_") || + name.startsWith("update_") || + name.startsWith("delete_"); + if (objectType === "StructureNode" && isMutation) { + const data = + args.data && typeof args.data === "object" + ? (args.data as Record) + : args; + const parentId = + data.parent_id != null ? String(data.parent_id) : undefined; + const targetId = args.id != null ? String(args.id) : undefined; + const scopeId = parentId ?? targetId; + const departmentId = scopeId?.split("-")[0]; + const scope = departmentId ? { departmentId } : undefined; + const result = await runPlatform( + ctx, + name, + scope, + args, + runKernel + ); + if (result !== undefined) return result; + } else { + const result = await runKernel(); + if (result !== undefined) return result; + } + } catch (err) { + if (err instanceof KernelError) throw new Error(err.message); + throw err; + } + } if (isPluginToolName(name)) { const result = await executePluginTool(name, args, pluginExecCtx(ctx)); if (result !== undefined) return result; diff --git a/apps/bridge/src/services/ai-tools-registry.ts b/apps/bridge/src/services/ai-tools-registry.ts index 2811f91..fecbf0f 100644 --- a/apps/bridge/src/services/ai-tools-registry.ts +++ b/apps/bridge/src/services/ai-tools-registry.ts @@ -1,15 +1,22 @@ import type { AppDatabase } from "../db.js"; import { agentCodeAccess, getAgent } from "./agents/agents-db.js"; import { isOperatorTenantDb } from "./tenant-kind.js"; -import { PAGE_KINDS } from "./page-kinds.js"; import { pluginToolsAsAiDefs, isTradingDepartmentPluginTool } from "../plugins/plugin-tools.js"; import { filterToolsForChatMode, type IntelligenceChatMode, } from "./chat-mode.js"; +import { + genericObjectTypeToolDefs, + objectTypeAutoToolDefs, +} from "../kernel/auto-tools.js"; +import { pageKindJsonSchema } from "../kernel/kind-registry.js"; -/** JSON-schema enum for structure node `kind` (mirrors web page-registry). */ -const KIND_SCHEMA = { type: "string", enum: [...PAGE_KINDS] } as const; +/** JSON-schema for structure node `kind` — live Kind registry (plugins extend). */ +function kindSchema(): Record { + const base = pageKindJsonSchema(); + return { ...base, description: "Page renderer kind from the Kind registry" }; +} export type ToolMode = "auto" | "confirm"; @@ -492,6 +499,7 @@ export const AI_TOOL_REGISTRY: AiToolDef[] = [ "List the full platform structure tree (departments, divisions, pages).", mode: "auto", }, + ...genericObjectTypeToolDefs(), { name: "create_department", description: @@ -503,7 +511,7 @@ export const AI_TOOL_REGISTRY: AiToolDef[] = [ id: { type: "string", description: "lowercase slug (a-z 0-9 -)" }, label: { type: "string" }, icon: { type: "string", description: "lucide icon slug" }, - kind: { ...KIND_SCHEMA, description: "Page renderer kind (default placeholder)" }, + kind: { ...kindSchema(), description: "Page renderer kind (default placeholder)" }, }, required: ["id", "label", "icon"], }, @@ -521,7 +529,7 @@ export const AI_TOOL_REGISTRY: AiToolDef[] = [ label: { type: "string" }, icon: { type: "string", description: "lucide icon slug" }, rightSidebar: { type: "string", description: "Plugin sidebar slot id, or none to clear" }, - kind: { ...KIND_SCHEMA, description: "Page renderer kind (e.g. sierra-dashboard-group)" }, + kind: { ...kindSchema(), description: "Page renderer kind (e.g. sierra-dashboard-group)" }, segment: { type: "string", description: "URL segment (defaults to id)" }, }, required: ["departmentId", "id", "label", "icon"], @@ -541,7 +549,7 @@ export const AI_TOOL_REGISTRY: AiToolDef[] = [ label: { type: "string" }, icon: { type: "string", description: "lucide icon slug" }, segment: { type: "string", description: "URL segment (a-z 0-9 -, may be empty)" }, - kind: { ...KIND_SCHEMA, description: "Page renderer kind (e.g. sierra-playbooks-group)" }, + kind: { ...kindSchema(), description: "Page renderer kind (e.g. sierra-playbooks-group)" }, }, required: ["departmentId", "divisionId", "id", "label", "icon"], }, @@ -562,7 +570,7 @@ export const AI_TOOL_REGISTRY: AiToolDef[] = [ icon: { type: "string" }, segment: { type: "string" }, rightSidebar: { type: "string", description: "Plugin sidebar slot id, or none to clear" }, - kind: KIND_SCHEMA, + kind: kindSchema(), }, required: ["nodeType", "departmentId"], }, @@ -1596,6 +1604,12 @@ const TASK_TOOLS = new Set([ // (Intelligence only) but lives in this subset so the model can discover it. const PLATFORM_STRUCTURE_TOOLS = new Set([ "list_structure", + "list_object_types", + "list_records", + "get_record", + "create_record", + "update_record", + "delete_record", "create_department", "create_division", "create_page", @@ -1679,7 +1693,18 @@ export const PERSONAL_DIGITAL_YOU_TOOL_NAMES = [ ] as const; function allRegisteredTools(): AiToolDef[] { - return [...AI_TOOL_REGISTRY, ...pluginToolsAsAiDefs()]; + const coreNames = new Set(AI_TOOL_REGISTRY.map((t) => t.name)); + const autoOt = objectTypeAutoToolDefs(coreNames).map( + (t): AiToolDef => ({ + name: t.name, + description: t.description, + mode: t.mode, + parameters: t.parameters, + category: t.category ?? "platform", + write: t.write, + }) + ); + return [...AI_TOOL_REGISTRY, ...autoOt, ...pluginToolsAsAiDefs()]; } /** Default tool allowlist for Intelligence on personal (non-operator) tenants. */ diff --git a/apps/bridge/src/services/ai-workflows.ts b/apps/bridge/src/services/ai-workflows.ts index ff506d8..35e27ab 100644 --- a/apps/bridge/src/services/ai-workflows.ts +++ b/apps/bridge/src/services/ai-workflows.ts @@ -54,6 +54,7 @@ export interface WorkflowGraph { export interface AiWorkflow { id: string; + agent_id: string | null; name: string; config: WorkflowGraph; enabled: number; @@ -63,6 +64,7 @@ export interface AiWorkflow { interface WorkflowRow { id: string; + agent_id: string | null; name: string; config_json: string; enabled: number; @@ -80,6 +82,7 @@ function rowToWorkflow(row: WorkflowRow): AiWorkflow { } return { id: row.id, + agent_id: row.agent_id, name: row.name, config, enabled: row.enabled, @@ -91,7 +94,7 @@ function rowToWorkflow(row: WorkflowRow): AiWorkflow { export function listWorkflows(db: AppDatabase): AiWorkflow[] { const rows = db .prepare( - `SELECT id, name, config_json, enabled, created_at, updated_at + `SELECT id, agent_id, name, config_json, enabled, created_at, updated_at FROM ai_workflows ORDER BY updated_at DESC` ) .all() as WorkflowRow[]; @@ -101,7 +104,7 @@ export function listWorkflows(db: AppDatabase): AiWorkflow[] { export function getWorkflow(db: AppDatabase, id: string): AiWorkflow | null { const row = db .prepare( - `SELECT id, name, config_json, enabled, created_at, updated_at + `SELECT id, agent_id, name, config_json, enabled, created_at, updated_at FROM ai_workflows WHERE id = ?` ) .get(id) as WorkflowRow | undefined; @@ -110,20 +113,37 @@ export function getWorkflow(db: AppDatabase, id: string): AiWorkflow | null { export function createWorkflow( db: AppDatabase, - input: { name: string; config?: WorkflowGraph; enabled?: boolean } + input: { + name: string; + agentId?: string | null; + config?: WorkflowGraph; + enabled?: boolean; + } ): AiWorkflow { const id = uuidv4(); const config: WorkflowGraph = input.config ?? { nodes: [], edges: [] }; db.prepare( - `INSERT INTO ai_workflows (id, name, config_json, enabled) VALUES (?, ?, ?, ?)` - ).run(id, input.name, JSON.stringify(config), input.enabled === false ? 0 : 1); + `INSERT INTO ai_workflows (id, agent_id, name, config_json, enabled) + VALUES (?, ?, ?, ?, ?)` + ).run( + id, + input.agentId ?? null, + input.name, + JSON.stringify(config), + input.enabled === false ? 0 : 1 + ); return getWorkflow(db, id)!; } export function updateWorkflow( db: AppDatabase, id: string, - patch: { name?: string; config?: WorkflowGraph; enabled?: boolean } + patch: { + agentId?: string | null; + name?: string; + config?: WorkflowGraph; + enabled?: boolean; + } ): AiWorkflow | null { if (!getWorkflow(db, id)) return null; if (patch.name != null) @@ -131,6 +151,10 @@ export function updateWorkflow( String(patch.name), id ); + if (patch.agentId !== undefined) + db.prepare( + `UPDATE ai_workflows SET agent_id = ?, updated_at = datetime('now') WHERE id = ?` + ).run(patch.agentId, id); if (patch.config != null) db.prepare( `UPDATE ai_workflows SET config_json = ?, updated_at = datetime('now') WHERE id = ?` diff --git a/apps/bridge/src/services/capability-index.ts b/apps/bridge/src/services/capability-index.ts index a1a13a7..0d03d94 100644 --- a/apps/bridge/src/services/capability-index.ts +++ b/apps/bridge/src/services/capability-index.ts @@ -3,10 +3,11 @@ import type { AppDatabase } from "../db.js"; import { AI_TOOL_REGISTRY, isToolVisibleForAgent } from "./ai-tools-registry.js"; import { listAiSkills } from "./ai-skills.js"; import { getWorkflow } from "./ai-workflows.js"; +import { listObjectTypes } from "../kernel/registry.js"; import type { EmbeddingClient } from "./embeddings/embedding-client.js"; import { blobToVector } from "./embeddings/embedding-client.js"; -export type CapabilityKind = "tool" | "skill" | "workflow"; +export type CapabilityKind = "tool" | "skill" | "workflow" | "objectType"; export interface CapabilityDoc { kind: CapabilityKind; @@ -156,6 +157,65 @@ export function buildCapabilityDocs(db: AppDatabase, agentId: string): Capabilit }); } + // Plugin ObjectTypes are tenant-scoped; this legacy index only has a DB and + // agent id, so index built-ins here rather than leaking installed metadata. + for (const ot of listObjectTypes().filter((item) => !item.pluginId)) { + const operations = ot.operations ?? []; + const actions = ot.actions ?? []; + const pairs = [ + "list_object_types", + ...operations.map((operation) => + operation === "list" + ? "list_records" + : operation === "get" + ? "get_record" + : `${operation}_record` + ), + ...(actions.length ? ["run_record_action"] : []), + ]; + const whenToUse = `Working with ${ot.label} through its declared ObjectType operations and actions.`; + const text = [ + `[objectType] ${ot.name}: ${ot.label}`, + ot.description ?? "", + `Storage: ${ot.storage.kind}`, + `Fields: ${ot.fields.map((f) => f.name).join(", ")}`, + `Operations: ${operations.join(", ") || "none"}`, + actions.length + ? `Actions: ${actions + .map( + (action) => + `${action.name} (${action.execution ?? "sync"}, ${ + action.confirmation?.required || action.confirm + ? "confirm" + : "auto" + })` + ) + .join(", ")}` + : "", + `When to use: ${whenToUse}`, + `Pairs with: ${pairs.join(", ")}`, + ] + .filter(Boolean) + .join("\n"); + docs.push({ + kind: "objectType", + id: ot.name, + agentId, + name: ot.name, + description: ot.description ?? ot.label, + whenToUse, + pairsWith: pairs.join(", "), + text, + metadata: { + storage: ot.storage, + module: ot.module ?? null, + contractVersion: ot.contractVersion ?? 1, + operations, + actions, + }, + }); + } + for (const skill of listAiSkills(db, true, agentId)) { if (skill.status !== "active" || !skill.enabled) continue; const pairsWith = skill.tools.join(", "); diff --git a/apps/bridge/src/services/data-management-migration.ts b/apps/bridge/src/services/data-management-migration.ts index 52a9f8a..09b7c0a 100644 --- a/apps/bridge/src/services/data-management-migration.ts +++ b/apps/bridge/src/services/data-management-migration.ts @@ -204,7 +204,7 @@ export interface PlatformEventInput { export function insertEvent(db: Database.Database, evt: PlatformEventInput): void { const seq = nextEventSeq(db); db.prepare( - `INSERT INTO events (id, seq, type, actor_agent_id, subject, payload_json, dispatched) + `INSERT INTO events (id, seq, ts, type, actor_agent_id, subject, payload_json, dispatched) VALUES (?, ?, datetime('now'), ?, ?, ?, ?, 0)` ).run( evt.id, diff --git a/apps/bridge/src/services/engines/reconciler.ts b/apps/bridge/src/services/engines/reconciler.ts index 53b5ad3..22052dc 100644 --- a/apps/bridge/src/services/engines/reconciler.ts +++ b/apps/bridge/src/services/engines/reconciler.ts @@ -75,6 +75,15 @@ export class EngineReconciler { this.bus.on("structure.reordered", (p: { kind: string }) => this.emitStructureChanged(p.kind ?? "structure", "reordered", "") ); + for (const action of ["created", "updated", "deleted"] as const) { + this.bus.on( + `structure.node.${action}`, + (payload: { nodeId: string }) => { + this.registry.reconcileAll(); + this.emitStructureChanged("node", action, payload.nodeId); + } + ); + } } private onDepartmentDeleted(payload: StructureDepartmentEvent): void { diff --git a/apps/bridge/src/services/events-relay.ts b/apps/bridge/src/services/events-relay.ts index 030a8ed..a2b5eae 100644 --- a/apps/bridge/src/services/events-relay.ts +++ b/apps/bridge/src/services/events-relay.ts @@ -6,22 +6,72 @@ import { } from "./data-management-migration.js"; const RELAY_INTERVAL_MS = Number(process.env.EVENTS_RELAY_INTERVAL_MS ?? 500); +const CONSUMER_ID = Symbol.for("godmode.eventConsumerId"); + +export function durableEventConsumer unknown>( + id: string, + listener: T +): T { + Object.defineProperty(listener, CONSUMER_ID, { value: id }); + return listener; +} export function startEventsRelay(db: AppDatabase, bus: EventEmitter): () => void { + db.exec(` + CREATE TABLE IF NOT EXISTS event_consumer_receipts ( + event_id TEXT NOT NULL, + consumer_id TEXT NOT NULL, + processed_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (event_id, consumer_id) + ) + `); + const dispatch = ( + eventId: string, + channel: string, + value: unknown + ): boolean => { + const listeners = bus.listeners(channel); + for (const [index, listener] of listeners.entries()) { + const consumerId = String( + (listener as unknown as Record)[CONSUMER_ID] ?? + `${channel}:${listener.name || "anonymous"}:${index}` + ); + const complete = db + .prepare( + `SELECT 1 FROM event_consumer_receipts + WHERE event_id=? AND consumer_id=?` + ) + .get(eventId, consumerId); + if (complete) continue; + try { + listener(value); + db.prepare( + `INSERT OR IGNORE INTO event_consumer_receipts (event_id, consumer_id) + VALUES (?, ?)` + ).run(eventId, consumerId); + } catch (error) { + console.warn( + `[events] consumer ${consumerId} failed:`, + error instanceof Error ? error.message : error + ); + return false; + } + } + return true; + }; const tick = () => { try { const batch = listUndispatchedEvents(db, 200); if (batch.length === 0) return; const ids: string[] = []; for (const evt of batch) { - ids.push(evt.id); let payload: unknown = {}; try { payload = JSON.parse(evt.payload_json); } catch { /* ignore */ } - bus.emit("platform.event", { + const envelope = { id: evt.id, seq: evt.seq, ts: evt.ts, @@ -29,8 +79,13 @@ export function startEventsRelay(db: AppDatabase, bus: EventEmitter): () => void actorAgentId: evt.actor_agent_id, subject: evt.subject, payload, - }); - bus.emit(evt.type, payload); + }; + if ( + dispatch(evt.id, "platform.event", envelope) && + dispatch(evt.id, evt.type, payload) + ) { + ids.push(evt.id); + } } markEventsDispatched(db, ids); } catch (err) { diff --git a/apps/bridge/src/services/legacy-endpoint-telemetry.ts b/apps/bridge/src/services/legacy-endpoint-telemetry.ts new file mode 100644 index 0000000..d9db5fc --- /dev/null +++ b/apps/bridge/src/services/legacy-endpoint-telemetry.ts @@ -0,0 +1,130 @@ +import { createHash } from "node:crypto"; +import type { NextFunction, Request, Response } from "express"; +import type { CoreDatabase } from "../core-db.js"; + +const LEGACY_ENDPOINTS: Array<{ key: string; pattern: RegExp }> = [ + { key: "/api/structure", pattern: /^\/api\/structure(?:\/|$)/ }, + { key: "/api/nodes", pattern: /^\/api\/nodes(?:\/|$)/ }, + { key: "/api/departments", pattern: /^\/api\/departments(?:\/|$)/ }, + { key: "/api/divisions", pattern: /^\/api\/divisions(?:\/|$)/ }, + { key: "/api/pages", pattern: /^\/api\/pages(?:\/|$)/ }, + { key: "/api/ai", pattern: /^\/api\/ai(?:\/|$)/ }, + { key: "/api/admin", pattern: /^\/api\/admin(?:\/|$)/ }, + { key: "/api/bank", pattern: /^\/api\/bank(?:\/|$)/ }, + { key: "/api/connections", pattern: /^\/api\/connections(?:\/|$)/ }, + { key: "/api/dm", pattern: /^\/api\/dm(?:\/|$)/ }, + { key: "/api/federation", pattern: /^\/api\/federation(?:\/|$)/ }, + { key: "/api/financial", pattern: /^\/api\/financial(?:\/|$)/ }, + { key: "/api/hooks", pattern: /^\/api\/hooks(?:\/|$)/ }, + { key: "/api/inference", pattern: /^\/api\/inference(?:\/|$)/ }, + { key: "/api/integrations", pattern: /^\/api\/integrations(?:\/|$)/ }, + { key: "/api/marketplace", pattern: /^\/api\/marketplace(?:\/|$)/ }, + { key: "/api/network", pattern: /^\/api\/network(?:\/|$)/ }, + { key: "/api/notifications", pattern: /^\/api\/notifications(?:\/|$)/ }, + { key: "/api/plugins", pattern: /^\/api\/plugins(?:\/|$)/ }, + { key: "/api/shares", pattern: /^\/api\/shares(?:\/|$)/ }, + { key: "/api/support", pattern: /^\/api\/support(?:\/|$)/ }, + { key: "/api/user", pattern: /^\/api\/user(?:\/|$)/ }, + { key: "/api/wiki", pattern: /^\/api\/wiki(?:\/|$)/ }, +]; +const MUTATION_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); +const PROTOCOL_EXCEPTIONS = + /(?:\/stream|\/upload|\/download|\/typing)(?:\/|$)/; + +export function ensureLegacyEndpointTelemetry(core: CoreDatabase): void { + core.exec(` + CREATE TABLE IF NOT EXISTS legacy_endpoint_usage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + method TEXT NOT NULL, + endpoint_key TEXT NOT NULL, + route_path TEXT NOT NULL, + tenant_id TEXT, + caller_hash TEXT NOT NULL, + response_status INTEGER, + used_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS legacy_endpoint_usage_lookup + ON legacy_endpoint_usage(endpoint_key, used_at DESC); + `); +} + +function callerHash(req: Request): string { + const source = [ + req.user?.id ?? "", + req.get("user-agent") ?? "", + req.ip ?? "", + ].join("|"); + return createHash("sha256").update(source).digest("hex").slice(0, 20); +} + +export function legacyEndpointTelemetry(core: CoreDatabase) { + ensureLegacyEndpointTelemetry(core); + return (req: Request, res: Response, next: NextFunction): void => { + if ( + !MUTATION_METHODS.has(req.method) || + PROTOCOL_EXCEPTIONS.test(req.path) + ) { + next(); + return; + } + const match = LEGACY_ENDPOINTS.find((entry) => entry.pattern.test(req.path)); + if (!match) { + next(); + return; + } + res.setHeader("Deprecation", "true"); + res.setHeader("Sunset", "ObjectType kernel parity and zero-use validation"); + const result = core + .prepare( + `INSERT INTO legacy_endpoint_usage + (method, endpoint_key, route_path, tenant_id, caller_hash) + VALUES (?, ?, ?, ?, ?)` + ) + .run( + req.method, + match.key, + req.path, + req.tenantId ?? req.get("X-Tenant-Id") ?? null, + callerHash(req) + ); + res.on("finish", () => { + core + .prepare( + `UPDATE legacy_endpoint_usage SET response_status=? WHERE id=?` + ) + .run(res.statusCode, result.lastInsertRowid); + }); + next(); + }; +} + +export function legacyEndpointUsageReport( + core: CoreDatabase, + windowDays = 30 +): Array<{ + endpoint_key: string; + method: string; + hits: number; + callers: number; + last_used_at: string; +}> { + ensureLegacyEndpointTelemetry(core); + const days = Math.max(1, Math.floor(windowDays)); + return core + .prepare( + `SELECT endpoint_key, method, COUNT(*) AS hits, + COUNT(DISTINCT caller_hash) AS callers, + MAX(used_at) AS last_used_at + FROM legacy_endpoint_usage + WHERE used_at >= datetime('now', ?) + GROUP BY endpoint_key, method + ORDER BY hits DESC, endpoint_key` + ) + .all(`-${days} days`) as Array<{ + endpoint_key: string; + method: string; + hits: number; + callers: number; + last_used_at: string; + }>; +} diff --git a/apps/bridge/src/services/page-kinds.ts b/apps/bridge/src/services/page-kinds.ts index 3eb633a..c49cafc 100644 --- a/apps/bridge/src/services/page-kinds.ts +++ b/apps/bridge/src/services/page-kinds.ts @@ -14,6 +14,8 @@ export const PAGE_KINDS = [ "backtest", "placeholder", "home", + "record-list", + "record-form", "sierra-dashboard-group", "sierra-trading-plan-group", "sierra-playbooks-group", diff --git a/apps/bridge/src/services/personal-os-structure-manifest.ts b/apps/bridge/src/services/personal-os-structure-manifest.ts index 3aa2aa8..ac5403b 100644 --- a/apps/bridge/src/services/personal-os-structure-manifest.ts +++ b/apps/bridge/src/services/personal-os-structure-manifest.ts @@ -60,6 +60,7 @@ export const PERSONAL_BOOTSTRAP_SKILL_IDS = [ "platform-workspace", "platform-extension", "plugin-authoring", + "object-types", "platform-self-loop", "shadcn-ui", ] as const; diff --git a/apps/bridge/src/services/platform-scope.ts b/apps/bridge/src/services/platform-scope.ts index 027fcf9..fbc65e6 100644 --- a/apps/bridge/src/services/platform-scope.ts +++ b/apps/bridge/src/services/platform-scope.ts @@ -57,7 +57,12 @@ const ACTION_MIN_ROLE: Record = { }; /** Actions that have no scope (platform-wide) and require the Intelligence superuser. */ -const GLOBAL_ACTIONS = new Set(["create_department", "create_agent"]); +const GLOBAL_ACTIONS = new Set([ + "create_department", + "create_agent", + "create_record", + "create_structure_node", +]); function scopeLabel(scope: PlatformScope): string { if (scope.divisionId && scope.pageId) { diff --git a/apps/bridge/src/services/structure.ts b/apps/bridge/src/services/structure.ts index 82eaf63..44b442b 100644 --- a/apps/bridge/src/services/structure.ts +++ b/apps/bridge/src/services/structure.ts @@ -14,6 +14,7 @@ export interface StructureNode { segment: string; path: string; kind: string; + objectType: string | null; rightSidebar: string | null; agentId: string | null; builtIn: boolean; @@ -77,6 +78,7 @@ interface DbNode { icon: string; segment: string; kind: string; + object_type: string | null; right_sidebar: string | null; agent_id: string | null; built_in: number; @@ -143,6 +145,7 @@ function buildTree(rows: DbNode[]): StructureNode[] { segment: row.segment, path: computePath(row, byId), kind: row.kind, + objectType: row.object_type, rightSidebar: parseRightSidebar(row.right_sidebar), agentId: row.agent_id, builtIn: row.built_in === 1, @@ -169,7 +172,7 @@ export function flattenStructureNodes(nodes: StructureNode[]): StructureNode[] { export function readStructure(db: AppDatabase): StructureTree { const rows = db .prepare( - `SELECT id, parent_id, label, icon, segment, kind, right_sidebar, agent_id, built_in, sort_order, tabs_json + `SELECT id, parent_id, label, icon, segment, kind, object_type, right_sidebar, agent_id, built_in, sort_order, tabs_json FROM structure_nodes ORDER BY sort_order, label` ) .all() as DbNode[]; @@ -219,6 +222,7 @@ export function createNode( icon: string; segment?: string; kind?: string; + objectType?: string | null; rightSidebar?: string | null; } ): StructureNode { @@ -262,8 +266,8 @@ export function createNode( db.prepare( `INSERT INTO structure_nodes - (id, parent_id, label, icon, segment, kind, right_sidebar, agent_id, built_in, sort_order) - VALUES (?, ?, ?, ?, ?, ?, ?, NULL, 0, ?)` + (id, parent_id, label, icon, segment, kind, object_type, right_sidebar, agent_id, built_in, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, 0, ?)` ).run( nodeId, parentId, @@ -271,6 +275,7 @@ export function createNode( input.icon, segment, kind, + input.objectType?.trim() || null, rightSidebar, sortOrder ); @@ -290,6 +295,7 @@ export function updateNode( icon?: string; segment?: string; kind?: string; + objectType?: string | null; rightSidebar?: string | null; parentId?: string | null; } @@ -362,6 +368,10 @@ export function updateNode( sets.push("kind=?"); vals.push(patch.kind.trim() || "placeholder"); } + if (patch.objectType !== undefined) { + sets.push("object_type=?"); + vals.push(patch.objectType?.trim() || null); + } if (patch.rightSidebar !== undefined) { sets.push("right_sidebar=?"); vals.push(normalizeRightSidebarInput(patch.rightSidebar)); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 1274420..ce746ac 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -22,6 +22,8 @@ import Notifications from "./pages/Notifications"; import Support from "./pages/Support"; import Wiki from "./pages/Wiki"; import WikiPage from "./pages/WikiPage"; +import RecordListPage from "./pages/records/RecordListPage"; +import RecordFormPage from "./pages/records/RecordFormPage"; import { Toaster } from "@/components/ui/sonner"; import { TooltipProvider } from "@/components/ui/tooltip"; import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; @@ -39,6 +41,7 @@ import { NOTIFICATIONS_PATH, SUPPORT_PATH, WIKI_PATH, + RECORDS_PATH, MARKETPLACE_PATH, CONTACTS_PATH, SHARED_PATH, @@ -246,6 +249,12 @@ function AppRoutes({ } /> } /> } /> + } /> + } /> + } + /> } /> } /> } /> diff --git a/apps/web/src/__tests__/structure-record-api.test.ts b/apps/web/src/__tests__/structure-record-api.test.ts new file mode 100644 index 0000000..c0504c1 --- /dev/null +++ b/apps/web/src/__tests__/structure-record-api.test.ts @@ -0,0 +1,105 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + createStructureNode, + deleteStructureNode, + reorderStructureNodes, + setNodeAgent, + updateStructureNode, +} from "../api"; + +describe("Structure ObjectType client", () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + localStorage.clear(); + fetchMock.mockReset(); + vi.stubGlobal("fetch", fetchMock); + }); + + it("creates Structure pages through the generic Record API", async () => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + id: "notes", + data: { + id: "notes", + parent_id: null, + label: "Notes", + icon: "folder", + segment: "notes", + kind: "records", + object_type: "WikiPage", + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ) + ); + const node = await createStructureNode({ + id: "notes", + label: "Notes", + kind: "records", + objectType: "WikiPage", + }); + expect(fetchMock).toHaveBeenCalledWith( + "/api/records/StructureNode", + expect.objectContaining({ method: "POST" }) + ); + const options = fetchMock.mock.calls[0][1] as RequestInit; + expect(JSON.parse(String(options.body))).toMatchObject({ + data: { id: "notes", object_type: "WikiPage" }, + }); + expect(node).toMatchObject({ id: "notes", objectType: "WikiPage" }); + }); + + it("updates and deletes through the generic Record API", async () => { + fetchMock + .mockResolvedValueOnce( + new Response(JSON.stringify({ id: "notes", data: {} }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + await updateStructureNode("notes", { label: "Knowledge" }); + await deleteStructureNode("notes"); + expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([ + "/api/records/StructureNode/notes", + "/api/records/StructureNode/notes", + ]); + expect(fetchMock.mock.calls.map((call) => call[1]?.method)).toEqual([ + "PUT", + "DELETE", + ]); + }); + + it("sends flat action input for agent assignment and reorder", async () => { + fetchMock.mockImplementation(() => + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + + await setNodeAgent("notes", "agent-1"); + await reorderStructureNodes(null, ["notes", "tasks"]); + + expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([ + "/api/records/StructureNode/notes/actions/set_agent", + "/api/records/StructureNode/actions/reorder", + ]); + expect( + fetchMock.mock.calls.map((call) => + JSON.parse(String((call[1] as RequestInit).body)) + ) + ).toEqual([ + { agent_id: "agent-1" }, + { parent_id: null, ordered_ids: ["notes", "tasks"] }, + ]); + }); +}); diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 6757210..5a663ee 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -31,7 +31,253 @@ export function setActiveTenantId(tenantId: string): void { writeTenantId(tenantId); } +export class ApiError extends Error { + status: number; + code?: string; + details?: unknown; + retryable: boolean; + + constructor( + status: number, + message: string, + opts: { code?: string; details?: unknown; retryable?: boolean } = {} + ) { + super(message); + this.status = status; + this.code = opts.code; + this.details = opts.details; + this.retryable = opts.retryable ?? false; + } +} + +interface ApiRewrite { + path: string; + options: RequestInit; + transform?: (payload: unknown) => unknown; +} + +function jsonBody(options?: RequestInit): Record { + if (typeof options?.body !== "string") return {}; + try { + const value = JSON.parse(options.body); + return value && typeof value === "object" + ? (value as Record) + : {}; + } catch { + return {}; + } +} + +function structureNodeData( + body: Record, + parentId?: string | null +): Record { + return { + id: body.id, + parent_id: + parentId !== undefined + ? parentId + : body.parentId === undefined + ? undefined + : body.parentId, + label: body.label, + icon: body.icon, + segment: body.segment, + kind: body.kind ?? body.pageKind, + object_type: body.objectType, + right_sidebar: body.rightSidebar, + }; +} + +function kernelizeStructureMutation( + path: string, + options?: RequestInit +): ApiRewrite { + const method = (options?.method ?? "GET").toUpperCase(); + if (!["POST", "PUT", "DELETE"].includes(method)) { + return { path, options: options ?? {} }; + } + const body = jsonBody(options); + const request = (nextPath: string, nextMethod: string, data?: unknown) => ({ + path: nextPath, + options: { + ...options, + method: nextMethod, + body: data === undefined ? undefined : JSON.stringify(data), + }, + }); + let match: RegExpMatchArray | null; + + if (method === "POST" && path === "/departments") { + return { + ...request("/records/StructureNode", "POST", { + data: structureNodeData(body, null), + }), + transform: (payload) => ({ + id: body.id, + label: body.label, + icon: body.icon, + ...((payload as { data?: object })?.data ?? {}), + }), + }; + } + if ((match = path.match(/^\/departments\/([^/]+)$/))) { + const id = encodeURIComponent(match[1]!); + if (method === "DELETE") { + return request(`/records/StructureNode/${id}`, "DELETE"); + } + return { + ...request(`/records/StructureNode/${id}`, "PUT", { + data: structureNodeData(body), + }), + transform: () => ({ ok: true }), + }; + } + if ( + method === "POST" && + (match = path.match(/^\/departments\/([^/]+)\/divisions$/)) + ) { + const parent = decodeURIComponent(match[1]!); + return { + ...request("/records/StructureNode", "POST", { + data: structureNodeData(body, parent), + }), + transform: (payload) => ({ + id: body.id, + label: body.label, + icon: body.icon, + ...((payload as { data?: object })?.data ?? {}), + }), + }; + } + if ((match = path.match(/^\/divisions\/([^/]+)\/([^/]+)$/))) { + const id = encodeURIComponent( + `${decodeURIComponent(match[1]!)}-${decodeURIComponent(match[2]!)}` + ); + if (method === "DELETE") { + return request(`/records/StructureNode/${id}`, "DELETE"); + } + return { + ...request(`/records/StructureNode/${id}`, "PUT", { + data: structureNodeData(body), + }), + transform: () => ({ ok: true }), + }; + } + if ( + method === "POST" && + (match = path.match(/^\/divisions\/([^/]+)\/([^/]+)\/pages$/)) + ) { + const parent = `${decodeURIComponent(match[1]!)}-${decodeURIComponent( + match[2]! + )}`; + return { + ...request("/records/StructureNode", "POST", { + data: structureNodeData(body, parent), + }), + transform: (payload) => ({ + id: body.id, + ...((payload as { data?: object })?.data ?? {}), + }), + }; + } + if ((match = path.match(/^\/pages\/([^/]+)\/([^/]+)\/([^/]+)$/))) { + const id = encodeURIComponent( + `${decodeURIComponent(match[1]!)}-${decodeURIComponent( + match[2]! + )}-${decodeURIComponent(match[3]!)}` + ); + if (method === "DELETE") { + return request(`/records/StructureNode/${id}`, "DELETE"); + } + return { + ...request(`/records/StructureNode/${id}`, "PUT", { + data: structureNodeData(body), + }), + transform: () => ({ ok: true }), + }; + } + if (method === "POST" && path === "/nodes") { + return { + ...request("/records/StructureNode", "POST", { + data: structureNodeData(body), + }), + transform: (payload) => { + const row = payload as { id?: string; data?: Record }; + return { + id: row.id, + parentId: row.data?.parent_id ?? null, + label: row.data?.label, + icon: row.data?.icon, + segment: row.data?.segment, + kind: row.data?.kind, + objectType: row.data?.object_type ?? null, + rightSidebar: row.data?.right_sidebar ?? null, + agentId: row.data?.agent_id ?? null, + builtIn: row.data?.built_in, + sortOrder: row.data?.sort_order, + tabs: row.data?.tabs_json, + path: row.data?.path, + }; + }, + }; + } + if ((match = path.match(/^\/nodes\/([^/]+)$/))) { + const id = encodeURIComponent(decodeURIComponent(match[1]!)); + if (method === "DELETE") { + return request(`/records/StructureNode/${id}`, "DELETE"); + } + return { + ...request(`/records/StructureNode/${id}`, "PUT", { + data: structureNodeData(body), + }), + transform: () => ({ ok: true }), + }; + } + if ((match = path.match(/^\/nodes\/([^/]+)\/agent$/))) { + const id = encodeURIComponent(decodeURIComponent(match[1]!)); + return { + ...request( + `/records/StructureNode/${id}/actions/set_agent`, + "POST", + { + agent_id: method === "DELETE" ? null : (body.agentId ?? null), + } + ), + transform: () => ({ ok: true }), + }; + } + if (method === "POST" && path === "/structure/reorder") { + let parentId = body.parentId; + let orderedIds = Array.isArray(body.orderedIds) ? body.orderedIds : []; + if (parentId === undefined) { + parentId = + body.kind === "department" + ? null + : body.kind === "division" + ? body.departmentId + : `${body.departmentId}-${body.divisionId}`; + if (typeof parentId === "string" && body.kind !== "department") { + orderedIds = orderedIds.map((id) => + String(id).startsWith(`${parentId}-`) ? id : `${parentId}-${id}` + ); + } + } + return { + ...request("/records/StructureNode/actions/reorder", "POST", { + parent_id: parentId, + ordered_ids: orderedIds, + }), + transform: () => ({ ok: true }), + }; + } + return { path, options: options ?? {} }; +} + export async function api(path: string, options?: RequestInit): Promise { + const rewritten = kernelizeStructureMutation(path, options); + path = rewritten.path; + options = rewritten.options; const tenantId = getActiveTenantId(); const { headers: callerHeaders, ...rest } = options ?? {}; const headers = new Headers(callerHeaders); @@ -56,10 +302,30 @@ export async function api(path: string, options?: RequestInit): Promise { }); if (!res.ok) { if (res.status === 401) clearSessionToken(); - const err = await res.json().catch(() => ({ error: res.statusText })); - throw new Error((err as { error?: string }).error ?? res.statusText); + const payload = await res.json().catch(() => ({ error: res.statusText })); + const error = (payload as { error?: unknown }).error; + if (error && typeof error === "object") { + const structured = error as { + code?: string; + message?: string; + details?: unknown; + retryable?: boolean; + }; + throw new ApiError(res.status, structured.message ?? res.statusText, { + code: structured.code, + details: structured.details, + retryable: structured.retryable, + }); + } + throw new ApiError( + res.status, + typeof error === "string" ? error : res.statusText + ); } - return res.json() as Promise; + const payload = await res.json(); + return (rewritten.transform + ? rewritten.transform(payload) + : payload) as T; } export interface SessionStatus { @@ -88,6 +354,7 @@ export interface PlaybookSignalRow { signalId: string; value: number; kind: string; + objectType: string | null; chart: number | null; ts: string | null; metadataJson: string | null; @@ -2074,6 +2341,7 @@ export interface StructureNodeDto { segment: string; path: string; kind: string; + objectType: string | null; rightSidebar: string | null; agentId: string | null; builtIn: boolean; @@ -2089,11 +2357,41 @@ export async function createStructureNode(body: { segment?: string; kind?: string; rightSidebar?: string | null; + objectType?: string | null; }) { - return api("/nodes", { - method: "POST", - body: JSON.stringify(body), - }); + const row = await api<{ + id: string; + data: Record; + }>("/records/StructureNode", { + method: "POST", + body: JSON.stringify({ + data: { + id: body.id, + parent_id: body.parentId ?? null, + label: body.label, + icon: body.icon ?? "folder", + segment: body.segment, + kind: body.kind, + right_sidebar: body.rightSidebar, + object_type: body.objectType, + }, + }), + }); + return { + id: row.id, + parentId: (row.data.parent_id as string | null) ?? null, + label: String(row.data.label ?? ""), + icon: String(row.data.icon ?? "folder"), + segment: String(row.data.segment ?? ""), + path: String(row.data.path ?? ""), + kind: String(row.data.kind ?? "placeholder"), + objectType: (row.data.object_type as string | null) ?? null, + rightSidebar: (row.data.right_sidebar as string | null) ?? null, + agentId: (row.data.agent_id as string | null) ?? null, + builtIn: Boolean(row.data.built_in), + sortOrder: Number(row.data.sort_order ?? 0), + children: [], + } satisfies StructureNodeDto; } export async function updateStructureNode( @@ -2105,16 +2403,34 @@ export async function updateStructureNode( kind?: string; rightSidebar?: string | null; parentId?: string | null; + objectType?: string | null; } ) { - return api<{ ok: boolean }>(`/nodes/${id}`, { + return api(`/records/StructureNode/${encodeURIComponent(id)}`, { method: "PUT", - body: JSON.stringify(patch), + body: JSON.stringify({ + data: { + ...(patch.label !== undefined ? { label: patch.label } : {}), + ...(patch.icon !== undefined ? { icon: patch.icon } : {}), + ...(patch.segment !== undefined ? { segment: patch.segment } : {}), + ...(patch.kind !== undefined ? { kind: patch.kind } : {}), + ...(patch.rightSidebar !== undefined + ? { right_sidebar: patch.rightSidebar } + : {}), + ...(patch.parentId !== undefined ? { parent_id: patch.parentId } : {}), + ...(patch.objectType !== undefined + ? { object_type: patch.objectType } + : {}), + }, + }), }); } export async function deleteStructureNode(id: string) { - return api<{ ok: boolean }>(`/nodes/${id}`, { method: "DELETE" }); + return api<{ ok: boolean }>( + `/records/StructureNode/${encodeURIComponent(id)}`, + { method: "DELETE" } + ); } /** Move a node under a new parent (or to top-level with `null`). */ diff --git a/apps/web/src/lib/navigation.ts b/apps/web/src/lib/navigation.ts index 244c1d4..e0d4c0a 100644 --- a/apps/web/src/lib/navigation.ts +++ b/apps/web/src/lib/navigation.ts @@ -11,6 +11,7 @@ export interface StructureNode { segment: string; path: string; kind: string; + objectType: string | null; rightSidebar: RightSidebarKind | null; agentId: string | null; builtIn: boolean; @@ -29,6 +30,7 @@ export interface PageNode { segment: string; /** Renderer kind (`dashboard`, `routines`, ..., `placeholder`) */ kind: string; + objectType: string | null; builtIn: boolean; sortOrder: number; } @@ -80,6 +82,7 @@ export const TASKS_PATH = "/tasks"; export const NOTIFICATIONS_PATH = "/notifications"; export const SUPPORT_PATH = "/support"; export const WIKI_PATH = "/wiki"; +export const RECORDS_PATH = "/records"; /** * Routes that render standalone (no department/division chrome such as the @@ -99,6 +102,7 @@ export function isChromelessPath(pathname: string): boolean { pathname.startsWith(NOTIFICATIONS_PATH) || pathname.startsWith(SUPPORT_PATH) || pathname.startsWith(WIKI_PATH) || + pathname.startsWith(RECORDS_PATH) || pathname.startsWith(STRUCTURE_PATH) || pathname.startsWith(CONTACTS_PATH) || pathname.startsWith(MARKETPLACE_PATH) @@ -115,6 +119,7 @@ export function chromelessHeaderSegments(pathname: string): string[] | null { if (norm.startsWith(HOME_PATH)) return ["Home"]; if (norm.startsWith(WIKI_PATH)) return ["Wiki"]; + if (norm.startsWith(RECORDS_PATH)) return ["Records"]; if (norm.startsWith(CALENDAR_PATH)) return ["Calendar"]; if (norm.startsWith(TASKS_PATH)) return ["Tasks"]; if (norm.startsWith(BANK_PATH) || norm.startsWith(HOLDINGS_PATH)) return ["Bank"]; diff --git a/apps/web/src/lib/object-types-api.ts b/apps/web/src/lib/object-types-api.ts new file mode 100644 index 0000000..d613718 --- /dev/null +++ b/apps/web/src/lib/object-types-api.ts @@ -0,0 +1,159 @@ +import { api, ApiError } from "@/api"; + +export interface FieldDefClient { + name: string; + label: string; + fieldType: string; + required?: boolean; + options?: string[]; + inList?: boolean; + inForm?: boolean; + description?: string; +} + +export interface ObjectTypeClient { + name: string; + label: string; + labelPlural?: string; + description?: string; + fields: FieldDefClient[]; + storage: { kind: string; adapterId?: string; tableName?: string }; + operations?: Array<"list" | "get" | "create" | "update" | "delete">; + contractVersion?: number; + actions?: Array<{ + name: string; + label: string; + description?: string; + target?: "record" | "collection" | "bulk"; + effect?: "read" | "write" | "destructive" | "external"; + execution?: "sync" | "async"; + confirm?: boolean; + confirmation?: { required: boolean; ttlSeconds?: number }; + inputSchema?: Record; + outputSchema?: Record; + }>; +} + +export interface RecordRowClient { + id: string; + objectType: string; + data: Record; +} + +export async function fetchObjectTypes(): Promise { + const res = await api<{ objectTypes: ObjectTypeClient[] }>("/object-types"); + return res.objectTypes; +} + +export async function fetchObjectType(name: string): Promise { + return api(`/object-types/${encodeURIComponent(name)}`); +} + +export async function fetchRecords( + objectType: string, + opts?: { parentId?: string | null; limit?: number } +): Promise<{ records: RecordRowClient[]; total: number }> { + const q = new URLSearchParams(); + if (opts && "parentId" in (opts ?? {})) { + q.set( + "parent_id", + opts?.parentId == null ? "null" : String(opts.parentId) + ); + } + if (opts?.limit != null) q.set("limit", String(opts.limit)); + const qs = q.toString(); + return api( + `/records/${encodeURIComponent(objectType)}${qs ? `?${qs}` : ""}` + ); +} + +export async function fetchRecord( + objectType: string, + id: string +): Promise { + return api( + `/records/${encodeURIComponent(objectType)}/${encodeURIComponent(id)}` + ); +} + +export async function createRecordApi( + objectType: string, + data: Record +): Promise { + return api(`/records/${encodeURIComponent(objectType)}`, { + method: "POST", + body: JSON.stringify({ data }), + }); +} + +export async function updateRecordApi( + objectType: string, + id: string, + data: Record +): Promise { + return api( + `/records/${encodeURIComponent(objectType)}/${encodeURIComponent(id)}`, + { + method: "PUT", + body: JSON.stringify({ data }), + } + ); +} + +export async function deleteRecordApi( + objectType: string, + id: string +): Promise { + await api( + `/records/${encodeURIComponent(objectType)}/${encodeURIComponent(id)}`, + { method: "DELETE" } + ); +} + +export async function runRecordActionApi( + objectType: string, + action: string, + input: Record, + opts?: { + id?: string; + confirmationId?: string; + idempotencyKey?: string; + confirmed?: boolean; + } +): Promise { + const target = opts?.id + ? `/records/${encodeURIComponent(objectType)}/${encodeURIComponent(opts.id)}/actions/${encodeURIComponent(action)}` + : `/records/${encodeURIComponent(objectType)}/actions/${encodeURIComponent(action)}`; + const headers: Record = {}; + if (opts?.confirmationId) { + headers["X-Kernel-Confirmation"] = opts.confirmationId; + } + if (opts?.idempotencyKey) { + headers["Idempotency-Key"] = opts.idempotencyKey; + } + try { + const response = await api<{ result: unknown }>(target, { + method: "POST", + headers, + body: JSON.stringify(input), + }); + return response.result; + } catch (error) { + const confirmationId = + error instanceof ApiError && + error.code === "KERNEL_CONFIRMATION_REQUIRED" && + error.details && + typeof error.details === "object" && + "confirmationId" in error.details + ? String( + (error.details as { confirmationId: unknown }).confirmationId + ) + : null; + if (!opts?.confirmed || !confirmationId) throw error; + return runRecordActionApi(objectType, action, input, { + ...opts, + confirmationId, + confirmed: false, + }); + } +} diff --git a/apps/web/src/lib/page-registry.tsx b/apps/web/src/lib/page-registry.tsx index 4732839..0f44be6 100644 --- a/apps/web/src/lib/page-registry.tsx +++ b/apps/web/src/lib/page-registry.tsx @@ -2,6 +2,8 @@ import type { ReactElement } from "react"; import Home from "@/pages/Home"; import Placeholder from "@/pages/Placeholder"; import TenantCustomPage from "@/pages/TenantCustomPage"; +import RecordListPage from "@/pages/records/RecordListPage"; +import RecordFormPage from "@/pages/records/RecordFormPage"; import { webPluginRuntime } from "@/plugins/runtime"; /** Core page kinds — plugin domains register additional kinds at runtime. */ @@ -9,6 +11,8 @@ export const CORE_PAGE_KINDS = [ "placeholder", "home", "custom", + "record-list", + "record-form", ] as const; export type CorePageKind = (typeof CORE_PAGE_KINDS)[number]; @@ -17,6 +21,8 @@ const CORE_RENDERERS: Record ReactElement> = { placeholder: () => , home: () => , custom: () => , + "record-list": () => , + "record-form": () => , }; export function pageElementFor(kind: string): ReactElement { diff --git a/apps/web/src/lib/structure-adapters.ts b/apps/web/src/lib/structure-adapters.ts index 3455f83..9a71f26 100644 --- a/apps/web/src/lib/structure-adapters.ts +++ b/apps/web/src/lib/structure-adapters.ts @@ -42,6 +42,7 @@ function divisionFromNode(node: StructureNode, dept: StructureNode): DivisionNod icon: node.icon, segment: "", kind: node.kind, + objectType: node.objectType, builtIn: node.builtIn, sortOrder: 0, }; @@ -51,6 +52,7 @@ function divisionFromNode(node: StructureNode, dept: StructureNode): DivisionNod icon: child.icon, segment: child.segment, kind: child.kind, + objectType: child.objectType, builtIn: child.builtIn, sortOrder: child.sortOrder ?? idx + 1, })); diff --git a/apps/web/src/lib/structure-context.tsx b/apps/web/src/lib/structure-context.tsx index b168363..56eabc1 100644 --- a/apps/web/src/lib/structure-context.tsx +++ b/apps/web/src/lib/structure-context.tsx @@ -12,50 +12,51 @@ import type { GroupTabDef } from "./group-tab-definitions"; import type { DepartmentNode, DivisionNode, PageNode, StructureNode } from "./navigation"; import { nodesToLegacyDepartments } from "./structure-adapters"; -interface StructureApiNode { +interface StructureRecord { id: string; - parentId: string | null; - label: string; - icon: string; - segment: string; - path: string; - kind: string; - rightSidebar: string | null; - agentId: string | null; - builtIn: boolean; - sortOrder: number; - tabs: GroupTabDef[] | null; - children: StructureApiNode[]; + data: Record; } -interface StructureApiResponse { - nodes: StructureApiNode[]; +interface StructureRecordsResponse { + records: StructureRecord[]; } -function adaptNodes(apiRes: StructureApiResponse): { +function adaptRecords(apiRes: StructureRecordsResponse): { nodes: StructureNode[]; departments: DepartmentNode[]; } { - const nodes: StructureNode[] = apiRes.nodes.map(mapNode); - return { nodes, departments: nodesToLegacyDepartments(nodes) }; -} - -function mapNode(n: StructureApiNode): StructureNode { - return { - id: n.id, - parentId: n.parentId, - label: n.label, - icon: n.icon, - segment: n.segment, - path: n.path, - kind: n.kind, - rightSidebar: n.rightSidebar, - agentId: n.agentId, - builtIn: n.builtIn, - sortOrder: n.sortOrder, - tabs: n.tabs ?? null, - children: n.children.map(mapNode), + const byId = new Map(); + for (const row of apiRes.records) { + const data = row.data; + byId.set(row.id, { + id: row.id, + parentId: (data.parent_id as string | null) ?? null, + label: String(data.label ?? ""), + icon: String(data.icon ?? "folder"), + segment: String(data.segment ?? ""), + path: String(data.path ?? ""), + kind: String(data.kind ?? "placeholder"), + objectType: (data.object_type as string | null) ?? null, + rightSidebar: (data.right_sidebar as string | null) ?? null, + agentId: (data.agent_id as string | null) ?? null, + builtIn: Boolean(data.built_in), + sortOrder: Number(data.sort_order ?? 0), + tabs: (data.tabs_json as GroupTabDef[] | null) ?? null, + children: [], + }); + } + const roots: StructureNode[] = []; + for (const node of byId.values()) { + const parent = node.parentId ? byId.get(node.parentId) : undefined; + if (parent) parent.children.push(node); + else roots.push(node); + } + const sort = (items: StructureNode[]) => { + items.sort((a, b) => a.sortOrder - b.sortOrder || a.label.localeCompare(b.label)); + for (const item of items) sort(item.children); }; + sort(roots); + return { nodes: roots, departments: nodesToLegacyDepartments(roots) }; } interface StructureCtxValue { @@ -82,8 +83,10 @@ export function StructureProvider({ children }: { children: ReactNode }) { const reload = useCallback(async () => { try { - const res = await api("/structure"); - const adapted = adaptNodes(res); + const res = await api( + "/records/StructureNode?limit=500" + ); + const adapted = adaptRecords(res); setNodes(adapted.nodes); setDepartments(adapted.departments); setError(null); diff --git a/apps/web/src/pages/Structure.tsx b/apps/web/src/pages/Structure.tsx index 4ccf670..e534e0b 100644 --- a/apps/web/src/pages/Structure.tsx +++ b/apps/web/src/pages/Structure.tsx @@ -11,6 +11,7 @@ import { import { toast } from "sonner"; import { api, + createStructureNode, fetchAiAgents, fetchAgentAssignments, setAgentAssignment, @@ -644,10 +645,7 @@ function CreateDepartmentDialog({ const submit = async () => { setBusy(true); try { - await api("/departments", { - method: "POST", - body: JSON.stringify({ id, label, icon }), - }); + await createStructureNode({ id, label, icon }); toast.success("Department created"); reset(); setOpen(false); diff --git a/apps/web/src/pages/records/RecordFormPage.tsx b/apps/web/src/pages/records/RecordFormPage.tsx new file mode 100644 index 0000000..6c4ceaf --- /dev/null +++ b/apps/web/src/pages/records/RecordFormPage.tsx @@ -0,0 +1,398 @@ +import { useEffect, useMemo, useState } from "react"; +import { useLocation, useNavigate, useParams } from "react-router-dom"; +import { Page, PageHeader } from "@/components/PageHeader"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + createRecordApi, + deleteRecordApi, + fetchObjectType, + fetchRecord, + runRecordActionApi, + updateRecordApi, + type FieldDefClient, + type ObjectTypeClient, +} from "@/lib/object-types-api"; +import { useStructure } from "@/lib/structure-context"; +import type { DepartmentNode, PageNode } from "@/lib/navigation"; + +function findPageContext( + pathname: string, + departments: DepartmentNode[] +): PageNode | undefined { + for (const d of departments) { + for (const div of d.divisions) { + for (const p of div.pages) { + const full = + p.segment === "" + ? div.basePath + : `${div.basePath.replace(/\/$/, "")}/${p.segment}`; + if (pathname === full) return p; + } + } + } + return undefined; +} + +function formFields(def: ObjectTypeClient): FieldDefClient[] { + return def.fields.filter( + (f) => f.fieldType !== "ReadOnly" && f.inForm !== false + ); +} + +export function RecordFormPage({ + objectType: objectTypeProp, + recordId: recordIdProp, +}: { + objectType?: string; + recordId?: string; +}) { + const params = useParams(); + const navigate = useNavigate(); + const { pathname } = useLocation(); + const { departments } = useStructure(); + + const objectType = useMemo(() => { + if (objectTypeProp) return objectTypeProp; + if (params.objectType) return params.objectType; + const page = findPageContext(pathname, departments); + if (page?.kind === "record-form" || page?.kind === "record-list") { + return page.objectType || undefined; + } + return undefined; + }, [objectTypeProp, params.objectType, pathname, departments]); + + const recordId = recordIdProp ?? params.recordId; + const isNew = !recordId || recordId === "new"; + + const [def, setDef] = useState(null); + const [values, setValues] = useState>({}); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + const [runningAction, setRunningAction] = useState(null); + const [actionValues, setActionValues] = useState< + Record> + >({}); + const [status, setStatus] = useState(null); + const canSave = + !!def && + (!def.operations || + def.operations.includes(isNew ? "create" : "update")); + + useEffect(() => { + if (!objectType) return; + let cancelled = false; + (async () => { + try { + const ot = await fetchObjectType(objectType); + if (cancelled) return; + setDef(ot); + const initial: Record = {}; + for (const f of formFields(ot)) { + initial[f.name] = ""; + } + if (!isNew && recordId) { + if (ot.operations && !ot.operations.includes("get")) { + throw new Error(`Reading ${ot.label} records is disabled`); + } + const row = await fetchRecord(objectType, recordId); + if (cancelled) return; + for (const f of formFields(ot)) { + const v = row.data[f.name]; + initial[f.name] = + v == null ? "" : typeof v === "string" ? v : JSON.stringify(v); + } + } + setValues(initial); + setError(null); + } catch (err) { + if (!cancelled) setError(err instanceof Error ? err.message : String(err)); + } + })(); + return () => { + cancelled = true; + }; + }, [objectType, recordId, isNew]); + + async function onSave() { + if (!objectType || !def) return; + setSaving(true); + setError(null); + try { + const data: Record = {}; + for (const f of formFields(def)) { + const raw = values[f.name] ?? ""; + if (raw === "" && !f.required) { + if (!isNew) data[f.name] = null; + continue; + } + if (f.fieldType === "Int") data[f.name] = Number.parseInt(raw, 10); + else if (f.fieldType === "Float") data[f.name] = Number.parseFloat(raw); + else if (f.fieldType === "Check") + data[f.name] = raw === "true" || raw === "1"; + else if (f.fieldType === "JSON") { + try { + data[f.name] = JSON.parse(raw); + } catch { + data[f.name] = raw; + } + } else data[f.name] = raw; + } + if (isNew) { + const created = await createRecordApi(objectType, data); + navigate( + `/records/${encodeURIComponent(objectType)}/${encodeURIComponent(created.id)}` + ); + } else if (recordId) { + await updateRecordApi(objectType, recordId, data); + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setSaving(false); + } + } + + async function refreshRecord() { + if (!objectType || !recordId || !def || isNew) return; + const row = await fetchRecord(objectType, recordId); + const next: Record = {}; + for (const field of formFields(def)) { + const value = row.data[field.name]; + next[field.name] = + value == null + ? "" + : typeof value === "string" + ? value + : JSON.stringify(value); + } + setValues(next); + } + + async function onDelete() { + if (!objectType || !recordId || !def) return; + if (!window.confirm(`Delete this ${def.label}? This cannot be undone.`)) { + return; + } + setSaving(true); + setError(null); + try { + await deleteRecordApi(objectType, recordId); + navigate(`/records/${encodeURIComponent(objectType)}`); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setSaving(false); + } + } + + async function onAction(action: NonNullable[number]) { + if (!objectType || (!recordId && action.target !== "collection")) return; + const needsConfirmation = + action.confirm === true || action.confirmation?.required === true; + if ( + needsConfirmation && + !window.confirm( + `${action.label} may change data or external systems. Continue?` + ) + ) { + return; + } + const schemaProperties = + action.inputSchema?.properties && + typeof action.inputSchema.properties === "object" + ? (action.inputSchema.properties as Record< + string, + Record + >) + : {}; + const rawValues = actionValues[action.name] ?? {}; + const input: Record = {}; + for (const [name, schema] of Object.entries(schemaProperties)) { + const raw = rawValues[name] ?? ""; + if (raw === "") continue; + if (schema.type === "integer") input[name] = Number.parseInt(raw, 10); + else if (schema.type === "number") input[name] = Number.parseFloat(raw); + else if (schema.type === "boolean") input[name] = raw === "true"; + else if (schema.type === "object" || schema.type === "array") { + input[name] = JSON.parse(raw); + } else input[name] = raw; + } + setRunningAction(action.name); + setError(null); + setStatus(null); + try { + const result = await runRecordActionApi(objectType, action.name, input, { + id: action.target === "collection" ? undefined : recordId, + confirmed: needsConfirmation, + idempotencyKey: + action.effect && action.effect !== "read" + ? crypto.randomUUID() + : undefined, + }); + setStatus( + result && + typeof result === "object" && + "operationRunId" in result + ? `${action.label} accepted` + : `${action.label} completed` + ); + if (!isNew) await refreshRecord(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setRunningAction(null); + } + } + + return ( + + + + + {!isNew && def?.operations?.includes("delete") && ( + + )} + + } + /> + {error && ( +

+ {error} +

+ )} +

+ {status} +

+ {def && ( +
+ {formFields(def).map((f) => ( +
+ + {f.fieldType === "Select" && f.options?.length ? ( + + ) : ( + + setValues((v) => ({ ...v, [f.name]: e.target.value })) + } + /> + )} + {f.description && ( +

{f.description}

+ )} +
+ ))} +
+ )} + {!isNew && def?.actions?.length ? ( +
+

+ Actions +

+ {def.actions.map((action) => { + const properties = + action.inputSchema?.properties && + typeof action.inputSchema.properties === "object" + ? (action.inputSchema.properties as Record< + string, + Record + >) + : {}; + return ( +
+
+

{action.label}

+ {action.description ? ( +

+ {action.description} +

+ ) : null} +
+ {Object.entries(properties).map(([name, schema]) => ( +
+ + + setActionValues((current) => ({ + ...current, + [action.name]: { + ...current[action.name], + [name]: event.target.value, + }, + })) + } + /> +
+ ))} + +
+ ); + })} +
+ ) : null} +
+ ); +} + +export default RecordFormPage; diff --git a/apps/web/src/pages/records/RecordListPage.tsx b/apps/web/src/pages/records/RecordListPage.tsx new file mode 100644 index 0000000..ef50343 --- /dev/null +++ b/apps/web/src/pages/records/RecordListPage.tsx @@ -0,0 +1,168 @@ +import { useEffect, useMemo, useState } from "react"; +import { Link, useLocation, useNavigate, useParams } from "react-router-dom"; +import { Page, PageHeader } from "@/components/PageHeader"; +import { Button } from "@/components/ui/button"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + fetchObjectType, + fetchRecords, + type ObjectTypeClient, + type RecordRowClient, +} from "@/lib/object-types-api"; +import { useStructure } from "@/lib/structure-context"; +import type { DepartmentNode, PageNode } from "@/lib/navigation"; + +function findPageContext( + pathname: string, + departments: DepartmentNode[] +): PageNode | undefined { + for (const d of departments) { + for (const div of d.divisions) { + for (const p of div.pages) { + const full = + p.segment === "" + ? div.basePath + : `${div.basePath.replace(/\/$/, "")}/${p.segment}`; + if (pathname === full) return p; + } + } + } + return undefined; +} + +export function RecordListPage({ + objectType: objectTypeProp, +}: { + objectType?: string; +}) { + const params = useParams(); + const navigate = useNavigate(); + const { pathname } = useLocation(); + const { departments } = useStructure(); + const objectType = useMemo(() => { + if (objectTypeProp) return objectTypeProp; + if (params.objectType) return params.objectType; + const page = findPageContext(pathname, departments); + if (page?.kind === "record-list") return page.objectType || undefined; + return undefined; + }, [objectTypeProp, params.objectType, pathname, departments]); + + const [def, setDef] = useState(null); + const [rows, setRows] = useState([]); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!objectType) { + setLoading(false); + setError( + "No ObjectType specified (set page ObjectType metadata or /records/:objectType)" + ); + return; + } + let cancelled = false; + setLoading(true); + fetchObjectType(objectType) + .then(async (ot) => { + if (ot.operations && !ot.operations.includes("list")) { + throw new Error(`Listing ${ot.labelPlural ?? ot.label} is disabled`); + } + const list = await fetchRecords(objectType); + return [ot, list] as const; + }) + .then(([ot, list]) => { + if (cancelled) return; + setDef(ot); + setRows(list.records); + setError(null); + }) + .catch((err: Error) => { + if (!cancelled) setError(err.message); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [objectType]); + + const listFields = (def?.fields ?? []).filter( + (f) => f.inList !== false && f.fieldType !== "JSON" + ); + + return ( + + + navigate(`/records/${encodeURIComponent(objectType)}/new`) + } + > + New + + ) : null + } + /> + {loading &&

Loading…

} + {error &&

{error}

} + {!loading && !error && def && ( + + + + {listFields.map((f) => ( + {f.label} + ))} + + + + {rows.length === 0 && ( + + + No records yet. + + + )} + {rows.map((r) => ( + + {listFields.map((f, idx) => ( + + {idx === 0 && + (!def.operations || def.operations.includes("get")) ? ( + + {String(r.data[f.name] ?? "")} + + ) : ( + String(r.data[f.name] ?? "") + )} + + ))} + + ))} + +
+ )} +
+ ); +} + +export default RecordListPage; diff --git a/apps/web/src/pages/records/__tests__/RecordFormPage.test.tsx b/apps/web/src/pages/records/__tests__/RecordFormPage.test.tsx new file mode 100644 index 0000000..f01b09d --- /dev/null +++ b/apps/web/src/pages/records/__tests__/RecordFormPage.test.tsx @@ -0,0 +1,61 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { RecordFormPage } from "../RecordFormPage"; + +const api = vi.hoisted(() => ({ + fetchObjectType: vi.fn(), + fetchRecord: vi.fn(), + createRecordApi: vi.fn(), + updateRecordApi: vi.fn(), +})); + +vi.mock("@/lib/object-types-api", () => api); +vi.mock("@/lib/structure-context", () => ({ + useStructure: () => ({ departments: [] }), +})); + +describe("RecordFormPage", () => { + beforeEach(() => { + api.fetchObjectType.mockResolvedValue({ + name: "Project", + label: "Project", + storage: { kind: "native" }, + fields: [ + { name: "title", label: "Title", fieldType: "Data", required: true }, + { + name: "status", + label: "Status", + fieldType: "Select", + options: ["open", "done"], + }, + { name: "computed", label: "Computed", fieldType: "ReadOnly" }, + ], + }); + api.createRecordApi.mockResolvedValue({ id: "project-1", data: {} }); + }); + + it("builds and submits a form from ObjectType metadata", async () => { + render( + + + + ); + fireEvent.change(await screen.findByLabelText("Title *"), { + target: { value: "Kernel" }, + }); + fireEvent.change(screen.getByLabelText("Status"), { + target: { value: "open" }, + }); + expect(screen.queryByLabelText("Computed")).not.toBeInTheDocument(); + fireEvent.click(screen.getByText("Save")); + await waitFor(() => + expect(api.createRecordApi).toHaveBeenCalledWith("Project", { + title: "Kernel", + status: "open", + }) + ); + }); +}); diff --git a/apps/web/src/pages/records/__tests__/RecordListPage.test.tsx b/apps/web/src/pages/records/__tests__/RecordListPage.test.tsx new file mode 100644 index 0000000..bc0a34a --- /dev/null +++ b/apps/web/src/pages/records/__tests__/RecordListPage.test.tsx @@ -0,0 +1,47 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { RecordListPage } from "../RecordListPage"; + +const api = vi.hoisted(() => ({ + fetchObjectType: vi.fn(), + fetchRecords: vi.fn(), +})); + +vi.mock("@/lib/object-types-api", () => api); +vi.mock("@/lib/structure-context", () => ({ + useStructure: () => ({ departments: [] }), +})); + +describe("RecordListPage", () => { + beforeEach(() => { + api.fetchObjectType.mockResolvedValue({ + name: "Project", + label: "Project", + labelPlural: "Projects", + storage: { kind: "adapter", adapterId: "projects" }, + fields: [ + { name: "title", label: "Title", fieldType: "Data" }, + { name: "meta", label: "Metadata", fieldType: "JSON" }, + ], + }); + api.fetchRecords.mockResolvedValue({ + objectType: "Project", + records: [{ id: "project-1", objectType: "Project", data: { title: "Kernel" } }], + total: 1, + }); + }); + + it("renders metadata-defined columns and records", async () => { + render( + + + + ); + expect(await screen.findByText("Kernel")).toBeInTheDocument(); + expect(screen.getByText("Projects")).toBeInTheDocument(); + expect(screen.queryByText("Metadata")).not.toBeInTheDocument(); + }); +}); diff --git a/deploy/Dockerfile b/deploy/Dockerfile index 65cefc6..bbbc16e 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -18,6 +18,7 @@ COPY packages/ packages/ RUN npm ci --omit=dev -w @godmode/bridge \ && apk del python3 make g++ COPY --from=build /app/packages/flow-core/dist packages/flow-core/dist +COPY --from=build /app/packages/kernel/dist packages/kernel/dist COPY --from=build /app/packages/plugin-api/dist packages/plugin-api/dist COPY --from=build /app/packages/plugin-host/dist packages/plugin-host/dist COPY --from=build /app/apps/bridge/dist apps/bridge/dist diff --git a/package-lock.json b/package-lock.json index 96899d8..6bd990c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,9 +12,15 @@ "packages/*" ], "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@vitest/coverage-v8": "^4.1.10", "concurrently": "^9.1.2", + "cross-env": "^10.1.0", + "jsdom": "^29.1.1", "tsx": "^4.19.2", - "typescript": "^5.7.3" + "typescript": "^5.7.3", + "vitest": "^4.1.10" } }, "apps/bridge": { @@ -23,8 +29,10 @@ "dependencies": { "@cursor/sdk": "^1.0.23", "@godmode/flow-core": "*", + "@godmode/kernel": "*", "@godmode/plugin-api": "*", "@godmode/plugin-host": "*", + "ajv": "^8.20.0", "better-sqlite3": "^12.10.0", "cors": "^2.8.5", "dotenv": "^17.4.2", @@ -102,6 +110,64 @@ "vite": "^6.0.7" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -608,6 +674,29 @@ } } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@bufbuild/protobuf": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.0.tgz", @@ -649,6 +738,146 @@ "@connectrpc/connect": "1.7.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", + "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", + "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@cursor/sdk": { "version": "1.0.23", "resolved": "https://registry.npmjs.org/@cursor/sdk/-/sdk-1.0.23.tgz", @@ -951,6 +1180,13 @@ "@noble/ciphers": "^1.0.0" } }, + "node_modules/@epic-web/invariant": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", + "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", + "dev": true, + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", @@ -1367,6 +1603,24 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@fastify/busboy": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", @@ -1450,6 +1704,10 @@ "resolved": "packages/flow-core", "link": true }, + "node_modules/@godmode/kernel": { + "resolved": "packages/kernel", + "link": true + }, "node_modules/@godmode/plugin-api": { "resolved": "packages/plugin-api", "link": true @@ -2841,6 +3099,82 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@tootallnate/once": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz", @@ -2861,6 +3195,14 @@ "path-browserify": "^1.0.1" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2927,6 +3269,17 @@ "@types/node": "*" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -3059,6 +3412,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -3303,6 +3663,150 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", + "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.10", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.10", + "vitest": "4.1.10" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@xyflow/react": { "version": "12.10.2", "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.2.tgz", @@ -3480,12 +3984,32 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types": { "version": "0.16.1", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", @@ -3498,6 +4022,25 @@ "node": ">=4" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", + "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/bail": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", @@ -3563,6 +4106,16 @@ "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -3939,6 +4492,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4299,6 +4862,24 @@ } } }, + "node_modules/cross-env": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", + "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@epic-web/invariant": "^1.0.0", + "cross-spawn": "^7.0.6" + }, + "bin": { + "cross-env": "dist/bin/cross-env.js", + "cross-env-shell": "dist/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4334,6 +4915,27 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -4558,6 +5160,58 @@ "node": ">= 12" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -4575,6 +5229,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decimal.js-light": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", @@ -4746,6 +5407,14 @@ "node": ">=0.3.1" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dotenv": { "version": "17.4.2", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", @@ -4873,6 +5542,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -4915,6 +5597,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-module-shims": { "version": "2.8.2", "resolved": "https://registry.npmjs.org/es-module-shims/-/es-module-shims-2.8.2.tgz", @@ -5034,6 +5723,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -5105,6 +5804,16 @@ "node": ">=6" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exponential-backoff": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", @@ -5865,6 +6574,26 @@ "node": ">=16.9.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/html-to-image": { "version": "1.11.13", "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz", @@ -6293,6 +7022,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -6359,6 +7095,58 @@ "node": ">=18" } }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -6377,22 +7165,121 @@ "url": "https://github.com/sponsors/panva" } }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, "license": "MIT", "dependencies": { - "argparse": "^2.0.1" + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/jsesc": { @@ -6801,6 +7688,17 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -6810,6 +7708,47 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/make-fetch-happen": { "version": "10.2.1", "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-10.2.1.tgz", @@ -7160,6 +8099,13 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -7856,6 +8802,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -8446,6 +9402,20 @@ "node": ">= 10" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -8675,6 +9645,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -8714,6 +9697,13 @@ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8821,6 +9811,44 @@ "node": ">=10" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/pretty-ms": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", @@ -8919,6 +9947,16 @@ "once": "^1.3.1" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.15.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", @@ -9218,6 +10256,20 @@ "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", "license": "MIT" }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", @@ -9546,6 +10598,19 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -9784,6 +10849,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -9947,6 +11019,13 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -9956,6 +11035,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/stdin-discarder": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", @@ -10069,6 +11155,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", @@ -10112,6 +11211,13 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tagged-tag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", @@ -10242,6 +11348,23 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -10258,6 +11381,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tldts": { "version": "7.0.30", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz", @@ -11297,6 +12430,109 @@ "@esbuild/win32-x64": "0.25.12" } }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -11312,6 +12548,16 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "license": "BSD-2-Clause" }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -11337,6 +12583,23 @@ "node": "^16.13.0 || >=18.0.0" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wide-align": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", @@ -11406,6 +12669,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -11554,6 +12834,14 @@ "typescript": "^5.7.3" } }, + "packages/kernel": { + "name": "@godmode/kernel", + "version": "0.1.0", + "devDependencies": { + "@types/node": "^22.10.5", + "typescript": "^5.7.3" + } + }, "packages/playbook-graph": { "name": "@godmode/playbook-graph", "version": "0.1.0", @@ -11583,6 +12871,7 @@ "name": "@godmode/plugin-api", "version": "0.1.0", "dependencies": { + "@godmode/kernel": "*", "express": "^4.21.2" }, "devDependencies": { diff --git a/package.json b/package.json index 72e719f..73e7659 100644 --- a/package.json +++ b/package.json @@ -8,18 +8,28 @@ ], "scripts": { "build": "npm run build --workspaces --if-present", - "build:packages": "npm run build -w @godmode/flow-core -w @godmode/plugin-api -w @godmode/plugin-host", + "build:packages": "npm run build -w @godmode/kernel -w @godmode/flow-core -w @godmode/plugin-api -w @godmode/plugin-host", "dev": "concurrently \"npm run dev -w @godmode/bridge\" \"npm run dev -w @godmode/web\"", "dev:bridge": "npm run dev -w @godmode/bridge", "dev:web": "npm run dev -w @godmode/web", "typecheck": "npm run build:packages && npm run typecheck --workspaces --if-present", "audit:oss": "node scripts/audit-oss-core.mjs", + "audit:kernel": "node scripts/audit-kernel-coverage.mjs", + "test": "vitest run", + "test:objecttypes": "vitest run packages/kernel/src packages/plugin-api/src apps/bridge/src/kernel apps/web/src", + "test:gate": "npm run build:packages && npm run typecheck && npm run audit:kernel && npm test", "seed:demo": "node scripts/seed-readme-demo.mjs", "plugins": "tsx scripts/godmode-plugins-cli.ts" }, "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@vitest/coverage-v8": "^4.1.10", "concurrently": "^9.1.2", + "cross-env": "^10.1.0", + "jsdom": "^29.1.1", "tsx": "^4.19.2", - "typescript": "^5.7.3" + "typescript": "^5.7.3", + "vitest": "^4.1.10" } } diff --git a/packages/kernel/package.json b/packages/kernel/package.json new file mode 100644 index 0000000..c5abd26 --- /dev/null +++ b/packages/kernel/package.json @@ -0,0 +1,23 @@ +{ + "name": "@godmode/kernel", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit" + }, + "dependencies": {}, + "devDependencies": { + "@types/node": "^22.10.5", + "typescript": "^5.7.3" + } +} diff --git a/packages/kernel/src/__tests__/schema.test.ts b/packages/kernel/src/__tests__/schema.test.ts new file mode 100644 index 0000000..62f5cbb --- /dev/null +++ b/packages/kernel/src/__tests__/schema.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { + STRUCTURE_NODE_OBJECT_TYPE, + fieldsToJsonSchema, + perObjectTypeToolNames, + validateObjectTypeDef, +} from "../index.js"; + +describe("ObjectType schema", () => { + it("accepts the built-in dynamic Structure kind", () => { + expect(validateObjectTypeDef(STRUCTURE_NODE_OBJECT_TYPE)).toEqual([]); + }); + + it("rejects malformed definitions", () => { + expect( + validateObjectTypeDef({ + name: "bad-name", + label: "", + storage: { kind: "native" }, + fields: [{ name: "id", label: "Id", fieldType: "Int" }], + }) + ).toEqual( + expect.arrayContaining([ + expect.stringContaining("PascalCase"), + "label required", + "id field must use Data", + ]) + ); + }); + + it("derives create/update schemas and readable plurals", () => { + const fields = [ + { name: "id", label: "Id", fieldType: "Data" as const, required: true }, + { name: "title", label: "Title", fieldType: "Data" as const, required: true }, + { name: "computed", label: "Computed", fieldType: "ReadOnly" as const }, + ]; + const create = fieldsToJsonSchema(fields, { + mode: "create", + includeId: true, + }) as { required: string[]; properties: Record }; + expect(create.required).toEqual(["title"]); + expect(create.properties.computed).toBeUndefined(); + expect(perObjectTypeToolNames("Address").list).toBe("list_addresses"); + expect(perObjectTypeToolNames("Category").list).toBe("list_categories"); + }); +}); diff --git a/packages/kernel/src/builtins.ts b/packages/kernel/src/builtins.ts new file mode 100644 index 0000000..a2a130c --- /dev/null +++ b/packages/kernel/src/builtins.ts @@ -0,0 +1,176 @@ +import type { ObjectTypeDef } from "./types.js"; + +/** + * Built-in ObjectType for the Structure shell tree. + * Storage is an adapter over structure_nodes (no second copy). + * + * Product UX still says department / division / page — those are tree roles, + * not separate ObjectTypes. + */ +export const STRUCTURE_NODE_OBJECT_TYPE: ObjectTypeDef = { + name: "StructureNode", + label: "Structure Node", + labelPlural: "Structure Nodes", + description: + "Page tree node for the GodMode shell (roots = departments, children = divisions/pages). Use Record CRUD or legacy create_department/create_division/create_page wrappers.", + storage: { + kind: "adapter", + adapterId: "structure_nodes", + }, + module: "platform", + contractVersion: 1, + operations: ["list", "get", "create", "update", "delete"], + fields: [ + { + name: "id", + label: "Id", + fieldType: "Data", + required: true, + inList: true, + description: "Stable id (roots = slug; children = parentId-slug)", + }, + { + name: "parent_id", + label: "Parent", + fieldType: "Link", + linkTo: "StructureNode", + inList: true, + inForm: true, + description: "Null for department roots", + }, + { + name: "label", + label: "Label", + fieldType: "Data", + required: true, + inList: true, + }, + { + name: "icon", + label: "Icon", + fieldType: "Data", + required: true, + inList: true, + description: "lucide icon slug", + }, + { + name: "segment", + label: "URL segment", + fieldType: "Data", + inList: true, + }, + { + name: "kind", + label: "Page kind", + fieldType: "Select", + inList: true, + description: "Renderer kind from the Kind registry (plugins may extend)", + optionsSource: "pageKinds", + }, + { + name: "object_type", + label: "ObjectType", + fieldType: "Data", + description: "Explicit ObjectType rendered by record-list / record-form", + }, + { + name: "right_sidebar", + label: "Right sidebar", + fieldType: "Data", + description: "Plugin shell slot id, or empty", + }, + { + name: "agent_id", + label: "Agent", + fieldType: "Data", + description: "Nav auto-chat agent id", + }, + { + name: "built_in", + label: "Built-in", + fieldType: "Check", + inForm: false, + inList: true, + }, + { + name: "sort_order", + label: "Sort order", + fieldType: "Int", + inForm: false, + inList: true, + }, + { + name: "tabs_json", + label: "Tabs JSON", + fieldType: "JSON", + inForm: false, + }, + { + name: "path", + label: "Path", + fieldType: "ReadOnly", + inList: true, + inForm: false, + description: "Derived URL path", + }, + ], + permissions: [ + { role: "viewer", read: true }, + { role: "editor", read: true, create: true, update: true }, + { role: "owner", read: true, create: true, update: true, delete: true }, + { + role: "intelligence", + read: true, + create: true, + update: true, + delete: true, + }, + ], + actions: [ + { + name: "set_agent", + label: "Set Agent", + target: "record", + effect: "write", + execution: "sync", + roles: ["editor", "owner", "intelligence"], + inputSchema: { + type: "object", + additionalProperties: false, + properties: { agent_id: { type: ["string", "null"] } }, + required: ["agent_id"], + }, + }, + { + name: "move", + label: "Move", + target: "record", + effect: "write", + execution: "sync", + roles: ["editor", "owner", "intelligence"], + inputSchema: { + type: "object", + additionalProperties: false, + properties: { parent_id: { type: ["string", "null"] } }, + required: ["parent_id"], + }, + }, + { + name: "reorder", + label: "Reorder", + target: "collection", + effect: "write", + execution: "sync", + roles: ["editor", "owner", "intelligence"], + inputSchema: { + type: "object", + additionalProperties: false, + properties: { + parent_id: { type: ["string", "null"] }, + ordered_ids: { type: "array", items: { type: "string" } }, + }, + required: ["ordered_ids"], + }, + }, + ], +}; diff --git a/packages/kernel/src/index.ts b/packages/kernel/src/index.ts new file mode 100644 index 0000000..cf47a1b --- /dev/null +++ b/packages/kernel/src/index.ts @@ -0,0 +1,30 @@ +export type { + FieldType, + FieldDef, + PermissionDef, + PermissionRole, + ActionTarget, + ActionEffect, + ActionExecution, + ConfirmationPolicy, + IdempotencyPolicy, + ActionEventDef, + ActionDef, + ActionResult, + ObjectTypeStorage, + ObjectTypeDef, + RecordData, + RecordRow, + ListRecordsResult, +} from "./types.js"; + +export { + objectTypeToSnake, + defaultNativeTableName, + validateObjectTypeDef, + fieldsToJsonSchema, + toolBaseName, + perObjectTypeToolNames, +} from "./schema.js"; + +export { STRUCTURE_NODE_OBJECT_TYPE } from "./builtins.js"; diff --git a/packages/kernel/src/schema.ts b/packages/kernel/src/schema.ts new file mode 100644 index 0000000..041f862 --- /dev/null +++ b/packages/kernel/src/schema.ts @@ -0,0 +1,251 @@ +import type { FieldDef, FieldType, ObjectTypeDef } from "./types.js"; + +const NAME_RE = /^[A-Z][A-Za-z0-9]*$/; +const FIELD_RE = /^[a-z][a-z0-9_]*$/; +const ROLES = new Set(["viewer", "editor", "owner", "intelligence"]); +const ACTION_TARGETS = new Set(["record", "collection", "bulk"]); +const ACTION_EFFECTS = new Set(["read", "write", "destructive", "external"]); +const ACTION_EXECUTIONS = new Set(["sync", "async"]); + +function isSchema(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +export function objectTypeToSnake(name: string): string { + return name + .replace(/([a-z0-9])([A-Z])/g, "$1_$2") + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2") + .toLowerCase(); +} + +export function defaultNativeTableName(objectTypeName: string): string { + return `gm_ot_${objectTypeToSnake(objectTypeName)}`; +} + +export function validateObjectTypeDef(def: ObjectTypeDef): string[] { + const errors: string[] = []; + if (!NAME_RE.test(def.name)) { + errors.push(`ObjectType name must be PascalCase (got ${def.name})`); + } + if (!def.label?.trim()) errors.push("label required"); + if (!def.fields?.length) errors.push("at least one field required"); + const seen = new Set(); + for (const f of def.fields ?? []) { + if (!FIELD_RE.test(f.name)) { + errors.push(`field name must be snake_case: ${f.name}`); + } + if (seen.has(f.name)) errors.push(`duplicate field: ${f.name}`); + seen.add(f.name); + if ( + f.fieldType === "Select" && + (!f.options || f.options.length === 0) && + !f.optionsSource + ) { + errors.push(`Select field ${f.name} needs options`); + } + if (f.fieldType === "Link" && !f.linkTo) { + errors.push(`Link field ${f.name} needs linkTo`); + } + } + if (!def.fields?.some((f) => f.name === "id")) { + errors.push("ObjectType must include an id field"); + } + if (!def.storage || !["adapter", "native"].includes(def.storage.kind)) { + errors.push("storage kind must be adapter or native"); + } else if (def.storage.kind === "adapter" && !def.storage.adapterId) { + errors.push("adapter storage requires adapterId"); + } + const idField = def.fields?.find((f) => f.name === "id"); + if (idField && idField.fieldType !== "Data") { + errors.push("id field must use Data"); + } + if (def.storage?.kind === "native" && def.database === "core") { + errors.push("native ObjectTypes must be tenant-local"); + } + if (def.contractVersion != null && (!Number.isInteger(def.contractVersion) || def.contractVersion < 1)) { + errors.push("contractVersion must be a positive integer"); + } + const permissionRoles = new Set(); + for (const permission of def.permissions ?? []) { + if (!ROLES.has(permission.role)) errors.push(`invalid permission role: ${permission.role}`); + if (permissionRoles.has(permission.role)) { + errors.push(`duplicate permission role: ${permission.role}`); + } + permissionRoles.add(permission.role); + } + if (def.permissions && def.permissions.length === 0) { + errors.push("permissions must not be empty"); + } + if (def.operations && new Set(def.operations).size !== def.operations.length) { + errors.push("operations must be unique"); + } + if (!def.operations?.length && !def.actions?.length) { + errors.push("at least one operation or action must be declared"); + } + const actionNames = new Set(); + for (const action of def.actions ?? []) { + if (!FIELD_RE.test(action.name)) { + errors.push(`action name must be snake_case: ${action.name}`); + } + if (actionNames.has(action.name)) { + errors.push(`duplicate action: ${action.name}`); + } + actionNames.add(action.name); + if (!action.label?.trim()) errors.push(`action ${action.name} label required`); + if (action.target && !ACTION_TARGETS.has(action.target)) { + errors.push(`action ${action.name} has invalid target`); + } + if (action.effect && !ACTION_EFFECTS.has(action.effect)) { + errors.push(`action ${action.name} has invalid effect`); + } + if (action.execution && !ACTION_EXECUTIONS.has(action.execution)) { + errors.push(`action ${action.name} has invalid execution`); + } + if (action.roles?.some((role) => !ROLES.has(role))) { + errors.push(`action ${action.name} has invalid role`); + } + if (!action.roles?.length) { + errors.push(`action ${action.name} must declare roles`); + } + if (action.roles && new Set(action.roles).size !== action.roles.length) { + errors.push(`action ${action.name} roles must be unique`); + } + if ( + action.contractVersion != null && + (!Number.isInteger(action.contractVersion) || action.contractVersion < 1) + ) { + errors.push(`action ${action.name} contractVersion must be positive`); + } + if ( + action.retry && + (!Number.isInteger(action.retry.maxAttempts) || + action.retry.maxAttempts < 1) + ) { + errors.push(`action ${action.name} retry.maxAttempts must be positive`); + } + if (action.concurrency?.required && action.target === "collection") { + errors.push(`collection action ${action.name} cannot require record concurrency`); + } + for (const [label, schema] of [ + ["inputSchema", action.inputSchema], + ["outputSchema", action.outputSchema], + ["errorSchema", action.errorSchema], + ] as const) { + if (schema != null && !isSchema(schema)) { + errors.push(`action ${action.name} ${label} must be an object`); + } + } + if (action.execution === "async" && action.cancellable == null) { + errors.push(`async action ${action.name} must declare cancellable`); + } + } + return errors; +} + +function jsonSchemaType(fieldType: FieldType): Record { + switch (fieldType) { + case "Int": + return { type: "integer" }; + case "Float": + return { type: "number" }; + case "Check": + return { type: "boolean" }; + case "JSON": + return {}; + default: + return { type: "string" }; + } +} + +/** JSON Schema object for create/update tool parameters from Field metadata. */ +export function fieldsToJsonSchema( + fields: FieldDef[], + opts?: { mode?: "create" | "update" | "list"; includeId?: boolean } +): Record { + const mode = opts?.mode ?? "create"; + const properties: Record = {}; + const required: string[] = []; + + for (const f of fields) { + if (f.fieldType === "ReadOnly" && mode !== "list") continue; + if (f.inForm === false && mode !== "list") continue; + if (f.name === "id" && mode === "create" && !opts?.includeId) continue; + if (f.name === "id" && mode === "update") { + properties.id = { + type: "string", + description: f.description ?? "Record id", + }; + required.push("id"); + continue; + } + + const prop: Record = { + ...jsonSchemaType(f.fieldType), + description: f.description ?? f.label, + }; + if (f.fieldType === "Select" && f.options?.length) { + prop.enum = [...f.options]; + } + properties[f.name] = prop; + if (mode === "create" && f.required && f.name !== "id") { + required.push(f.name); + } + } + + if (mode === "list") { + return { + type: "object", + properties: { + parent_id: { + type: "string", + description: "Optional parent filter (tree types)", + }, + limit: { type: "integer" }, + offset: { type: "integer" }, + filters: { + type: "object", + description: "Exact-match filters keyed by Field name", + additionalProperties: true, + }, + sort: { type: "string", description: "Field name to sort by" }, + direction: { type: "string", enum: ["asc", "desc"] }, + }, + }; + } + + return { + type: "object", + properties, + required: required.length ? required : undefined, + }; +} + +export function toolBaseName(objectTypeName: string): string { + return objectTypeToSnake(objectTypeName); +} + +export function perObjectTypeToolNames(objectTypeName: string): { + list: string; + get: string; + create: string; + update: string; + delete: string; +} { + const base = toolBaseName(objectTypeName); + const plural = + base.endsWith("s") || + base.endsWith("x") || + base.endsWith("ch") || + base.endsWith("sh") + ? `${base}es` + : base.endsWith("y") && !/[aeiou]y$/.test(base) + ? `${base.slice(0, -1)}ies` + : `${base}s`; + return { + list: `list_${plural}`, + get: `get_${base}`, + create: `create_${base}`, + update: `update_${base}`, + delete: `delete_${base}`, + }; +} diff --git a/packages/kernel/src/types.ts b/packages/kernel/src/types.ts new file mode 100644 index 0000000..f7858ab --- /dev/null +++ b/packages/kernel/src/types.ts @@ -0,0 +1,171 @@ +/** + * GodMode metadata kernel vocabulary. + * + * - ObjectType — definition (fields, permissions, naming, storage) + * - Field — column / property on an ObjectType + * - Record — one instance of an ObjectType + * + * Do not call these DocTypes. + */ + +export type FieldType = + | "Data" + | "Text" + | "Int" + | "Float" + | "Check" + | "Select" + | "Link" + | "JSON" + | "ReadOnly"; + +export interface FieldDef { + /** Stable field name (snake_case preferred for storage). */ + name: string; + label: string; + fieldType: FieldType; + required?: boolean; + /** For Select fields. */ + options?: string[]; + /** Runtime registry that supplies Select values (for example page kinds). */ + optionsSource?: "pageKinds"; + /** For Link fields — target ObjectType name. */ + linkTo?: string; + /** Shown in list views by default. */ + inList?: boolean; + /** Included in create/update tool params (default true except ReadOnly / id). */ + inForm?: boolean; + description?: string; + default?: unknown; + /** Never serialize this field through generic Record APIs. */ + secret?: boolean; +} + +export interface PermissionDef { + role: PermissionRole; + read?: boolean; + create?: boolean; + update?: boolean; + delete?: boolean; +} + +export type PermissionRole = "viewer" | "editor" | "owner" | "intelligence"; +export type ActionTarget = "record" | "collection" | "bulk"; +export type ActionEffect = "read" | "write" | "destructive" | "external"; +export type ActionExecution = "sync" | "async"; + +export interface ConfirmationPolicy { + required: boolean; + /** Maximum age of a confirmation grant. */ + ttlSeconds?: number; +} + +export interface IdempotencyPolicy { + required?: boolean; + /** How long a completed action result may be reused. */ + ttlSeconds?: number; +} + +export interface ActionEventDef { + type: string; + schema?: Record; +} + +export interface ActionDef { + name: string; + label: string; + description?: string; + target?: ActionTarget; + effect?: ActionEffect; + execution?: ActionExecution; + contractVersion?: number; + roles?: PermissionRole[]; + /** Compatibility shorthand. Prefer confirmation for new definitions. */ + confirm?: boolean; + confirmation?: ConfirmationPolicy; + inputSchema?: Record; + outputSchema?: Record; + errorSchema?: Record; + sensitiveInputPaths?: string[]; + sensitiveOutputPaths?: string[]; + idempotency?: IdempotencyPolicy; + retry?: { + maxAttempts: number; + backoffMs?: number; + retryableErrorCodes?: string[]; + }; + concurrency?: { + required: boolean; + versionField?: string; + }; + timeoutMs?: number; + cancellable?: boolean; + events?: ActionEventDef[]; + deprecated?: { + since: number; + message: string; + replacement?: string; + }; +} + +export type ObjectTypeStorage = + | { + /** Maps onto an existing table / service (no second copy of data). */ + kind: "adapter"; + adapterId: string; + } + | { + /** Physical SQLite table materialized from Field defs. */ + kind: "native"; + /** Optional explicit table name; default gm_ot_. */ + tableName?: string; + }; + +export interface ObjectTypeDef { + /** PascalCase type name, e.g. StructureNode. */ + name: string; + label: string; + description?: string; + /** Plural label for lists. */ + labelPlural?: string; + storage: ObjectTypeStorage; + fields: FieldDef[]; + permissions?: PermissionDef[]; + /** Plugin id when contributed by a plugin. */ + pluginId?: string; + /** Soft module / department hint for Discovery. */ + module?: string; + /** Named, centrally enforced access policy for discovery and adapters. */ + accessPolicy?: string; + /** Public metadata/action contract revision, independent from storage schema. */ + contractVersion?: number; + /** Metadata/schema revision used by native additive migrations. */ + schemaVersion?: number; + /** Database containing adapter records. Native ObjectTypes are tenant-local. */ + database?: "tenant" | "core"; + /** Operations exposed through generic Record APIs. */ + operations?: Array<"list" | "get" | "create" | "update" | "delete">; + /** Named domain operations implemented by an adapter (moves, approvals, runs). */ + actions?: ActionDef[]; +} + +/** Serialized Record payload (field name → value). */ +export type RecordData = Record; + +export interface RecordRow { + id: string; + objectType: string; + data: RecordData; +} + +export interface ListRecordsResult { + objectType: string; + records: RecordRow[]; + total: number; +} + +export type ActionResult = + | { status: "succeeded"; result: unknown } + | { status: "accepted"; operationRunId: string } + | { status: "confirmation_required"; confirmationId: string } + | { status: "conflict"; code: string; message: string }; diff --git a/packages/kernel/tsconfig.json b/packages/kernel/tsconfig.json new file mode 100644 index 0000000..cbd3607 --- /dev/null +++ b/packages/kernel/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "skipLibCheck": true + }, + "include": ["src/**/*"] +} diff --git a/packages/plugin-api/package.json b/packages/plugin-api/package.json index 7db43ae..b068da4 100644 --- a/packages/plugin-api/package.json +++ b/packages/plugin-api/package.json @@ -16,6 +16,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@godmode/kernel": "*", "express": "^4.21.2" }, "devDependencies": { diff --git a/packages/plugin-api/src/__tests__/manifest.test.ts b/packages/plugin-api/src/__tests__/manifest.test.ts new file mode 100644 index 0000000..48b12fd --- /dev/null +++ b/packages/plugin-api/src/__tests__/manifest.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { parseGodmodePluginManifest } from "../index.js"; + +describe("plugin ObjectType manifests", () => { + it("accepts metadata-only plugins", () => { + expect( + parseGodmodePluginManifest({ + id: "example", + name: "Example", + version: "1.0.0", + objectTypes: [ + { + name: "ExampleItem", + label: "Example Item", + storage: { kind: "native" }, + fields: [{ name: "id", label: "Id", fieldType: "Data" }], + }, + ], + records: [ + { + objectType: "ExampleItem", + data: { id: "example", title: "Example" }, + }, + ], + }) + ).toMatchObject({ id: "example", objectTypes: [{ name: "ExampleItem" }] }); + }); + + it("rejects invalid metadata before registration", () => { + expect(() => + parseGodmodePluginManifest({ + id: "example", + name: "Example", + version: "1.0.0", + objectTypes: [{ name: "bad-name", fields: [] }], + }) + ).toThrow(/ObjectType|objectTypes/); + }); +}); diff --git a/packages/plugin-api/src/bridge-api.ts b/packages/plugin-api/src/bridge-api.ts index e5ba0b1..4146af5 100644 --- a/packages/plugin-api/src/bridge-api.ts +++ b/packages/plugin-api/src/bridge-api.ts @@ -1,6 +1,12 @@ import type { Express, IRouter, RequestHandler } from "express"; import type { EventEmitter } from "node:events"; import type { PluginHostServices } from "./host-services.js"; +import type { + ListRecordsResult, + ObjectTypeDef, + RecordData, + RecordRow, +} from "@godmode/kernel"; export type PluginHookName = | "boot" @@ -42,6 +48,55 @@ export interface PluginToolDef { pluginId?: string; } +export interface PluginRecordContext extends PluginTenantContext { + role: "viewer" | "editor" | "owner" | "intelligence"; + source: "http" | "agent" | "plugin" | "system"; + requestId?: string; + signal?: AbortSignal; +} + +export interface PluginRecordQuery { + parentId?: string | null; + limit?: number; + offset?: number; + filters?: Record; + sort?: string; + direction?: "asc" | "desc"; +} + +export interface PluginRecordAdapter { + list?( + query: PluginRecordQuery, + ctx: PluginRecordContext + ): ListRecordsResult; + get?( + id: string, + ctx: PluginRecordContext + ): RecordRow | null; + create?( + data: RecordData, + ctx: PluginRecordContext + ): RecordRow; + update?( + id: string, + data: RecordData, + ctx: PluginRecordContext + ): RecordRow; + delete?(id: string, ctx: PluginRecordContext): void; + actions?: Record< + string, + ( + id: string, + input: RecordData, + ctx: PluginRecordContext + ) => unknown | Promise + >; +} + +export interface PluginRegistration { + dispose(): void; +} + export interface GodModePluginApi { readonly manifest: { id: string; version: string; name: string }; readonly pluginRoot: string; @@ -56,6 +111,18 @@ export interface GodModePluginApi { register(tools: PluginToolDef[]): void; }; + /** Register renderer kind names on Bridge for metadata/tool validation. */ + pageKinds: { + register(kinds: string[]): void; + }; + + objectTypes: { + register( + definition: ObjectTypeDef, + adapter: PluginRecordAdapter + ): PluginRegistration; + }; + hooks: { on(hook: PluginHookName, handler: (ctx: PluginBootContext & PluginTenantContext) => void | Promise): void; }; diff --git a/packages/plugin-api/src/index.ts b/packages/plugin-api/src/index.ts index be791c7..6d880dc 100644 --- a/packages/plugin-api/src/index.ts +++ b/packages/plugin-api/src/index.ts @@ -1,5 +1,6 @@ export { type GodmodePluginManifest, + type PluginRecordSeed, manifestPath, parseGodmodePluginManifest, readGodmodePluginManifest, @@ -19,6 +20,10 @@ export { type PluginTenantContext, type PluginToolDef, type PluginToolHandler, + type PluginRecordContext, + type PluginRecordQuery, + type PluginRecordAdapter, + type PluginRegistration, } from "./bridge-api.js"; export { diff --git a/packages/plugin-api/src/manifest.ts b/packages/plugin-api/src/manifest.ts index 5595b3d..2a2cee0 100644 --- a/packages/plugin-api/src/manifest.ts +++ b/packages/plugin-api/src/manifest.ts @@ -1,5 +1,15 @@ import fs from "node:fs"; import path from "node:path"; +import { + validateObjectTypeDef, + type ObjectTypeDef, + type RecordData, +} from "@godmode/kernel"; + +export interface PluginRecordSeed { + objectType: string; + data: RecordData; +} export interface GodmodePluginManifest { id: string; @@ -20,6 +30,10 @@ export interface GodmodePluginManifest { entry: string; }; tenantMigrations?: string[]; + /** ObjectType definitions shipped by the plugin (registered before tenant:install). */ + objectTypes?: ObjectTypeDef[]; + /** Optional Record seeds applied after ObjectTypes register (upsert by id). */ + records?: PluginRecordSeed[]; } const MANIFEST_FILE = "godmode.plugin.json"; @@ -28,6 +42,88 @@ export function manifestPath(pluginRoot: string): string { return path.join(pluginRoot, MANIFEST_FILE); } +function parseObjectTypes(raw: unknown, pluginId: string): ObjectTypeDef[] | undefined { + if (!Array.isArray(raw)) return undefined; + const out: ObjectTypeDef[] = []; + for (const [index, item] of raw.entries()) { + if (!item || typeof item !== "object") { + throw new Error(`Invalid plugin manifest (${pluginId}): objectTypes[${index}] must be an object`); + } + const ot = item as ObjectTypeDef; + const operations = + ot.operations ?? + (ot.storage?.kind === "native" + ? (["list", "get", "create", "update", "delete"] as const) + : (["list", "get"] as const)); + const writable = operations.some((operation) => + ["create", "update", "delete"].includes(operation) + ); + const permissions = + ot.permissions ?? + [ + { role: "viewer" as const, read: true }, + { + role: "editor" as const, + read: true, + create: writable, + update: writable, + delete: writable, + }, + { + role: "owner" as const, + read: true, + create: writable, + update: writable, + delete: writable, + }, + { + role: "intelligence" as const, + read: true, + create: writable, + update: writable, + delete: writable, + }, + ]; + const owned: ObjectTypeDef = { + ...ot, + contractVersion: ot.contractVersion ?? 1, + operations: [...operations], + permissions, + pluginId, + }; + const errors = validateObjectTypeDef(owned); + if (errors.length) { + throw new Error( + `Invalid plugin manifest (${pluginId}): ObjectType ${String(ot.name)}: ${errors.join("; ")}` + ); + } + out.push(owned); + } + return out.length ? out : undefined; +} + +function parseRecordSeeds(raw: unknown): PluginRecordSeed[] | undefined { + if (!Array.isArray(raw)) return undefined; + const out: PluginRecordSeed[] = []; + for (const [index, item] of raw.entries()) { + if (!item || typeof item !== "object") { + throw new Error(`Invalid record seed at index ${index}`); + } + const r = item as Record; + if (typeof r.objectType !== "string" || !r.data || typeof r.data !== "object") { + throw new Error(`Invalid record seed at index ${index}: objectType and data required`); + } + if (r.data && (r.data as Record).id == null) { + throw new Error(`Invalid record seed at index ${index}: deterministic data.id required`); + } + out.push({ + objectType: r.objectType, + data: r.data as RecordData, + }); + } + return out.length ? out : undefined; +} + export function parseGodmodePluginManifest(raw: unknown): GodmodePluginManifest { if (!raw || typeof raw !== "object") { throw new Error("Invalid plugin manifest: expected object"); @@ -51,8 +147,9 @@ export function parseGodmodePluginManifest(raw: unknown): GodmodePluginManifest throw new Error(`Invalid plugin manifest (${m.id}): web.entry must be string`); } const native = m.native as Record | undefined; + const id = m.id.trim(); return { - id: m.id.trim(), + id, version: m.version.trim(), name: m.name.trim(), engine: typeof m.engine === "string" ? m.engine : undefined, @@ -73,6 +170,8 @@ export function parseGodmodePluginManifest(raw: unknown): GodmodePluginManifest tenantMigrations: Array.isArray(m.tenantMigrations) ? m.tenantMigrations.filter((x): x is string => typeof x === "string") : undefined, + objectTypes: parseObjectTypes(m.objectTypes, id), + records: parseRecordSeeds(m.records), }; } diff --git a/scripts/audit-kernel-coverage.mjs b/scripts/audit-kernel-coverage.mjs new file mode 100644 index 0000000..8b1cd5b --- /dev/null +++ b/scripts/audit-kernel-coverage.mjs @@ -0,0 +1,480 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const routeRoot = path.join(repoRoot, "apps", "bridge", "src", "routes"); +const kernelRoutes = path.join(repoRoot, "apps", "bridge", "src", "kernel", "routes.ts"); +const toolRegistry = path.join( + repoRoot, + "apps", + "bridge", + "src", + "services", + "ai-tools-registry.ts" +); +const autoTools = path.join(repoRoot, "apps", "bridge", "src", "kernel", "auto-tools.ts"); + +const routeSources = [ + { + file: "routes/admin-billing.ts", + classification: "protocol-exception", + rationale: "Platform-admin billing configuration and provider connectivity checks are control-plane operations.", + routes: ["PUT /", "POST /test"], + }, + { + file: "routes/admin-users.ts", + classification: "compatibility-shim", + target: "User, Tenant, and TenantMembership ObjectTypes", + routes: [ + "POST /users", "PATCH /users/:userId", "DELETE /users/:userId", + "POST /users/:userId/tenants", "PATCH /tenants/:tenantId", "DELETE /tenants/:tenantId", + ], + }, + { + file: "routes/ai.ts", + classification: "compatibility-shim", + target: "Intelligence, automation, productivity, and CalendarEvent ObjectTypes/actions", + routes: [ + "POST /embeddings/start", "POST /embeddings/stop", "POST /embeddings/enabled", + "POST /capabilities/rebuild", "PUT /settings", "PUT /prompt-flow", + "POST /memories/:id/approve", "POST /memories", "PUT /memories/:id", + "DELETE /memories/:id", "PUT /rules/:id", "POST /rules/:id/approve", + "DELETE /rules/:id", "PUT /skills/:id", "POST /skills/:id/approve", + "DELETE /skills/:id", "POST /artifacts", "DELETE /artifacts/:id", + "PUT /agents/assignments", "POST /agents", "POST /agents/:id/clone", + "PUT /agents/:id", "POST /agents/:id/accounts/apikey", + "DELETE /agents/:id/accounts/:accountId", "DELETE /agents/:id", + "PATCH /agents/:id/reflection", "POST /agents/:id/reflection/run", + "POST /reflection/proposals/:id/approve", "POST /reflection/proposals/:id/reject", + "POST /memory/distill", "POST /memory/wiki-synthesize", "POST /secrets", + "DELETE /secrets/:id", "POST /cursor/api-key", "DELETE /cursor/api-key", + "POST /cursor/cli-login-url", "POST /cursor/use-for-intelligence", + "POST /select-model", "POST /start", "POST /stop", "POST /restart", + "POST /chats", "DELETE /chats/:id", "POST /chats/:id/share", "POST /chat", + "POST /chat/confirm-tool", "DELETE /chats/:chatId/messages/:messageId", + "POST /chats/:chatId/truncate", "POST /lora-adapters", "POST /adapters", + "PUT /adapters/:id", "DELETE /adapters/:id", "POST /queue", + "POST /queue/:id/cancel", "POST /workflows", "PUT /workflows/:id", + "DELETE /workflows/:id", "POST /workflows/:id/comments", + "POST /workflows/runs/:id/resume", "POST /workflows/runs/:id/cancel", + "POST /autonomous/kick", "POST /schedules", "PUT /schedules/:id", + "DELETE /schedules/:id", "POST /projects/cards", "PATCH /projects/cards/:id", + "DELETE /projects/cards/:id", "POST /projects/cards/:id/comments", + "POST /training/jobs", "POST /training/jobs/:id/cancel", "POST /datasets", + "POST /datasets/build", "POST /calendar/events", "PATCH /calendar/events/:id", + "DELETE /calendar/events/:id", + ], + }, + { + file: "routes/api-core.ts", + classification: "compatibility-shim", + target: "StructureNode Record API and StructureNode actions", + routes: [ + "PUT /structure/graph/layout", "POST /departments", "PUT /departments/:id", + "DELETE /departments/:id", "POST /departments/:dept/divisions", + "PUT /divisions/:dept/:id", "DELETE /divisions/:dept/:id", + "POST /divisions/:dept/:div/pages", "PUT /pages/:dept/:div/:id", + "DELETE /pages/:dept/:div/:id", "POST /nodes", "PUT /nodes/:id", + "DELETE /nodes/:id", "POST /nodes/:id/agent", "DELETE /nodes/:id/agent", + "POST /structure/reorder", + ], + }, + { + file: "routes/api-core.ts", + classification: "protocol-exception", + rationale: "Read-only analytical SQL uses POST so a structured query can be carried in the request body.", + routes: ["POST /analytics/timeseries/query"], + }, + { + file: "routes/auth.ts", + classification: "protocol-exception", + rationale: "Authentication, session, credential, profile, and tenant bootstrap operations are identity protocol endpoints.", + routes: [ + "POST /tenants", "POST /login", "POST /signup", "POST /logout", + "POST /change-password", "PATCH /profile", + ], + }, + { + file: "routes/connections.ts", + classification: "compatibility-shim", + target: "BridgeConnection and PeerConnection ObjectTypes plus federation execute action", + routes: [ + "POST /", "DELETE /:id", "POST /local", "POST /remote", + "POST /federation/execute", + ], + }, + { + file: "routes/dm.ts", + classification: "compatibility-shim", + target: "DirectConversation and DirectMessage ObjectTypes/actions", + routes: [ + "POST /conversations", "POST /conversations/:id/messages", + "POST /conversations/:id/read", "POST /conversations/:id/members", + "DELETE /conversations/:id/members/:userId", "POST /conversations/:id/share", + "POST /conversations/:id/typing", "POST /uploads", + ], + }, + { + file: "routes/federation.ts", + classification: "protocol-exception", + rationale: "Federation invitation acceptance and signed remote-command dispatch are wire-protocol operations.", + routes: ["POST /invites/:token/accept", "POST /sc/:verb"], + }, + { + file: "routes/financial.ts", + classification: "compatibility-shim", + target: "Holding connection ObjectTypes and refresh/connect actions", + routes: [ + "POST /config/moralis", "POST /config/paypal", "POST /connections", + "DELETE /connections/:id", "POST /connections/:id/refresh", + "POST /crypto/balance", "POST /crypto/connect", "POST /paypal/connect", + ], + }, + { + file: "routes/hooks.ts", + classification: "compatibility-shim", + target: "Hook, HookRun, and PlatformEvent ObjectTypes/actions", + routes: [ + "POST /", "PATCH /:id", "DELETE /:id", "POST /runs/:runId/approve", + "POST /runs/:runId/reject", "POST /", + ], + }, + { + file: "routes/inference.ts", + classification: "protocol-exception", + rationale: "Inference endpoint provisioning and model execution are compute protocol commands.", + routes: ["POST /endpoints", "POST /run"], + }, + { + file: "routes/integrations.ts", + classification: "protocol-exception", + rationale: "Calendar and email synchronization trigger external integration protocols.", + routes: ["POST /calendar/sync", "POST /email/sync"], + }, + { + file: "routes/marketplace-catalog.ts", + classification: "compatibility-shim", + target: "CatalogSource and CatalogInstall ObjectTypes plus plugin lifecycle actions", + routes: [ + "POST /sources", "DELETE /sources/:id", "POST /local-plugins", + "DELETE /local-plugins", "POST /plugins/install", "POST /plugins/uninstall", + "POST /install/:entryId", + ], + }, + { + file: "routes/marketplace.ts", + classification: "compatibility-shim", + target: "MarketplaceListing, MarketplaceEntitlement, and InferenceEndpoint ObjectTypes/actions", + routes: [ + "POST /entitlements/:id/cancel", "POST /wallet/purchase", "POST /wallet/checkout", + "POST /inference/endpoints", "POST /listings", "POST /listings/:id/acquire", + "POST /import", "POST /export", + ], + }, + { + file: "routes/network.ts", + classification: "protocol-exception", + rationale: "Tailscale and peer invitation endpoints orchestrate networking and federation protocols.", + routes: [ + "POST /tailscale/enable", "POST /peers/invite", "POST /peers/refresh", + "POST /share-invites", "POST /share-invites/accept", + ], + }, + { + file: "routes/notifications.ts", + classification: "compatibility-shim", + target: "Notification ObjectType actions mark-read, clear, and delete", + routes: ["POST /read", "POST /clear", "DELETE /:id"], + }, + { + file: "routes/onboarding.ts", + classification: "protocol-exception", + rationale: "First-run LLM setup and onboarding completion are installation lifecycle commands.", + routes: ["POST /llm/local", "POST /llm/cloud-ready", "POST /complete"], + }, + { + file: "routes/plugins.ts", + classification: "protocol-exception", + rationale: "Plugin install and uninstall mutate executable extension lifecycle state, not tenant records.", + routes: ["POST /install", "POST /uninstall"], + }, + { + file: "routes/shares.ts", + classification: "compatibility-shim", + target: "ShareGrant ObjectType plus share, clone, and live-resource actions", + routes: [ + "POST /", "POST /model", "DELETE /:id", "POST /live/:kind/:resourceId/mutate", + "POST /clone/:kind/:resourceId", + ], + }, + { + file: "routes/support.ts", + classification: "compatibility-shim", + target: "SupportTicket and SupportMessage ObjectTypes/actions", + routes: [ + "POST /tickets", "POST /tickets/:id/messages", "PATCH /tickets/:id", + "POST /group/members", "DELETE /group/members", "PATCH /admin/tickets/:id", + ], + }, + { + file: "routes/user-productivity.ts", + classification: "compatibility-shim", + target: "CalendarEvent, TaskCard, and CardComment ObjectTypes", + routes: [ + "POST /calendar/events", "PATCH /calendar/events/:id", + "DELETE /calendar/events/:id", "POST /projects/cards", + "PATCH /projects/cards/:id", "DELETE /projects/cards/:id", + "POST /projects/cards/:id/comments", + ], + }, + { + file: "routes/wiki.ts", + classification: "compatibility-shim", + target: "WikiPage and WikiProposal ObjectTypes/actions", + routes: [ + "POST /proposals/:id/approve", "POST /proposals/:id/reject", "POST /pages", + "PATCH /pages/:id", "DELETE /pages/:id", + ], + }, + { + file: "kernel/routes.ts", + classification: "kernel-record", + target: "Dynamic ObjectType create/update/delete Record operations", + routes: [ + "POST /records/:objectType", "PUT /records/:objectType/:id", + "DELETE /records/:objectType/:id", + ], + }, + { + file: "kernel/routes.ts", + classification: "kernel-action", + target: "Dynamic ObjectType declared action", + routes: [ + "POST /records/:objectType/actions/:action", + "POST /records/:objectType/:id/actions/:action", + ], + }, +]; + +const TOOL_GROUPS = { + "kernel-generic": [ + "list_object_types", "list_records", "get_record", "create_record", + "update_record", "delete_record", "run_record_action", + ], + "agent-foundation": [ + "remember", "use_skill", "delegate_to_subagent", "list_subagents", "todo_write", + "ask_cursor_agent", "create_skill", "create_rule", + ], + "web-and-artifacts": [ + "web_search", "fetch_url", "save_artifact", "read_artifact", "list_artifacts", + "delete_artifact", + ], + "tasks-and-personal": [ + "create_project_card", "move_project_card", "list_project_cards", "set_card_priority", + "create_subtask", "list_subtasks", "add_card_comment", "comment_card", + "list_card_comments", "list_user_calendar", "create_user_calendar_event", + "list_user_tasks", "create_user_task", "update_card", + ], + "structure-and-agents": [ + "list_structure", "create_department", "create_division", "create_page", + "update_structure_node", "delete_structure_node", "assign_agent", "set_agent_role", + "create_agent", "attach_node_agent", + ], + "sharing-and-automation": [ + "list_share_grants", "create_share_grant", "share_model", "revoke_share_grant", + "list_workflows", "run_workflow", "create_workflow", "update_workflow", + "list_schedules", "create_schedule", + ], + coding: [ + "read_file", "list_dir", "glob", "grep", "write_file", "edit_file", "delete_file", + "run_terminal", "codebase_search", "apply_patch", "read_diagnostics", "revert_file", + "explore_codebase", + ], + "notifications-support-knowledge": [ + "list_notifications", "create_notification", "mark_notification_read", + "create_support_ticket", "list_support_tickets", "reply_support_ticket", + "update_support_ticket", "list_wiki_pages", "read_wiki_page", "create_wiki_page", + "update_wiki_page", "delete_wiki_page", + ], + "messages-and-events": [ + "list_conversations", "read_conversation", "send_message", "create_conversation", + "list_hooks", "create_hook", "update_hook", "delete_hook", "list_hook_runs", + "emit_event", "list_events", + ], + "finance-marketplace-plugins": [ + "list_holdings", "get_net_worth", "create_holding", "refresh_holdings", + "search_marketplace", "list_my_listings", "create_listing", "install_catalog_entry", + "list_available_plugins", "scaffold_plugin", "install_plugin", "build_plugin", + "prepare_marketplace_submission", + ], + inference: [ + "get_llm_status", "list_models", "scan_models", "start_llm", "stop_llm", + "restart_llm", "list_inference_endpoints", + ], +}; + +function key(file, methodAndPath, occurrence = 1) { + return `${file}|${methodAndPath}|#${occurrence}`; +} + +function buildRouteBaseline() { + const entries = []; + const occurrences = new Map(); + for (const source of routeSources) { + for (const route of source.routes) { + const base = `${source.file}|${route}`; + const occurrence = (occurrences.get(base) ?? 0) + 1; + occurrences.set(base, occurrence); + entries.push({ + key: key(source.file, route, occurrence), + file: source.file, + route, + classification: source.classification, + ...(source.target ? { target: source.target } : {}), + ...(source.rationale ? { rationale: source.rationale } : {}), + }); + } + } + return entries; +} + +function discoverRoutes() { + const files = fs.readdirSync(routeRoot) + .filter((name) => name.endsWith(".ts")) + .map((name) => [path.join(routeRoot, name), `routes/${name}`]); + files.push([kernelRoutes, "kernel/routes.ts"]); + + const found = []; + for (const [absolute, relative] of files) { + const source = fs.readFileSync(absolute, "utf8"); + const routerNames = [ + ...new Set( + [...source.matchAll(/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:Router|express)\s*\(/g)] + .map((match) => match[1]) + ), + ]; + if (!routerNames.length) continue; + const names = routerNames.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|"); + const literalPattern = new RegExp( + `\\b(?:${names})\\s*\\.\\s*(post|put|patch|delete)\\s*\\(\\s*([\"'\`])([^\"'\`]+)\\2`, + "g" + ); + const declarationPattern = new RegExp( + `\\b(?:${names})\\s*\\.\\s*(post|put|patch|delete)\\s*\\(`, + "g" + ); + const declarationCount = [...source.matchAll(declarationPattern)].length; + const literals = [...source.matchAll(literalPattern)]; + if (declarationCount !== literals.length) { + throw new Error( + `${relative}: found ${declarationCount} mutation declarations but only ` + + `${literals.length} static string paths; classify dynamic declarations explicitly` + ); + } + const occurrences = new Map(); + for (const match of literals) { + const route = `${match[1].toUpperCase()} ${match[3]}`; + const occurrence = (occurrences.get(route) ?? 0) + 1; + occurrences.set(route, occurrence); + found.push(key(relative, route, occurrence)); + } + } + return found; +} + +function discoverStaticToolNames() { + const source = fs.readFileSync(toolRegistry, "utf8"); + const start = source.indexOf("export const AI_TOOL_REGISTRY"); + const end = source.indexOf("\n];", start); + if (start < 0 || end < 0) throw new Error("Could not locate AI_TOOL_REGISTRY"); + const registry = source.slice(start, end); + return [...registry.matchAll(/\bname:\s*(["'])([^"']+)\1/g)].map((match) => match[2]); +} + +function duplicates(values) { + const seen = new Set(); + return [...new Set(values.filter((value) => seen.has(value) || !seen.add(value)))]; +} + +const errors = []; +const baseline = buildRouteBaseline(); +const baselineKeys = baseline.map((entry) => entry.key); +const duplicateRoutes = duplicates(baselineKeys); +if (duplicateRoutes.length) errors.push(`Duplicate route baseline entries: ${duplicateRoutes.join(", ")}`); + +let discoveredRoutes = []; +try { + discoveredRoutes = discoverRoutes(); +} catch (error) { + errors.push(error instanceof Error ? error.message : String(error)); +} + +const baselineSet = new Set(baselineKeys); +const discoveredSet = new Set(discoveredRoutes); +const addedRoutes = discoveredRoutes.filter((route) => !baselineSet.has(route)); +const removedRoutes = baselineKeys.filter((route) => !discoveredSet.has(route)); +if (addedRoutes.length) errors.push(`Unclassified mutation routes:\n ${addedRoutes.join("\n ")}`); +if (removedRoutes.length) errors.push(`Removed/renamed baseline routes:\n ${removedRoutes.join("\n ")}`); + +for (const entry of baseline) { + if (!["kernel-record", "kernel-action", "compatibility-shim", "protocol-exception"].includes(entry.classification)) { + errors.push(`Invalid classification for ${entry.key}`); + } + if (!entry.target && !entry.rationale) errors.push(`Missing target/rationale for ${entry.key}`); +} + +const groupedToolNames = Object.values(TOOL_GROUPS).flat(); +const duplicateTools = duplicates(groupedToolNames); +if (duplicateTools.length) errors.push(`Tools assigned to multiple groups: ${duplicateTools.join(", ")}`); + +let staticTools = []; +try { + staticTools = discoverStaticToolNames(); +} catch (error) { + errors.push(error instanceof Error ? error.message : String(error)); +} +const staticToolSet = new Set(staticTools); +const groupedToolSet = new Set(groupedToolNames); +const ungroupedTools = staticTools.filter((name) => !groupedToolSet.has(name)); +const missingTools = groupedToolNames.filter( + (name) => !staticToolSet.has(name) && !TOOL_GROUPS["kernel-generic"].includes(name) +); +if (ungroupedTools.length) errors.push(`Ungrouped static AI tools: ${ungroupedTools.join(", ")}`); +if (missingTools.length) errors.push(`Removed/renamed grouped AI tools: ${missingTools.join(", ")}`); + +const autoSource = fs.readFileSync(autoTools, "utf8"); +const discoveredKernelGeneric = [...autoSource.matchAll(/\bname:\s*(["'])([^"']+)\1/g)] + .map((match) => match[2]); +const expectedKernelGeneric = TOOL_GROUPS["kernel-generic"]; +if ( + discoveredKernelGeneric.length !== expectedKernelGeneric.length || + discoveredKernelGeneric.some((name) => !expectedKernelGeneric.includes(name)) +) { + errors.push( + `Kernel generic tool baseline changed: discovered [${discoveredKernelGeneric.join(", ")}]` + ); +} +for (const marker of ["...genericObjectTypeToolDefs()", "objectTypeAutoToolDefs(coreNames)"]) { + const source = marker.startsWith("...") ? fs.readFileSync(toolRegistry, "utf8") : fs.readFileSync(toolRegistry, "utf8"); + if (!source.includes(marker)) errors.push(`Missing kernel/generated tool registration marker: ${marker}`); +} + +if (errors.length) { + console.error("Kernel migration coverage audit FAILED\n"); + for (const error of errors) console.error(`- ${error}`); + process.exitCode = 1; +} else { + const counts = baseline.reduce((acc, entry) => { + acc[entry.classification] = (acc[entry.classification] ?? 0) + 1; + return acc; + }, {}); + console.log( + `Kernel migration coverage audit passed: ${baseline.length} mutation routes ` + + `(${Object.entries(counts).map(([name, count]) => `${name}=${count}`).join(", ")}); ` + + `${staticTools.length} static AI tools in ${Object.keys(TOOL_GROUPS).length - 1} non-kernel groups; ` + + `${expectedKernelGeneric.length} kernel generic tools plus generated ObjectType tools.` + ); +} diff --git a/scripts/audit-oss-core.mjs b/scripts/audit-oss-core.mjs index 29916e9..f23a932 100644 --- a/scripts/audit-oss-core.mjs +++ b/scripts/audit-oss-core.mjs @@ -89,6 +89,9 @@ const DOC_SCAN_ALLOWLIST = new Set([ path.normalize("apps/bridge/src/services/structure-regroup-migration.ts"), path.normalize("apps/bridge/src/services/platform-scope.ts"), path.normalize("packages/plugin-api/src/host-services.ts"), + // Explicit boundary/audit documents may name private plugin domains. + path.normalize("docs/TRADING_RESIDUE_AUDIT.md"), + path.normalize("docs/SALES_PITCH.md"), ]); function scanDocForBannedTerms(rel) { diff --git a/scripts/hub-smoke-test.ps1 b/scripts/hub-smoke-test.ps1 index b971992..0d02461 100644 --- a/scripts/hub-smoke-test.ps1 +++ b/scripts/hub-smoke-test.ps1 @@ -48,6 +48,9 @@ try { } Test-Endpoint -Name "Auth me" -Path "/api/auth/me" -ExpectStatus @(200, 401) +Test-Endpoint -Name "ObjectType registry" -Path "/api/object-types" -ExpectStatus @(200, 401) +Test-Endpoint -Name "Structure ObjectType" -Path "/api/object-types/StructureNode" -ExpectStatus @(200, 401) +Test-Endpoint -Name "Structure records" -Path "/api/records/StructureNode?limit=1" -ExpectStatus @(200, 401) if ($deploymentMode -eq "hub") { Test-Endpoint -Name "Marketplace listings (auth required)" -Path "/api/marketplace/listings" -ExpectStatus @(401, 403) diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..3c10d27 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,22 @@ +import { fileURLToPath, URL } from "node:url"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: [ + "packages/kernel/src/__tests__/**/*.test.ts", + "packages/plugin-api/src/__tests__/**/*.test.ts", + "apps/bridge/src/kernel/__tests__/**/*.test.ts", + "apps/web/src/__tests__/**/*.test.ts", + "apps/web/src/pages/records/__tests__/**/*.test.tsx", + ], + environment: "node", + globals: false, + restoreMocks: true, + }, + resolve: { + alias: { + "@": fileURLToPath(new URL("./apps/web/src", import.meta.url)), + }, + }, +}); From 6d88e6684194d0f372376223012db1e815de0822 Mon Sep 17 00:00:00 2001 From: ReBotics AI Date: Tue, 14 Jul 2026 18:43:14 -0600 Subject: [PATCH 02/11] Document the ObjectType kernel architecture Make the deployed contracts, migration boundaries, plugin lifecycle, security limits, and verification workflow explicit for users, contributors, and agents. --- CHANGELOG.md | 13 ++ CONTRIBUTING.md | 15 +- DEPLOY.md | 13 ++ README.md | 17 +- .../ai/rules-bootstrap/platform-actions.mdc | 5 +- .../platform-builder-tiers.mdc | 21 ++- .../ai/rules-bootstrap/platform-plugins.mdc | 19 ++- .../ai/skills-bootstrap/object-types/SKILL.md | 40 +++++ .../platform-extension/SKILL.md | 22 ++- .../platform-workspace/SKILL.md | 16 +- .../plugin-authoring/SKILL.md | 27 ++- docs/FEATURES.md | 13 +- docs/KERNEL_MIGRATION_MATRIX.md | 99 +++++++++++ docs/MARKETPLACE.md | 18 +- docs/OBJECTTYPE_KERNEL.md | 159 ++++++++++++++++++ docs/PLUGIN_AUTHORING.md | 86 +++++++++- docs/README.md | 2 + docs/SECURITY.md | 23 +++ docs/SHARED_FEDERATION.md | 8 + docs/VERIFICATION.md | 44 +++++ docs/architecture.md | 62 ++++++- docs/multi-tenant-model.md | 35 +++- packages/kernel/README.md | 56 ++++++ 23 files changed, 751 insertions(+), 62 deletions(-) create mode 100644 apps/bridge/data/ai/skills-bootstrap/object-types/SKILL.md create mode 100644 docs/KERNEL_MIGRATION_MATRIX.md create mode 100644 docs/OBJECTTYPE_KERNEL.md create mode 100644 packages/kernel/README.md diff --git a/CHANGELOG.md b/CHANGELOG.md index b65e9e0..35315de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,14 @@ Post–0.1.0 work on `main` (merged PRs #1–#15). No new version tag yet — se ### Added +- **ObjectType metadata kernel** — 54 core ObjectTypes; explicit CRUD and named + action contracts; service-backed and native adapters; mandatory operation + context, access policies, confirmation, idempotency, concurrency, redaction, + durable declared-action events, and asynchronous `OperationRun` tracking +- **Kernel consumers and plugin lifecycle** — generic Record/action HTTP routes, + generated AI tools, capability discovery, metadata-driven web list/form UI, + ownership-safe tenant-scoped plugin registration with durable lifecycle + state/compensation and seed Records, and telemetry-backed migration targets - **Per-workspace FirstRunWizard** — onboarding (Welcome → LLM → Ready) is tenant-scoped; hub installs never share platform-wide completion flags - **`LLAMA_EXTERNAL` attach mode** — Bridge attaches to a host-managed `llama-server` (no spawn/kill inside Docker); hub external LLM compose example - **Unified Intelligence model catalog** — picker lists local GGUFs, Cursor subscription models, cloud providers, and shared/remote endpoints @@ -27,6 +35,9 @@ Post–0.1.0 work on `main` (merged PRs #1–#15). No new version tag yet — se - ~5s black screen after production sign-in - Embedding-only GGUFs (e.g. EmbeddingGemma) excluded from the Intelligence chat model picker - Vault Cursor Connect status (named `cursor_api_key` counts as connected; model-list errors no longer clear Connected) +- Structure Record listing now returns child plugin nodes when no parent filter is + requested; Structure action compatibility calls send the kernel's flat input + body ### Documentation @@ -34,6 +45,8 @@ Post–0.1.0 work on `main` (merged PRs #1–#15). No new version tag yet — se - [CURSOR_SUBSCRIPTION.md](docs/CURSOR_SUBSCRIPTION.md) — Auto vs named Cursor models - [AGENT_MEMORY.md](docs/AGENT_MEMORY.md) — memory layers and embeddings - Marketplace Docker hub notes; onboarding / verification updates +- [OBJECTTYPE_KERNEL.md](docs/OBJECTTYPE_KERNEL.md) — canonical architecture, + action contract, tenancy, compatibility strategy, and current limitations ## [0.1.0] - 2026-06-29 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ac2e104..1a76c5e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,14 +18,25 @@ Fresh clone = **personal OS only** (Intelligence, wiki, tasks, structure). Copy `DEPLOYMENT_MODE=local` (default) is for local development. **Authentication is required by default** (`AUTH_ALLOW_ANONYMOUS=false` in `.env.example`). Set `AUTH_ALLOW_ANONYMOUS=true` only for headless local tooling — never on a network-exposed host. -Run `npm run audit:oss` before release-related PRs. +Run `npm run audit:oss` before release-related PRs. Changes to authenticated +mutations, AI tools, ObjectTypes, adapters, or actions must also update the +kernel coverage baseline and contract tests. ## Pull requests - Keep changes focused; match existing code style. -- Run `npm run typecheck` before submitting. +- Run `npm run audit:kernel`, `npm run typecheck`, and `npm test` before + submitting; build affected production workspaces. - Do not commit secrets (`.env`, API keys, wallet keys). - Domain-specific integrations belong in **external plugin repos**, not the public core tree. +- Declare ObjectType operations/actions explicitly and keep adapter + implementations, schemas, roles, confirmation, idempotency, concurrency, + redaction, and event behavior consistent with the metadata. +- Preserve the authenticated `OperationContext` and tenant/plugin visibility; + custom plugin routes require explicit install checks. +- Document protocol exceptions rather than disguising transport or control-plane + operations as Record CRUD. See + [docs/OBJECTTYPE_KERNEL.md](docs/OBJECTTYPE_KERNEL.md). ## What we are looking for (roadmap themes) diff --git a/DEPLOY.md b/DEPLOY.md index 011a7d9..c751275 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -67,6 +67,19 @@ Open http://localhost:8080. Sign in with email and password. Workspace data stay Both compose files mount `PLATFORM_DATA_DIR=/data` (SQLite tenants, core DB, tenant sandboxes). +That volume also contains native ObjectType tables, durable operation/audit +state, event-consumer receipts, and Intelligence-authored plugins. Back up the +entire platform data directory before image upgrades, plugin lifecycle changes, +or ObjectType schema changes. A safe backup captures `core.sqlite`, every tenant +SQLite database (using SQLite's backup mechanism or while writers are stopped), +and tenant plugin workspaces. + +Native ObjectType evolution is additive only. Plugin uninstall removes runtime +visibility but retains native tables and Records, so uninstall is not an erasure +or space-reclamation operation. Verify `/api/health`, ObjectType discovery, +plugin navigation, a representative Record action, and legacy endpoint telemetry +after deployment; see [docs/VERIFICATION.md](docs/VERIFICATION.md). + ## Intelligence on hub New tenants get Intelligence with `backend=provider` (OpenAI-compatible). Users add API keys in **Vault → Secrets** and configure the provider in **Agents → Pipeline**. diff --git a/README.md b/README.md index f800c54..a649b73 100644 --- a/README.md +++ b/README.md @@ -60,11 +60,15 @@ flowchart TB subgraph client [Your machine] Web[Web Dashboard React] Bridge[Bridge API Node.js] + Kernel[ObjectType kernel] + Services[Authoritative services and adapters] CoreDb[(core.sqlite users and tenants)] TenantDb[(tenant.sqlite per workspace)] Web -->|REST and WebSocket| Bridge - Bridge --> CoreDb - Bridge --> TenantDb + Bridge --> Kernel + Kernel --> Services + Services --> CoreDb + Services --> TenantDb end subgraph intelligence [Intelligence loop] User[You] --> Chat[Chat panel] @@ -121,6 +125,12 @@ flowchart LR Core ships as a complete personal OS. Plugins add domain packs without forking the platform. See [docs/PLUGIN_AUTHORING.md](docs/PLUGIN_AUTHORING.md). +Authenticated data consumers discover explicit CRUD operations and named actions +through the ObjectType kernel. Existing domain services remain authoritative +behind adapters, so the migration standardizes dispatch without duplicating +business logic. Live chat token streaming is an intentional transport exception. +See [docs/OBJECTTYPE_KERNEL.md](docs/OBJECTTYPE_KERNEL.md). + ## Quick start **Requirements:** Node.js 20+, npm 10+ @@ -225,6 +235,7 @@ LLM and integration keys belong in **Vault** inside the app, not in `.env`, unle |-----------|------|------| | Web dashboard | `apps/web` | React UI - Chat panel, structure, productivity | | Bridge | `apps/bridge` | REST/WebSocket API, auth, multi-tenant SQLite | +| ObjectType kernel | `packages/kernel`, `apps/bridge/src/kernel` | Metadata registry, policies, Record CRUD/actions, native storage, generated tools | | Connector | `apps/connector` | Local runtime for hardware-bound marketplace plugins | | Plugin API | `packages/plugin-api` | Plugin manifest and register contracts | | Plugin host | `packages/plugin-host` | Runtime facades for plugins | @@ -244,7 +255,7 @@ Full documentation index: **[docs/README.md](docs/README.md)** |---|---| | **Get started** | [GETTING_STARTED](docs/GETTING_STARTED.md) · [ONBOARDING](docs/ONBOARDING.md) · [FEATURES](docs/FEATURES.md) · [LOCAL_LLM](docs/LOCAL_LLM.md) · [CURSOR](docs/CURSOR_SUBSCRIPTION.md) | | **Use GodMode** | [AGENT_MEMORY](docs/AGENT_MEMORY.md) · [MARKETPLACE](docs/MARKETPLACE.md) · [SHARED_FEDERATION](docs/SHARED_FEDERATION.md) · [CONFIGURATION](docs/CONFIGURATION.md) · [SECURITY](docs/SECURITY.md) | -| **Deploy & extend** | [DEPLOY](DEPLOY.md) · [architecture](docs/architecture.md) · [PLUGIN_AUTHORING](docs/PLUGIN_AUTHORING.md) (contributors) | +| **Deploy & extend** | [DEPLOY](DEPLOY.md) · [architecture](docs/architecture.md) · [ObjectType kernel](docs/OBJECTTYPE_KERNEL.md) · [PLUGIN_AUTHORING](docs/PLUGIN_AUTHORING.md) (contributors) | | **Project** | [CHANGELOG](CHANGELOG.md) · [CONTRIBUTING](CONTRIBUTING.md) (includes roadmap themes) | ## License diff --git a/apps/bridge/data/ai/rules-bootstrap/platform-actions.mdc b/apps/bridge/data/ai/rules-bootstrap/platform-actions.mdc index f414428..a9c2682 100644 --- a/apps/bridge/data/ai/rules-bootstrap/platform-actions.mdc +++ b/apps/bridge/data/ai/rules-bootstrap/platform-actions.mdc @@ -3,7 +3,10 @@ description: Prefer platform tools over manual UI instructions alwaysApply: true priority: 20 --- -Use platform tools to create and update workspace content from chat: create_department, create_page, create_agent, create_wiki_page, todo_write, create_project_card, etc. +Discover ObjectTypes and declared actions first. Prefer generic Record CRUD and +`run_record_action` over legacy static mutation tools; compatibility wrappers +remain for existing callers. Use specialized platform tools only when the +operation is not yet represented by the kernel contract. For multi-step work, plan with todo_write (parent Task + nested subtasks) before execution tools. diff --git a/apps/bridge/data/ai/rules-bootstrap/platform-builder-tiers.mdc b/apps/bridge/data/ai/rules-bootstrap/platform-builder-tiers.mdc index e527eea..e79badf 100644 --- a/apps/bridge/data/ai/rules-bootstrap/platform-builder-tiers.mdc +++ b/apps/bridge/data/ai/rules-bootstrap/platform-builder-tiers.mdc @@ -5,22 +5,25 @@ priority: 30 --- ## Tier 1 — Native workspace (default) -Wiki, structure shells, pages, agents, tasks, calendar hooks, marketplace catalog installs. +Wiki, StructureNode shells, pages, agents, tasks, calendar hooks, marketplace catalog installs. -- Call `use_skill('platform-workspace')` first. -- Tools only: create_*, wiki_*, todo_write, install_catalog_entry. +- Call `use_skill('platform-workspace')` first; for metadata use `use_skill('object-types')`. +- Tools: list/create_record, create_*, wiki_*, todo_write, install_catalog_entry. - Never edit `apps/bridge` for Tier 1 workspace setup. - **Do not** use bare `create_department` when the user implies an API, integration, hardware, or compiled extension — that is Tier 2. ## Tier 2 — Plugin extension (durable surface) -New routes, web UI areas, Bridge tools, department types, external APIs, integrations. - -- Call `use_skill('platform-extension')` + `use_skill('plugin-authoring')`. -- **`scaffold_plugin` first** — never only `create_department` for functional domains. -- Seed org structure in the plugin's `tenant:install` hook, then `build_plugin` → `install_plugin`. -- **Implement the code yourself** with read_file, grep, edit_file, run_terminal. +New ObjectTypes, routes, web UI areas, Bridge tools, department types, external APIs, integrations. +- Call `use_skill('platform-extension')` + `use_skill('plugin-authoring')` + `use_skill('object-types')`. +- **`scaffold_plugin` first** — ship metadata-native `objectTypes` / `records` + for straightforward CRUD; add executable adapters/actions when service logic + is required. Never only `create_department` for functional domains. +- Seed org structure as StructureNode Records, then `build_plugin` → `install_plugin`. +- **Implement the code yourself** with read_file, grep, edit_file, run_terminal when metadata is not enough. +- Verify action schemas/policies, implementation parity, tenant isolation, and + custom-route authentication before claiming completion. ## Native coding (codeAccess) Intelligence is the coding agent for multi-file refactors, plugin implementation, and repository engineering. diff --git a/apps/bridge/data/ai/rules-bootstrap/platform-plugins.mdc b/apps/bridge/data/ai/rules-bootstrap/platform-plugins.mdc index 8fe546d..db856f6 100644 --- a/apps/bridge/data/ai/rules-bootstrap/platform-plugins.mdc +++ b/apps/bridge/data/ai/rules-bootstrap/platform-plugins.mdc @@ -6,11 +6,18 @@ priority: 25 When the user needs **durable platform surface** OR **integration/API/hardware behavior**: 1. **Plugin first:** `use_skill('platform-extension')` then `scaffold_plugin` — do not only mutate tenant DB with `create_department`. -2. **Structure via install hook:** seed departments/divisions/pages in the plugin's `tenant:install` handler; run `install_plugin` so structure appears after install. -3. Install private plugins via Marketplace, `GODMODE_PLUGIN_PATH`, or a local catalog — see `docs/MARKETPLACE.md`. -4. After scaffold: `build_plugin`, set `GODMODE_PLUGIN_PATH`, restart Bridge, `install_plugin`. -5. Kanban/hook operational loops use `platform-self-loop` inside existing surface. +2. **ObjectTypes first:** use manifest-native definitions for straightforward + CRUD; register executable adapters and declared actions for service-backed + behavior. Implement every declared capability. Structure shells = + `StructureNode` Records. Vocabulary: ObjectType / Field / Record (not + DocType). +3. **Structure via install:** seed StructureNode Records in `records` or `tenant:install`; run `install_plugin`. +4. Install private plugins via Marketplace, `GODMODE_PLUGIN_PATH`, or a local catalog — see `docs/MARKETPLACE.md`. +5. After scaffold: `build_plugin` → `install_plugin` (no restart for tools / ObjectTypes / tenant:install). +6. Kanban/hook operational loops use `platform-self-loop` inside existing surface. +7. Plugin ObjectTypes are tenant-visible only when installed. Custom routes must + enforce authentication, tenancy, and installation checks explicitly. -**Examples:** YouTube division with API → plugin with tenant:install; wiki-only notes → Tier 1 wiki tools; compiled domain extension → Tier 2 plugin + native coding tools. +**Examples:** Accounting domain → plugin with ObjectTypes + optional bridge tools; wiki-only notes → Tier 1 wiki tools. -See `docs/PLUGIN_AUTHORING.md` and skill `plugin-authoring`. +See `docs/PLUGIN_AUTHORING.md`, `@godmode/kernel`, and skills `plugin-authoring` / `object-types`. diff --git a/apps/bridge/data/ai/skills-bootstrap/object-types/SKILL.md b/apps/bridge/data/ai/skills-bootstrap/object-types/SKILL.md new file mode 100644 index 0000000..d7188ec --- /dev/null +++ b/apps/bridge/data/ai/skills-bootstrap/object-types/SKILL.md @@ -0,0 +1,40 @@ +--- +name: object-types +description: Define and use GodMode ObjectTypes (metadata) — Fields, Records, auto CRUD — not DocTypes +tools: ["list_object_types", "list_records", "get_record", "create_record", "update_record", "delete_record", "run_record_action", "list_structure", "create_department", "create_division", "create_page"] +--- +# ObjectTypes (metadata kernel) + +GodMode extends through **ObjectTypes**, not compiled one-offs when possible. + +## Vocabulary + +- **ObjectType** — fields, access policy, explicit operations/actions, storage, + `contractVersion`, and `schemaVersion` +- **Field** — property on an ObjectType +- **Record** — one row/instance + +Never say DocType. Structure uses ObjectType **StructureNode**; department/division/page are tree roles. + +## Pipeline + +1. Prefer existing ObjectTypes: `list_object_types`. +2. Shell tree: prefer **StructureNode** Records via `create_record` + (`objectType: StructureNode`); legacy structure tools are compatibility + wrappers. +3. Domain data: plugins ship `objectTypes` + `records` seeds in `godmode.plugin.json`; compiled `bridge.entry` only when metadata is not enough. +4. Generic tools: use declared CRUD tools and `run_record_action` with + `objectType` set; inspect metadata before mutating. +5. For page kinds `record-list` / `record-form`, + `StructureNode.object_type` selects the ObjectType and `segment` remains the + URL segment. +6. Honor action schemas, roles, confirmation, idempotency, concurrency, and + sensitive-input metadata. Async actions return an `OperationRun`. +7. Plugin ObjectTypes are visible only to tenants where their owner is installed. + +## Tiering + +- **Tier 1:** StructureNode shells + wiki + tasks — native tools. +- **Tier 2:** New ObjectTypes via plugin scaffold + `objectTypes` in the manifest. + +One tool call at a time; confirm results before continuing. diff --git a/apps/bridge/data/ai/skills-bootstrap/platform-extension/SKILL.md b/apps/bridge/data/ai/skills-bootstrap/platform-extension/SKILL.md index 638d7b5..9750007 100644 --- a/apps/bridge/data/ai/skills-bootstrap/platform-extension/SKILL.md +++ b/apps/bridge/data/ai/skills-bootstrap/platform-extension/SKILL.md @@ -1,13 +1,17 @@ --- name: platform-extension -description: When adding durable platform surface (departments, routes, tools, integrations), scaffold a private Bridge plugin instead of only mutating tenant DB -tools: ["scaffold_plugin", "install_plugin", "list_available_plugins", "prepare_marketplace_submission", "build_plugin"] +description: When adding durable platform surface (ObjectTypes, departments, routes, tools, integrations), scaffold a Bridge plugin +tools: ["scaffold_plugin", "install_plugin", "list_available_plugins", "prepare_marketplace_submission", "build_plugin", "list_object_types", "create_record"] --- -1. **Plugin first** when the change adds routes, web pages, AI tools, department types, external APIs, or hardware integration. -2. Do **not** only call `create_department` for functional domains — structure belongs in the plugin's `tenant:install` hook. -3. Use `scaffold_plugin` with a kebab-case id and human name. Files land under `plugins//` inside the coding root (local: repo; hub: tenant workspace). Use returned `codingPath` with `edit_file`. -4. In `src/bridge.ts`, implement `tenant:install` to seed departments/divisions/pages for this plugin's domain. -5. Implement bridge/web entries → `build_plugin` (Bridge esbuild) → `install_plugin` (runtime load + tenant enable). **No** `GODMODE_PLUGIN_PATH` and **no** Bridge restart for tools / `tenant:install`. +1. **Plugin first** when the change adds ObjectTypes, routes, web pages, AI tools, department types, external APIs, or hardware integration. +2. Do **not** only call `create_department` for functional domains — declare StructureNode / domain Records in manifest `records` or seed them in `tenant:install`. +3. Prefer manifest-native `objectTypes` for straightforward tenant CRUD. Use + `api.objectTypes.register(definition, adapter)` for service-backed CRUD or + named actions and implement every declared capability. See skill + `object-types`. +4. Use `scaffold_plugin` with a kebab-case id and human name. Files land under `plugins//` inside the coding root. +5. Implement bridge/web entries → `build_plugin` → `install_plugin`. 6. For public packs, run `prepare_marketplace_submission` and open a PR to GodMode-Marketplace. -7. Kanban/hook loops for **operational** work inside existing surface still use the autonomous-task-runner skill. -8. Advanced Express `api.routes.mount` / `server:beforeListen` after boot may still need a Bridge restart — prefer tools + tenant hooks in v1 scaffolds. +7. Custom Express routes must enforce authentication, tenant membership, and + installed-plugin visibility explicitly. Advanced mounts after boot may still + need a Bridge restart — prefer tools + ObjectTypes + tenant hooks in v1. diff --git a/apps/bridge/data/ai/skills-bootstrap/platform-workspace/SKILL.md b/apps/bridge/data/ai/skills-bootstrap/platform-workspace/SKILL.md index f8f4980..7c6b66e 100644 --- a/apps/bridge/data/ai/skills-bootstrap/platform-workspace/SKILL.md +++ b/apps/bridge/data/ai/skills-bootstrap/platform-workspace/SKILL.md @@ -1,16 +1,20 @@ --- name: platform-workspace -description: Build basic workspace content with native tools only (wiki, structure shells, pages, agents, tasks) — no plugins -tools: ["create_department", "create_division", "create_page", "create_agent", "create_wiki_page", "update_wiki_page", "list_wiki_pages", "read_wiki_page", "todo_write", "create_project_card", "list_structure"] +description: Build basic workspace content with native tools only (wiki, StructureNode shells, pages, agents, tasks) — no plugins +tools: ["list_object_types", "list_records", "create_record", "run_record_action", "create_department", "create_division", "create_page", "create_agent", "create_wiki_page", "update_wiki_page", "list_wiki_pages", "read_wiki_page", "todo_write", "create_project_card", "list_structure"] --- Use this skill for **everyday workspace setup**. Stay in native tools; do not scaffold plugins unless the user clearly needs integration or API behavior. **Do here (Tier 1):** 1. `read_wiki_page` / `list_wiki_pages` when the user asks how something works. -2. `create_department` → `create_division` → `create_page` for **non-functional** org labels only (no API/integration implied). -3. `create_agent` for specialists; link via structure when appropriate. -4. `create_wiki_page` / `update_wiki_page` for guides and notes. -5. `todo_write` + `create_project_card` for tasks and automations. +2. Structure shells: prefer `create_record` with + `objectType: StructureNode`; department/division/page tools are legacy + compatibility wrappers for **non-functional** org labels only. +3. `list_object_types` / `use_skill('object-types')` discovers existing durable + shapes. Defining a new ObjectType is Tier 2 plugin work. +4. `create_agent` for specialists; link via structure when appropriate. +5. `create_wiki_page` / `update_wiki_page` for guides and notes. +6. `todo_write` + `create_project_card` for tasks and automations. **When integration/API/hardware is implied:** stop — call `use_skill('platform-extension')` and `scaffold_plugin` instead of bare structure tools. diff --git a/apps/bridge/data/ai/skills-bootstrap/plugin-authoring/SKILL.md b/apps/bridge/data/ai/skills-bootstrap/plugin-authoring/SKILL.md index e3e998c..172cf8e 100644 --- a/apps/bridge/data/ai/skills-bootstrap/plugin-authoring/SKILL.md +++ b/apps/bridge/data/ai/skills-bootstrap/plugin-authoring/SKILL.md @@ -1,12 +1,23 @@ --- name: plugin-authoring -description: Author GodMode Bridge plugins (manifest, register hooks, web entry, tenant install) -tools: ["scaffold_plugin", "install_plugin", "list_available_plugins", "prepare_marketplace_submission", "build_plugin", "git_status", "git_commit", "git_push", "gh_pr_create"] +description: Author GodMode Bridge plugins (manifest, ObjectTypes, register hooks, web entry, tenant install) +tools: ["scaffold_plugin", "install_plugin", "list_available_plugins", "prepare_marketplace_submission", "build_plugin", "list_object_types", "git_status", "git_commit", "git_push", "gh_pr_create"] --- 1. Read `docs/PLUGIN_AUTHORING.md` for manifest and register API shape. -2. `scaffold_plugin` creates `godmode.plugin.json`, `src/bridge.ts`, `src/web.ts`, and package.json under `plugins//`. -3. Bridge plugin exports `register(api)`; web plugin exports `registerWeb(api)`. -4. `build_plugin` compiles with Bridge esbuild (`src` → `dist`). No per-plugin `npm install` required. -5. `install_plugin` loads the plugin at runtime and enables it for the current tenant (same pipeline as Marketplace → Unofficial). No Bridge restart for tools / tenant:install. -6. Optional: add catalog entry via Official PR or Unofficial catalog URL. -7. When **Git** / **GitHub** Official plugins are installed, ship code changes with `git_status` → `git_add` → `git_commit` → `git_push` → `gh_pr_create` (do not invent raw shell git unless those tools are unavailable). +2. Prefer declaring **ObjectTypes**, explicit operations/actions, and optional + `records` seeds in `godmode.plugin.json` before hand-writing routes. + Vocabulary: ObjectType / Field / Record (not DocType). Call + `use_skill('object-types')`. +3. `scaffold_plugin` creates `godmode.plugin.json`, `src/bridge.ts`, `src/web.ts`, and package.json under `plugins//`. +4. Bridge plugin exports `register(api)`; web plugin exports `registerWeb(api)`. + Service-backed ObjectTypes use + `api.objectTypes.register(definition, adapter)` and implement every declared + CRUD operation/action. +5. `build_plugin` compiles with Bridge esbuild (`src` → `dist`). No per-plugin `npm install` required. +6. `install_plugin` registers owned ObjectTypes before seeding Records, loads the + plugin, and enables it for the tenant. Custom routes must enforce tenant and + installed-plugin checks. No Bridge restart for tools / tenant:install. +7. Declare strict action input/output schemas, roles, confirmation, idempotency, + concurrency, execution mode, and sensitive paths; verify tenant isolation. +8. Optional: add catalog entry via Official PR or Unofficial catalog URL. +9. When **Git** / **GitHub** Official plugins are installed, ship code changes with `git_status` → `git_add` → `git_commit` → `git_push` → `gh_pr_create`. diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 0181df0..35245ad 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -56,6 +56,7 @@ Longer architecture (working / semantic / episodic / procedural + wiki RAG): [AG | **Intelligence plugin pipeline** | Chat tools | `scaffold_plugin` → `build_plugin` → `install_plugin` for local/hub authoring. See [PLUGIN_AUTHORING.md](PLUGIN_AUTHORING.md). | | **Git / GitHub plugins** | Marketplace → Official | Structured `git_*` / `gh_*` tools for commit→PR→CI (requires host `git`/`gh`). See [MARKETPLACE.md](MARKETPLACE.md#official-devtools-plugins-git--github). | | **Connector** | `apps/connector` | Optional local process for hardware-bound marketplace plugins (desktop apps, devices). | +| **ObjectType Records** | `/records/:objectType` | Metadata-driven list/form pages and declared actions for core and installed-plugin domains. | ## Chat modes and commands @@ -72,6 +73,12 @@ Longer architecture (working / semantic / episodic / procedural + wiki RAG): [AG - **LLM backends:** local llama.cpp (spawned or `LLAMA_EXTERNAL` attach), Cursor Cloud, or provider keys in Vault. - **Multi-agent org:** scoped permissions, tool allowlists, and structure-linked agents. - **Workspace growth:** Intelligence can create departments, wiki pages, tasks, and automations from chat. -- **Plugins:** optional domain packs register bridge routes, web pages, and install hooks without forking core. - -See [architecture.md](architecture.md), [VERIFICATION.md](VERIFICATION.md), [MARKETPLACE.md](MARKETPLACE.md), [SHARED_FEDERATION.md](SHARED_FEDERATION.md), [ONBOARDING.md](ONBOARDING.md), [AGENT_MEMORY.md](AGENT_MEMORY.md), and [PLUGIN_AUTHORING.md](PLUGIN_AUTHORING.md). +- **ObjectType kernel:** authenticated web, agent, plugin, and HTTP consumers + discover explicit Record CRUD and named actions; adapters preserve existing + domain services and side effects. +- **Plugins:** optional domain packs register ObjectTypes, actions, bridge routes, + web pages, and install hooks without forking core. +- **Compatibility:** legacy mutation shims remain measurable during cutover; live + chat token streaming is an explicit transport exception. + +See [OBJECTTYPE_KERNEL.md](OBJECTTYPE_KERNEL.md), [architecture.md](architecture.md), [VERIFICATION.md](VERIFICATION.md), [MARKETPLACE.md](MARKETPLACE.md), [SHARED_FEDERATION.md](SHARED_FEDERATION.md), [ONBOARDING.md](ONBOARDING.md), [AGENT_MEMORY.md](AGENT_MEMORY.md), and [PLUGIN_AUTHORING.md](PLUGIN_AUTHORING.md). diff --git a/docs/KERNEL_MIGRATION_MATRIX.md b/docs/KERNEL_MIGRATION_MATRIX.md new file mode 100644 index 0000000..ae8783b --- /dev/null +++ b/docs/KERNEL_MIGRATION_MATRIX.md @@ -0,0 +1,99 @@ +# Kernel migration matrix + +This document is the human-readable companion to +`scripts/audit-kernel-coverage.mjs`. The script is the authoritative, +machine-readable baseline: it statically scans every TypeScript file in +`apps/bridge/src/routes` and `apps/bridge/src/kernel/routes.ts` for Express +`post`, `put`, `patch`, and `delete` declarations. + +The baseline currently covers 195 mutation-verb declarations: + +- **Kernel Record API:** 3 dynamic create/update/delete routes. +- **Kernel action API:** 2 dynamic declared-action routes (collection and + record target). +- **Compatibility shims:** 165 legacy or domain-specific routes with an + ObjectType/action migration target. +- **Protocol exceptions:** 25 routes whose boundary is not Record CRUD. + +The audit rejects both additions and removals. A route cannot disappear, +change verb/path, or be added until its baseline entry is deliberately updated +with one of the four classifications and a target or rationale. It also rejects +mutation declarations whose path is not a static string, so dynamic routing +cannot bypass review. + +## Domain coverage + +- **Platform structure (17 declarations):** legacy departments, divisions, + pages, and nodes target `StructureNode` Records and declared structure + actions. The generic kernel routes are the replacement boundary. +- **Intelligence and automation (75 declarations):** memories, rules, skills, + artifacts, agents, workflows, schedules, queues, datasets, chats, projects, + and calendar operations target their registered ObjectTypes or explicit + actions. +- **Identity and administration (17 declarations):** user/tenant membership + mutations target `User`, `Tenant`, and `TenantMembership`; authentication and + billing control-plane operations and onboarding remain protocol exceptions. +- **Collaboration and knowledge (40 declarations):** shares, direct messages, + notifications, support, wiki, hooks/events, and personal productivity target + the corresponding registered read models and service-backed ObjectTypes. +- **Marketplace, finance, and connectivity (41 declarations):** catalog, + listing, entitlement, holding, bridge, and peer routes have explicit domain + targets; remote execution and network orchestration remain protocol + exceptions. +- **Kernel-native boundary (5 declarations):** dynamic Record create, update, + delete, and both declared action execution targets are classified directly as + `kernel-record` or `kernel-action`. + +These counts describe route declarations, not unique public URLs. For example, +`routes/hooks.ts` contains two routers that each declare `POST /`; the baseline +tracks declaration occurrence so neither is silently collapsed. + +## Protocol exceptions + +Protocol exceptions are intentionally narrow and include a rationale in the +machine baseline: + +- authentication/session/credential flows and first-run onboarding; +- platform-admin billing configuration and provider checks; +- federation, Tailscale, peer invitations, and signed remote dispatch; +- inference execution and endpoint provisioning; +- external calendar/email synchronization; +- plugin install/uninstall lifecycle operations; +- the read-only analytical SQL endpoint, which uses POST to carry a structured + query body. + +An exception does not imply weaker authorization. It means the operation is a +control-plane, compute, transport, or executable-lifecycle command rather than +durable ObjectType Record CRUD. + +## AI tool inventory + +The same audit inventories the static `AI_TOOL_REGISTRY` without importing or +executing Bridge runtime code. + +- All 104 explicit static tools must appear in one of 10 named non-kernel + domain groups. A newly added static tool fails the audit until grouped. +- The 7 generic kernel tools are separately marked: + `list_object_types`, `list_records`, `get_record`, `create_record`, + `update_record`, `delete_record`, and `run_record_action`. +- The audit requires both kernel registration paths: + `genericObjectTypeToolDefs()` and `objectTypeAutoToolDefs(coreNames)`. + Per-ObjectType tools remain generated from registered ObjectTypes rather than + being copied into a stale hand-maintained list. +- Plugin tools are runtime registrations and are outside the static registry + baseline; their registration path remains tenant/plugin scoped. + +## Updating the inventory + +When a mutation route changes, update the embedded route baseline in +`scripts/audit-kernel-coverage.mjs` in the same change. Use: + +- `kernel-record` for generic Record create/update/delete; +- `kernel-action` for an action declared by an ObjectType adapter; +- `compatibility-shim` for a legacy/domain route with a concrete migration + target; +- `protocol-exception` only with a concise explanation of why Record/action + semantics do not fit. + +Do not place credentials, request bodies, tokens, endpoint secrets, or customer +data in this inventory. diff --git a/docs/MARKETPLACE.md b/docs/MARKETPLACE.md index 42da58c..8594a28 100644 --- a/docs/MARKETPLACE.md +++ b/docs/MARKETPLACE.md @@ -35,6 +35,20 @@ Plugin repos: [godmode-plugin-git](https://github.com/ReBoticsAI/godmode-plugin- Use **Marketplace → Installed** to review workspace plugins, uninstall, or remove registered local paths. +Plugin ObjectTypes and generated Record/action tools are visible only in +workspaces where the plugin is installed. Installation atomically validates and +replaces owned definitions before applying seed Records; lifecycle state, tenant +hooks, and knowledge import are separate durable compensated steps. Uninstall +removes runtime visibility and plugin-owned knowledge, but deliberately retains +native ObjectType tables and tenant Records for reinstall or recovery. It is not +a data erasure operation; export or delete retained data explicitly when +required. + +Plugin Bridge code runs with host privileges. Install only trusted sources, +review requested routes/actions and secret fields, and remember that custom +Express routes must enforce authentication, tenant membership, and installed +plugin checks themselves. + Each unofficial catalog must expose a `catalog/index.json` compatible with the official schema. You can point at a **local file catalog** (never leaves your machine): @@ -45,7 +59,7 @@ file:///C:/Users/you/my-catalog/catalog/index.json ## Private plugins -Three supported paths for plugins that are not public on GitHub: +Four supported paths for plugins that are not public on GitHub: ### 1. Local folder in the UI (recommended) @@ -86,7 +100,7 @@ GODMODE_PLUGIN_PATH=C:\dev\godmode-plugin-mine Restart Bridge, then install from **Marketplace → Unofficial** under **Plugins on this machine**. -Intelligence tools `scaffold_plugin` → `build_plugin` → `install_plugin` use the **same** activate path as Unofficial (persist path + runtime load + tenant install). Scaffolds live under the coding root at `plugins//` (on hub: under the tenant workspace on `/data`). No restart required for tools / `tenant:install`. +Intelligence tools `scaffold_plugin` → `build_plugin` → `install_plugin` use the **same** activate path as Unofficial (persist path + runtime load + tenant install). Scaffolds live under the coding root at `plugins//` (on hub: under the tenant workspace on `/data`). No restart is required for tools, live ObjectType/adapter registration, Record seeds, or `tenant:install`. ## Docker hub notes diff --git a/docs/OBJECTTYPE_KERNEL.md b/docs/OBJECTTYPE_KERNEL.md new file mode 100644 index 0000000..d3a95c1 --- /dev/null +++ b/docs/OBJECTTYPE_KERNEL.md @@ -0,0 +1,159 @@ +# ObjectType Kernel + +The ObjectType kernel is GodMode's contract layer between authenticated consumers +and the services or storage that own platform data. It gives the web app, agents, +plugins, and HTTP clients one discoverable vocabulary without replacing domain +business logic. + +## Vocabulary and contracts + +- An **ObjectType** declares a stable type name, module, fields, access policy, + supported CRUD operations, named actions, and its storage adapter. +- A **Field** describes one record value, including its type, validation, + required or secret status, relationships, and UI hints. +- A **Record** is an ObjectType instance. The portable `RecordRow` contract + guarantees `id`, `objectType`, and typed `data`; adapters may expose timestamps + or a resource version as additional fields. +- `contractVersion` revises the public metadata/action contract. + `schemaVersion` describes the storage schema supplied by an owner; it does not + perform compatibility checks or run migrations. +- The package and plugin API versions identify the executable contract. Consumers + must not infer behavior from metadata that the active kernel version does not + enforce. + +ObjectTypes must explicitly declare at least one CRUD operation or named action. +The registry validates definitions before exposing them. + +## Registration and storage + +Core definitions are grouped by domain under `apps/bridge/src/kernel/domains`. +At startup, the Bridge registers those definitions and binds each one to an +adapter. + +There are two storage models: + +1. **Adapter-backed ObjectTypes** delegate to existing authoritative services or + tables. This is the normal migration path for core domains because validation, + authorization, side effects, and integrations remain in their established + services. +2. **Native ObjectTypes** materialize SQLite tables from manifest field + definitions. Native schema evolution is additive only. Removing, renaming, or + changing the type of a field requires an explicit migration outside the + generic materializer. + +The deployed registry exposes 54 core ObjectTypes, including `StructureNode`. +This is a hybrid architecture: the kernel standardizes discovery and dispatch, +while domain adapters preserve authoritative business logic. + +## Operations and actions + +CRUD capabilities are opt-in through `operations`: `list`, `get`, `create`, +`update`, and `delete`. A request is rejected when the ObjectType does not +declare the requested operation. + +Actions represent domain behavior that does not fit CRUD. An action declares: + +- collection, record, or declared bulk target; +- read, write, destructive, or external effect; +- synchronous or asynchronous execution; +- input and output JSON Schemas; +- allowed roles and whether explicit confirmation is required; +- idempotency and optimistic-concurrency behavior; +- retry, cancellation, sensitive-input, audit, and event metadata where + applicable. + +The dispatcher validates action input and output, authorizes the caller, enforces +confirmation and declared concurrency checks, applies idempotency protection, +redacts sensitive values from audit data, and emits declared events. Metadata is +not a promise by itself: only behavior implemented by the current dispatcher and +adapter is enforced. + +Asynchronous actions return an `OperationRun`. Runs persist status, result or +error, and timestamps for audit and inspection. On restart, interrupted runs are +marked failed rather than resumed. + +Current limitations: retry policy, timeout, `errorSchema`, deprecation, custom +concurrency version fields, `bulk` behavior, and strict enforcement of +`cancellable` are declared metadata but are not generically executed by the +dispatcher. Idempotency expiry/retry and native optimistic version handling are +also limited. Adapters must not promise those behaviors until host enforcement +is added. + +## Security and tenancy + +Every kernel request carries an `OperationContext`, including tenant, user, +roles, source, request and idempotency keys, expected version, confirmation +state, installed plugin IDs, and system capability where applicable. + +Authorization is layered: + +1. authenticated route or trusted system entry point; +2. tenant visibility and ObjectType access policy; +3. operation or action role and confirmation policy; +4. adapter/service authorization and data scoping. + +Plugin ObjectTypes are visible only to tenants where their owning plugin is +installed. Definition replacement is ownership checked and atomic; core +lifecycle state, tenant seeds, hooks, and knowledge import are separate durable +steps with compensation, not one cross-database transaction. Native plugin +tables and records are intentionally retained on uninstall so reinstall and +recovery do not destroy tenant data. + +Secret fields and declared sensitive action paths are redacted from logs and +audit payloads. Plugin custom Express routes are outside generic Record dispatch; +their authors must enforce authentication, tenant boundaries, and installed +plugin visibility explicitly. + +Declared action events are stored durably. The relay records consumer receipts +so a named consumer can skip a previously completed durable event; this provides +idempotent processing, not exactly-once delivery. Generic `object.record.*` +notifications use the in-memory event bus. + +## Consumers + +The generic API exposes discovery plus: + +- CRUD at `/api/records/:objectType` and + `/api/records/:objectType/:id`; +- collection actions at + `/api/records/:objectType/actions/:action`; +- record actions at + `/api/records/:objectType/:id/actions/:action`. + +Action input is the request body itself, not `{ "input": ... }`. +Action clients provide `Idempotency-Key`, `If-Match`, and +`X-Kernel-Confirmation` headers when required by the declared contract. + +ObjectType metadata also powers generated AI CRUD/action tools, capability +discovery, plugin runtime registrations, and generic web list/form pages. +`StructureNode.object_type` chooses generic Record rendering; `segment` remains +the URL segment. + +## Compatibility and protocol exceptions + +The migration inventory classifies legacy HTTP mutations by their target. +Structure compatibility routes currently delegate to kernel operations so +existing clients keep working; most other entries are migration targets and have +not yet been replaced by kernel dispatch. Legacy mutation telemetry records +remaining use. A shim can be retired only after delegation/parity is verified +and usage remains at zero for a sustained observation period. + +Some transports cannot be represented as a normal Record response. Live chat +token streaming remains an explicit protocol exception: the session and model +lifecycle are kernel-discoverable, but the streaming connection keeps its +specialized protocol. Uploads and similar transport concerns may also remain +exceptions while durable domain state uses Records and actions. + +## Plugin author checklist + +1. Prefer manifest-native ObjectTypes for straightforward tenant data. +2. Register an executable adapter for service-backed behavior. +3. Declare only operations and actions the adapter implements. +4. Supply strict schemas, roles, confirmation, idempotency, concurrency, and + sensitive-input metadata. +5. Use generated discovery and action tools instead of adding static mutation + tools when possible. +6. Treat `tenantMigrations` as manifest metadata unless a specific host runner is + documented; it is not a general migration framework. +7. Back up persistent data before schema or deployment changes and test install, + uninstall, reinstall, and tenant isolation. diff --git a/docs/PLUGIN_AUTHORING.md b/docs/PLUGIN_AUTHORING.md index 595542d..ea986f4 100644 --- a/docs/PLUGIN_AUTHORING.md +++ b/docs/PLUGIN_AUTHORING.md @@ -38,27 +38,82 @@ This matches **Marketplace → Unofficial**. Custom Express routes registered vi "engine": "^0.1.0", "departments": ["my-domain"], "bridge": { "entry": "dist/bridge.js" }, - "web": { "entry": "dist/web.js" } + "web": { "entry": "dist/web.js" }, + "objectTypes": [ + { + "name": "Invoice", + "label": "Invoice", + "storage": { "kind": "native" }, + "operations": ["list", "get", "create", "update", "delete"], + "fields": [ + { "name": "id", "label": "Id", "fieldType": "Data", "required": true }, + { "name": "amount", "label": "Amount", "fieldType": "Float", "required": true } + ] + } + ], + "records": [ + { + "objectType": "StructureNode", + "data": { + "id": "my-domain", + "parent_id": null, + "label": "My Domain", + "icon": "box", + "kind": "placeholder" + } + } + ] } ``` - `engine` — semver range checked against host (`@godmode/plugin-api` `GODMODE_ENGINE_VERSION`) - `bridge.entry` — ESM module exporting `register(api)` or default - `web.entry` — ESM module exporting `registerWeb(api)` or default +- `objectTypes` — metadata **ObjectTypes** (Fields + storage). Prefer these for CRUD domains. Vocabulary is ObjectType / Field / Record — **not** DocType. See `@godmode/kernel`. +- `records` — declarative Record seeds applied on tenant install (before / with `tenant:install`). Structure shells should prefer seeding `StructureNode` Records here when possible. +- Manifest-native ObjectTypes receive native storage and generic CRUD from + metadata. Service-backed behavior requires an executable Bridge registration + that supplies an adapter and implements every declared operation/action. +- Defaults are intentionally narrow: declare supported `operations`, action + roles, confirmation, idempotency, schemas, concurrency, execution mode, and + sensitive input explicitly. +- `tenantMigrations` is parsed manifest metadata, not a general migration runner. + Run required versioned migrations from a reviewed lifecycle implementation. + +## ObjectType pipeline +``` +objectTypes in manifest → validate ownership → tenant-visible registration → native table or adapter → Record/action tools + list/form UI +``` + +Create shells by seeding `StructureNode` Records and set `object_type` when a +node should render a generic Record page; `segment` remains its URL segment. +Prefer ObjectType discovery and declared actions over legacy static mutation +tools. Use compiled `bridge.entry` only when metadata is not enough. ## Bridge register ```typescript import type { GodModePluginRegister } from "@godmode/plugin-api"; export const register: GodModePluginRegister = (api) => { + api.objectTypes.register(invoiceDefinition, { + list: (query, ctx) => listInvoices(query, ctx), + get: (id, ctx) => getInvoice(id, ctx), + create: (data, ctx) => createInvoice(data, ctx), + update: (id, data, ctx) => updateInvoice(id, data, ctx), + delete: (id, ctx) => deleteInvoice(id, ctx), + actions: { + approve: (id, input, ctx) => approveInvoice(id, input, ctx), + }, + }); + api.tools.register([ { name: "my_tool", description: "…", handler: async (args, ctx) => ({ ok: true }) }, ]); api.hooks.on("tenant:install", async ({ tenantId, host }) => { const db = host.getTenantDb(tenantId); - // seed structure, run migrations + // Run reviewed migrations or seed service-backed state. }); api.hooks.on("server:beforeListen", (ctx) => { @@ -69,6 +124,16 @@ export const register: GodModePluginRegister = (api) => { }; ``` +`api.objectTypes.register(definition, adapter)` is the executable path for +service-backed ObjectTypes. `PluginRecordAdapter` supports optional `list`, +`get`, `create`, `update`, `delete`, and named `actions`. Every operation/action +declared by the definition must have a matching adapter implementation; do not +declare capabilities the plugin cannot execute. + +HTTP action input is the direct JSON request body. Clients send +`Idempotency-Key`, `If-Match`, and `X-Kernel-Confirmation` headers when required +by the action contract. + ## Host SDK (`@godmode/plugin-host`) Injected at boot via `api.host`: @@ -133,12 +198,27 @@ There is no automatic sibling-repo discovery in OSS core. Prefer Intelligence or ## Per-tenant install -`tenant_plugins` records which plugins a workspace has installed. Bridge gates manifest, web bundles, and routes on this table. +`tenant_plugins` records which plugins a workspace has installed. Bridge gates +manifest access, web bundles, ObjectType visibility, and host-managed plugin +routes on this table. A custom Express route mounted directly by plugin code does +not automatically inherit that check; resolve authentication, tenant membership, +and plugin installation in the route. Install: Intelligence `install_plugin`, **Marketplace → Unofficial**, or `npm run plugins -- install --tenant `. Only the target plugin's `tenant:install` hook runs (not all plugins). +Definition replacement is ownership checked and atomic; a plugin cannot replace +another plugin's or core's ObjectType/adapter. Core lifecycle state, tenant +seeds, hooks, and knowledge import are durable compensated steps rather than one +cross-database transaction. Record seeds run only after their ObjectTypes are +available. Uninstall removes runtime visibility but deliberately retains native +tables and Records; plugin-owned rules and skills are removed as described +below. Treat retained data as tenant data for backup, export, and erasure. + +Mark secret fields and sensitive action input paths so audit records redact them. +Never store credentials in manifest seed Records or source-controlled defaults. + ## Build **Intelligence / Bridge:** `build_plugin` runs esbuild inside Bridge (`src/bridge.ts` → `dist/bridge.js`, optional web entry). `@godmode/plugin-api` and `@godmode/plugin-host` are externalized and linked to the host packages at load time. diff --git a/docs/README.md b/docs/README.md index da433bb..8b5ffe2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -29,8 +29,10 @@ Documentation for installing, using, and extending GodMode. |-------|-------------| | [../DEPLOY.md](../DEPLOY.md) | Docker hub/client deployment | | [architecture.md](architecture.md) | System design and data model | +| [OBJECTTYPE_KERNEL.md](OBJECTTYPE_KERNEL.md) | Canonical ObjectType, Record, action, adapter, tenancy, and compatibility contract | | [PLUGIN_AUTHORING.md](PLUGIN_AUTHORING.md) | Build and install plugins (contributors) | | [multi-tenant-model.md](multi-tenant-model.md) | Core vs tenant databases (contributors) | +| [KERNEL_MIGRATION_MATRIX.md](KERNEL_MIGRATION_MATRIX.md) | Audited mutation-route and AI-tool migration inventory | ## Project diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 8829f6f..64f546a 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -29,6 +29,29 @@ OSS core uses **email/password + HttpOnly session cookies** only. There is no OA | DuckDB analytics | SQL against attached timeseries | Platform admin only; SELECT-only subset | | Markdown rendering | `javascript:` links in assistant/wiki output | URL scheme allowlist in web UI | +## ObjectType kernel boundary + +Generic Record routes do not replace authentication or domain authorization. +Each call receives an `OperationContext` containing tenant, user, role, source, +installed plugin IDs, confirmation state, request/idempotency key, and expected +version where applicable. + +The dispatcher applies tenant visibility and ObjectType access policy, declared +operation/action roles, confirmation, JSON Schema validation, idempotency, and +optimistic concurrency before invoking an adapter. Adapters and authoritative +services remain responsible for resource-level checks and domain invariants. +Secret fields and declared sensitive action paths are redacted from audit data. +Asynchronous actions retain auditable `OperationRun` state. + +Plugin ObjectTypes are visible only when their owner is installed for the active +tenant. This protection does not automatically wrap a plugin's custom Express +routes; plugin authors must authenticate, resolve the tenant, and check +installation explicitly. Plugin Bridge code still runs with host privileges. + +Native ObjectType uninstall retains physical tables and Records to avoid +destructive data loss. Operators must include core and tenant SQLite files in +backups and handle erasure requirements explicitly. + ## Reporting Open a private security advisory on GitHub for vulnerabilities in the public core. Do not commit secrets, wallet keys, or operator `.env` files. diff --git a/docs/SHARED_FEDERATION.md b/docs/SHARED_FEDERATION.md index 2aaad07..766b19d 100644 --- a/docs/SHARED_FEDERATION.md +++ b/docs/SHARED_FEDERATION.md @@ -7,6 +7,8 @@ Share live resources (divisions, agents, models, plugin-backed pages) across sep ## Same Bridge Grants live in `core.sqlite`. Share by user email from any resource's share UI. +Share grants and other durable collaboration state are kernel Records dispatched +with tenant/user context and adapter-level authorization. ## Cross-home (Tailscale) @@ -20,6 +22,12 @@ When owner and grantee run on different machines: Federation API (`/api/federation/*`) proxies SC commands, health checks, and live resource access over the tailnet. +Federation invitations, signed remote dispatch, peer health, and live streaming +remain transport/control-plane protocol exceptions rather than generic Record +CRUD. That classification does not weaken authentication or authorization: +durable local state still uses kernel Records/actions and remote requests retain +their token, tenant, grant, and ownership checks. + ## Health Bridge refreshes peer health every five minutes and on demand via **Refresh peers**. diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md index 58d7aba..03fc275 100644 --- a/docs/VERIFICATION.md +++ b/docs/VERIFICATION.md @@ -125,6 +125,49 @@ See [MARKETPLACE.md](./MARKETPLACE.md#official-devtools-plugins-git--github). --- +## 8. ObjectType kernel regression checks + +Run the automated gate before deployment: + +```bash +npm run audit:kernel +npm run typecheck +npm test +npm run build --workspace @godmode/bridge +npm run build --workspace @godmode/web +``` + +`npm run test:gate` runs the package build, typecheck, kernel audit, and test +suite together. + +Then validate the production deployment manually: + +1. Build the production Docker image and confirm `/api/health` returns `ok`. +2. Sign in and inspect `/api/object-types` and `/api/kernel/capabilities`; the + core registry should expose 54 ObjectTypes including `StructureNode`. +3. Create, update, read, list, and delete a disposable Record through a supported + ObjectType. Confirm unsupported operations are rejected. +4. Execute a lifecycle action and verify invalid input, missing role, and missing + confirmation are rejected. +5. Start an asynchronous action and inspect its `OperationRun`. Cancellation + metadata is not yet generically enforced, so verify the specific adapter's + behavior and do not treat `cancellable` as a host guarantee. +6. Install a plugin with ObjectTypes, confirm its navigation and Records appear + only for that tenant, uninstall it, and confirm the runtime visibility is + removed while native data remains available for reinstall/recovery. +7. Exercise Structure navigation, generic Record list/form pages, and a declared + action from the web UI. +8. Confirm live chat still streams tokens; streaming is a protocol exception, + not a normal Record action response. +9. Inspect legacy mutation telemetry. Retire a compatibility shim only after + parity and sustained zero-use evidence. + +The Z440 production smoke is manual; Playwright is not part of CI. + +See [OBJECTTYPE_KERNEL.md](./OBJECTTYPE_KERNEL.md). + +--- + ## Related docs - [MARKETPLACE.md](./MARKETPLACE.md) @@ -135,5 +178,6 @@ See [MARKETPLACE.md](./MARKETPLACE.md#official-devtools-plugins-git--github). - [LOCAL_LLM.md](./LOCAL_LLM.md) - [CURSOR_SUBSCRIPTION.md](./CURSOR_SUBSCRIPTION.md) - [AGENT_MEMORY.md](./AGENT_MEMORY.md) +- [OBJECTTYPE_KERNEL.md](./OBJECTTYPE_KERNEL.md) - [../CHANGELOG.md](../CHANGELOG.md) - [../CONTRIBUTING.md](../CONTRIBUTING.md#what-we-are-looking-for-roadmap-themes) diff --git a/docs/architecture.md b/docs/architecture.md index dd7dd64..a028a48 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -10,6 +10,41 @@ GodMode is a **local-first personal OS**: a React dashboard talks to a Node.js B | Bridge | Node.js + Express + SQLite | REST/WebSocket API, auth, tenant routing, AI orchestration | | Connector | Node.js (optional) | Local runtime for hardware-bound marketplace plugins | | Plugins | npm packages / marketplace installs | Domain extensions registered at Bridge and Web boot | +| Kernel | `@godmode/kernel` + Bridge `kernel/` | Metadata ObjectTypes → storage adapters / native tables → Record CRUD tools + UI | + +## ObjectType kernel + +GodMode extends in-place via **ObjectTypes** (not DocTypes). The deployed core +registry contains 54 ObjectTypes, including `StructureNode`. + +```mermaid +flowchart LR + Consumers[Web, agents, plugins, HTTP clients] + Auth[Authentication and tenant resolution] + Kernel[ObjectType registry and dispatcher] + Adapters[Service-backed adapters] + Native[Native ObjectType storage] + Services[Authoritative domain services] + Databases[(core and tenant SQLite)] + Consumers --> Auth --> Kernel + Kernel --> Adapters --> Services --> Databases + Kernel --> Native --> Databases +``` + +Core and plugin definitions declare fields, policies, explicit CRUD operations, +and named actions. The Bridge validates and registers them, then binds either an +adapter or additive native storage. Metadata drives generic Record routes, +generated AI tools, capability discovery, and web list/form pages. + +Service-backed adapters preserve existing business rules and side effects. +Structure compatibility routes already delegate legacy mutations into the same +dispatcher. The coverage inventory assigns migration targets to the remaining +legacy routes, while telemetry measures callers. Live chat token streaming +remains a specialized protocol, although its durable lifecycle state is +kernel-visible. + +See [OBJECTTYPE_KERNEL.md](OBJECTTYPE_KERNEL.md) for the complete action, +security, tenancy, storage, and compatibility contract. ## Data storage @@ -51,17 +86,25 @@ Physical file separation provides tenant isolation; most tenant tables omit a re sequenceDiagram participant Browser participant Bridge + participant Kernel as ObjectType kernel + participant Service as Adapter/service participant CoreDb as core.sqlite participant TenantDb as tenant.sqlite Browser->>Bridge: HTTP /api/... + session cookie Bridge->>CoreDb: Resolve user session Bridge->>CoreDb: Validate tenant membership - Bridge->>TenantDb: Open workspace DB + Bridge->>Kernel: OperationContext + CRUD/action + Kernel->>Service: authorized validated dispatch + Service->>CoreDb: core-scoped operation, when declared + Service->>TenantDb: tenant-scoped operation, when declared Bridge->>Browser: JSON response ``` -Every authenticated request carries `{ userId, tenantId, role }`. Handlers use `getReqTenantDb(req)` — never a global operator database for tenant-scoped data. +Every kernel request carries an `OperationContext` derived from authenticated +user, tenant, role, source, confirmation, request/idempotency keys, version, and +installed-plugin context. Handlers use `getReqTenantDb(req)` — never a global +operator database for tenant-scoped data. WebSocket clients pass `?tenantId=` because browsers cannot set custom headers on the upgrade. @@ -122,17 +165,20 @@ Plugins ship a manifest (`godmode.plugin.json`) and register: - Bridge routes and tools - Web UI bundles (loaded from `/api/plugins/:id/web.js`) -- Optional migrations and seed data +- ObjectTypes, executable adapters/actions, and seed Records +- Optional lifecycle hooks and declared migration metadata -Discovery order: +Plugin path discovery order: -1. Marketplace-registered paths in `platform_meta.marketplace.plugin_paths` -2. Optional `GODMODE_PLUGIN_PATH` env var -3. Per-tenant `tenant_plugins` (**Marketplace** install/uninstall) +1. Optional `GODMODE_PLUGIN_PATH` env var +2. Marketplace-registered paths in `platform_meta.marketplace.plugin_paths` Intelligence can also **scaffold → build → install** plugins from chat ([PLUGIN_AUTHORING.md](PLUGIN_AUTHORING.md)). -See [PLUGIN_AUTHORING.md](PLUGIN_AUTHORING.md). +Kernel registration is ownership-safe and tenant visibility follows +`tenant_plugins`; installation is distinct from path discovery. Custom plugin +Express routes remain responsible for their own installed-plugin checks. See +[PLUGIN_AUTHORING.md](PLUGIN_AUTHORING.md). ## Deployment modes diff --git a/docs/multi-tenant-model.md b/docs/multi-tenant-model.md index ff5b04d..a8de032 100644 --- a/docs/multi-tenant-model.md +++ b/docs/multi-tenant-model.md @@ -12,6 +12,7 @@ Global platform state shared across all workspaces: |-------------|---------| | `users`, `sessions` | Email/password identity and auth | | `tenants`, `tenant_memberships` | Workspaces and roles | +| `tenant_plugins` | Durable per-workspace plugin lifecycle state | | `credit_wallets`, `credit_ledger` | Platform economy | | `marketplace_listings`, `marketplace_purchases`, `marketplace_entitlements` | Marketplace | | `share_grants` | Cross-tenant resource sharing | @@ -19,6 +20,7 @@ Global platform state shared across all workspaces: | `inference_endpoints`, `inference_usage` | Metered inference products | | `bridge_connections` | Local/remote Bridge federation registry | | `platform_meta` | Bootstrap flags | +| `legacy_endpoint_usage` | Legacy mutation telemetry for retirement decisions | Legacy `oauth_accounts` rows may exist from older installs; OSS core no longer writes to this table. @@ -28,10 +30,13 @@ One SQLite file per workspace. Physical file selection provides isolation; most | Table group | Purpose | |-------------|---------| -| `departments`, `divisions`, `division_pages` | Navigation structure | +| `structure_nodes` | Navigation structure and generic Record page metadata | | `ai_agents`, `ai_chats`, `ai_messages`, `ai_memories`, … | AI workspace | | `holdings_*` | Financial connections | | Wiki, kanban, calendar, vault tables | Productivity | +| `gm_ot_*` | Native plugin ObjectType Records | +| `kernel_action_idempotency`, `kernel_operation_runs`, action logs | Kernel action execution and audit state | +| `events`, `event_consumer_receipts` | Durable declared-action events and consumer receipts | Domain-specific tables (trading, external integrations) are added by **plugins** when installed. @@ -43,6 +48,10 @@ Every HTTP request, WebSocket connection, and background job must carry: { userId: string; tenantId: string; role: MembershipRole } ``` +Kernel dispatch expands that identity into `OperationContext`, adding source, +installed plugin IDs, request and idempotency keys, expected version, +confirmation state, and trusted system capability where applicable. + ### HTTP - Client sends `X-Tenant-Id` (or `?tenantId=`). @@ -59,6 +68,27 @@ Every HTTP request, WebSocket connection, and background job must carry: - Queue rows include `tenant_id`; workers open `getTenantDb(tenantId)` per job. +### ObjectType routing + +An ObjectType declares whether it uses the core or tenant database. Tenant +ObjectTypes operate on the database selected after membership validation; core +ObjectTypes still receive caller and tenant context for policy checks. The +registry only exposes plugin-owned ObjectTypes to tenants where the plugin is +installed. + +Generic Record dispatch enforces ObjectType access policies and action roles, +then delegates to authoritative adapters/services for resource-level rules. +Custom plugin routes do not inherit this dispatch boundary and must perform the +same tenant and installation checks themselves. + +Native ObjectType tables are additive and remain in the tenant database after +plugin uninstall. Uninstall removes runtime visibility and plugin-owned +knowledge, not tenant data. Backups and explicit retention/erasure procedures +must account for these retained tables. + +Native ObjectTypes are always tenant-local. Core-database ObjectTypes require a +reviewed service-backed adapter. + ## Engine vs work (shared agents) When a user operates someone else's shared agent: @@ -87,7 +117,8 @@ Connections are registered in `bridge_connections` (core DB) and resolved at run 1. User signs up with **email and password** (`POST /auth/signup`). 2. Bridge creates a user row, session, and default workspace tenant. -3. `seedPersonalOsForNewTenant` provisions Life department placeholders and welcome wiki. +3. `seedPersonalOsForNewTenant` provisions core tenant data; the structure tree + intentionally starts empty until the user or Intelligence creates it. 4. If `INITIAL_ADMINS` is empty, `promoteFirstSignupAdmin` makes the first signup platform admin. Pre-seeded admins (`INITIAL_ADMINS=Name:email`) receive optional `INITIAL_ADMIN_PASSWORD` on first boot. diff --git a/packages/kernel/README.md b/packages/kernel/README.md new file mode 100644 index 0000000..cc9af5c --- /dev/null +++ b/packages/kernel/README.md @@ -0,0 +1,56 @@ +# @godmode/kernel + +GodMode **metadata kernel** vocabulary and helpers — shared by Bridge, web, plugins, and agents. + +## Vocabulary (not DocTypes) + +| Term | Meaning | +|------|---------| +| **ObjectType** | Metadata definition: fields, access policy, operations, actions, naming, and storage | +| **Field** | Typed property on an ObjectType, with validation and optional UI/secret metadata | +| **Record** | One ObjectType instance; portable rows guarantee `id`, `objectType`, and `data` | + +Department / division / page remain **UX labels** for tree roles on `StructureNode` Records. + +## Agent pipeline + +``` +authenticated consumer → ObjectType registry/policy → adapter or native storage → Record response/event +``` + +1. Declare an `ObjectTypeDef` (built-in or plugin `godmode.plugin.json` → `objectTypes`). +2. Bridge kernel registers it and materializes storage (`adapter` or `native` table). +3. Declare supported CRUD operations and named action contracts explicitly. +4. Agents use generated Record/action tools; web page kinds `record-list` and + `record-form` render metadata-driven UI. + +Built-in **StructureNode** is an adapter over `structure_nodes` — no duplicate +tree. `StructureNode.object_type` selects generic Record rendering, while +`segment` remains the URL segment. + +## Storage + +- **adapter** — wraps an existing service/table (`adapterId`) +- **native** — SQLite table `gm_ot_` (or custom `tableName`) + +Native schema evolution is additive only. `schemaVersion` is descriptive and +does not run destructive migrations. `contractVersion` independently revisions +the public metadata/action contract. + +## Actions and runtime context + +Actions declare collection/record target, effect, schemas, roles, confirmation, +idempotency, concurrency, execution mode, cancellation, redaction, and events. +The Bridge dispatcher validates and enforces supported metadata before invoking +the adapter. Asynchronous work is represented by durable `OperationRun` Records. +Retry, timeout, `errorSchema`, custom concurrency version fields, bulk semantics, +and strict `cancellable` enforcement are currently descriptive metadata rather +than generic dispatcher behavior. + +Every operation requires an `OperationContext`; consumers must not bypass tenant, +role, confirmation, or installed-plugin checks. Plugin custom routes sit outside +generic dispatch and enforce those boundaries themselves. + +The complete host behavior, routes, compatibility policy, and current +limitations are documented in +[`docs/OBJECTTYPE_KERNEL.md`](../../docs/OBJECTTYPE_KERNEL.md). From 19024e366758237c9b57bad0e06487718a67ce52 Mon Sep 17 00:00:00 2001 From: ReBotics AI Date: Tue, 14 Jul 2026 18:49:00 -0600 Subject: [PATCH 03/11] Clarify remaining kernel documentation boundaries Close the final audit gaps around plugin authoring, compatibility classifications, security enforcement, deployment checks, and agent guidance. --- CHANGELOG.md | 2 ++ CONTRIBUTING.md | 8 ++++++-- DEPLOY.md | 3 +++ README.md | 7 +++++-- .../rules-bootstrap/platform-builder-tiers.mdc | 2 ++ .../platform-structure-scope.mdc | 3 +++ .../platform-extension/SKILL.md | 2 ++ .../platform-self-loop/SKILL.md | 17 ++++++++++------- .../skills-bootstrap/plugin-authoring/SKILL.md | 2 ++ docs/FEATURES.md | 2 ++ docs/KERNEL_MIGRATION_MATRIX.md | 5 +++++ docs/MARKETPLACE.md | 4 ++++ docs/PLUGIN_AUTHORING.md | 18 ++++++++++++++++++ docs/SECURITY.md | 5 +++++ docs/SHARED_FEDERATION.md | 2 ++ 15 files changed, 71 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 35315de..5b87809 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,8 @@ Post–0.1.0 work on `main` (merged PRs #1–#15). No new version tag yet — se - Marketplace Docker hub notes; onboarding / verification updates - [OBJECTTYPE_KERNEL.md](docs/OBJECTTYPE_KERNEL.md) — canonical architecture, action contract, tenancy, compatibility strategy, and current limitations +- [KERNEL_MIGRATION_MATRIX.md](docs/KERNEL_MIGRATION_MATRIX.md) — governed route + and AI-tool migration inventory ## [0.1.0] - 2026-06-29 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1a76c5e..9fe7e1a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,8 +25,9 @@ kernel coverage baseline and contract tests. ## Pull requests - Keep changes focused; match existing code style. -- Run `npm run audit:kernel`, `npm run typecheck`, and `npm test` before - submitting; build affected production workspaces. +- Run `npm run test:gate` before submitting kernel or route changes. + `npm run audit:kernel` and `npm run test:objecttypes` are available as focused + checks; build affected production workspaces. - Do not commit secrets (`.env`, API keys, wallet keys). - Domain-specific integrations belong in **external plugin repos**, not the public core tree. - Declare ObjectType operations/actions explicitly and keep adapter @@ -37,6 +38,9 @@ kernel coverage baseline and contract tests. - Document protocol exceptions rather than disguising transport or control-plane operations as Record CRUD. See [docs/OBJECTTYPE_KERNEL.md](docs/OBJECTTYPE_KERNEL.md). +- When mutation routes change, update both + `scripts/audit-kernel-coverage.mjs` and + `docs/KERNEL_MIGRATION_MATRIX.md`. ## What we are looking for (roadmap themes) diff --git a/DEPLOY.md b/DEPLOY.md index c751275..8b29dfc 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -80,6 +80,9 @@ or space-reclamation operation. Verify `/api/health`, ObjectType discovery, plugin navigation, a representative Record action, and legacy endpoint telemetry after deployment; see [docs/VERIFICATION.md](docs/VERIFICATION.md). +Startup reconciles installed-plugin ObjectTypes and seeds before serving tenant +traffic. Include `/api/kernel/capabilities` in the post-deploy smoke check. + ## Intelligence on hub New tenants get Intelligence with `backend=provider` (OpenAI-compatible). Users add API keys in **Vault → Secrets** and configure the provider in **Agents → Pipeline**. diff --git a/README.md b/README.md index a649b73..2e69171 100644 --- a/README.md +++ b/README.md @@ -120,10 +120,13 @@ flowchart LR Plugin[Domain plugin repo] Core --> PluginHost PluginHost -->|discover at runtime| Plugin - Plugin -->|registers routes tools UI| Core + Plugin -->|registers ObjectTypes actions routes tools UI| Core ``` -Core ships as a complete personal OS. Plugins add domain packs without forking the platform. See [docs/PLUGIN_AUTHORING.md](docs/PLUGIN_AUTHORING.md). +Core ships as a complete personal OS. Plugins add domain packs without forking +the platform. Plugin ObjectTypes are discoverable only for tenants where the +plugin is installed. See +[docs/PLUGIN_AUTHORING.md](docs/PLUGIN_AUTHORING.md). Authenticated data consumers discover explicit CRUD operations and named actions through the ObjectType kernel. Existing domain services remain authoritative diff --git a/apps/bridge/data/ai/rules-bootstrap/platform-builder-tiers.mdc b/apps/bridge/data/ai/rules-bootstrap/platform-builder-tiers.mdc index e79badf..995984c 100644 --- a/apps/bridge/data/ai/rules-bootstrap/platform-builder-tiers.mdc +++ b/apps/bridge/data/ai/rules-bootstrap/platform-builder-tiers.mdc @@ -21,6 +21,8 @@ New ObjectTypes, routes, web UI areas, Bridge tools, department types, external for straightforward CRUD; add executable adapters/actions when service logic is required. Never only `create_department` for functional domains. - Seed org structure as StructureNode Records, then `build_plugin` → `install_plugin`. +- Prefer generated ObjectType CRUD/action tools before adding static mutation + tools. - **Implement the code yourself** with read_file, grep, edit_file, run_terminal when metadata is not enough. - Verify action schemas/policies, implementation parity, tenant isolation, and custom-route authentication before claiming completion. diff --git a/apps/bridge/data/ai/rules-bootstrap/platform-structure-scope.mdc b/apps/bridge/data/ai/rules-bootstrap/platform-structure-scope.mdc index 3316b21..28e2307 100644 --- a/apps/bridge/data/ai/rules-bootstrap/platform-structure-scope.mdc +++ b/apps/bridge/data/ai/rules-bootstrap/platform-structure-scope.mdc @@ -4,6 +4,9 @@ alwaysApply: true priority: 12 --- GodMode organizes work in a department → division → page tree (Structure sidebar). +These labels are roles of `StructureNode` Records. +`StructureNode.object_type` selects generic Record rendering; `segment` is only +the URL component. When the user is on a page, treat platform context and live API data as ground truth for what they are viewing. Department-scoped rules apply when chatting from that department's pages. diff --git a/apps/bridge/data/ai/skills-bootstrap/platform-extension/SKILL.md b/apps/bridge/data/ai/skills-bootstrap/platform-extension/SKILL.md index 9750007..e4e5f40 100644 --- a/apps/bridge/data/ai/skills-bootstrap/platform-extension/SKILL.md +++ b/apps/bridge/data/ai/skills-bootstrap/platform-extension/SKILL.md @@ -15,3 +15,5 @@ tools: ["scaffold_plugin", "install_plugin", "list_available_plugins", "prepare_ 7. Custom Express routes must enforce authentication, tenant membership, and installed-plugin visibility explicitly. Advanced mounts after boot may still need a Bridge restart — prefer tools + ObjectTypes + tenant hooks in v1. +8. Verify discovery, representative Record/action calls, adapter parity, and + tenant isolation after install. diff --git a/apps/bridge/data/ai/skills-bootstrap/platform-self-loop/SKILL.md b/apps/bridge/data/ai/skills-bootstrap/platform-self-loop/SKILL.md index 1abe1b1..aa6e688 100644 --- a/apps/bridge/data/ai/skills-bootstrap/platform-self-loop/SKILL.md +++ b/apps/bridge/data/ai/skills-bootstrap/platform-self-loop/SKILL.md @@ -1,12 +1,15 @@ --- name: platform-self-loop description: Set up Kanban-backed autonomous work with a schedule hook or workflow loop until stop conditions -tools: ["todo_write", "list_project_cards", "create_hook", "create_schedule", "run_workflow", "list_hooks", "update_hook", "run_agent"] +tools: ["list_object_types", "run_record_action", "todo_write", "list_project_cards", "create_hook", "create_schedule", "run_workflow", "list_hooks", "update_hook", "run_agent"] --- 1. Plan with todo_write — breakdown: board setup → hook/schedule → verify loop → stop condition. -2. Ensure cards exist on the Kanban (todo_write or create_project_card). -3. Prefer run_workflow with workflowId autonomous-task-runner for the canonical loop, OR create_hook with triggerKind schedule and actionKind run_agent. -4. create_schedule on autonomous-task-runner at */30 * * * * if cron-based workflow execution is desired. -5. Each loop iteration: list_project_cards (backlog, limit 1), work the card, move to done. -6. STOP when the board is clear: update_hook enabled false or disable the schedule. -7. Verify in Automations tab (hooks/schedules) and list_hook_runs. +2. Discover `Workflow`, `Schedule`, `Hook`, and `OperationRun` actions first; + prefer declared actions where available and use legacy wrappers only for + compatibility. +3. Ensure cards exist on the Kanban (todo_write or create_project_card). +4. Prefer run_workflow with workflowId autonomous-task-runner for the canonical loop, OR create_hook with triggerKind schedule and actionKind run_agent. +5. create_schedule on autonomous-task-runner at */30 * * * * if cron-based workflow execution is desired. +6. Each loop iteration: list_project_cards (backlog, limit 1), work the card, move to done. +7. STOP when the board is clear: update_hook enabled false or disable the schedule. +8. Verify in Automations tab (hooks/schedules) and list_hook_runs. diff --git a/apps/bridge/data/ai/skills-bootstrap/plugin-authoring/SKILL.md b/apps/bridge/data/ai/skills-bootstrap/plugin-authoring/SKILL.md index 172cf8e..6c4ea22 100644 --- a/apps/bridge/data/ai/skills-bootstrap/plugin-authoring/SKILL.md +++ b/apps/bridge/data/ai/skills-bootstrap/plugin-authoring/SKILL.md @@ -19,5 +19,7 @@ tools: ["scaffold_plugin", "install_plugin", "list_available_plugins", "prepare_ installed-plugin checks. No Bridge restart for tools / tenant:install. 7. Declare strict action input/output schemas, roles, confirmation, idempotency, concurrency, execution mode, and sensitive paths; verify tenant isolation. + Native storage is tenant-local and additive-only; uninstall retains native + tables and Records. 8. Optional: add catalog entry via Official PR or Unofficial catalog URL. 9. When **Git** / **GitHub** Official plugins are installed, ship code changes with `git_status` → `git_add` → `git_commit` → `git_push` → `gh_pr_create`. diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 35245ad..cae0b7a 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -80,5 +80,7 @@ Longer architecture (working / semantic / episodic / procedural + wiki RAG): [AG web pages, and install hooks without forking core. - **Compatibility:** legacy mutation shims remain measurable during cutover; live chat token streaming is an explicit transport exception. +- **Generic structure pages:** `StructureNode.object_type` selects the Record + renderer; `segment` remains the URL component. See [OBJECTTYPE_KERNEL.md](OBJECTTYPE_KERNEL.md), [architecture.md](architecture.md), [VERIFICATION.md](VERIFICATION.md), [MARKETPLACE.md](MARKETPLACE.md), [SHARED_FEDERATION.md](SHARED_FEDERATION.md), [ONBOARDING.md](ONBOARDING.md), [AGENT_MEMORY.md](AGENT_MEMORY.md), and [PLUGIN_AUTHORING.md](PLUGIN_AUTHORING.md). diff --git a/docs/KERNEL_MIGRATION_MATRIX.md b/docs/KERNEL_MIGRATION_MATRIX.md index ae8783b..04f21c1 100644 --- a/docs/KERNEL_MIGRATION_MATRIX.md +++ b/docs/KERNEL_MIGRATION_MATRIX.md @@ -21,6 +21,11 @@ with one of the four classifications and a target or rationale. It also rejects mutation declarations whose path is not a static string, so dynamic routing cannot bypass review. +`compatibility-shim` is the audit classification for a concrete migration +target; it does not mean every classified handler already delegates through the +kernel. Structure mutations currently do, while the remaining targets stay +measured until cutover. + ## Domain coverage - **Platform structure (17 declarations):** legacy departments, divisions, diff --git a/docs/MARKETPLACE.md b/docs/MARKETPLACE.md index 8594a28..fa27f69 100644 --- a/docs/MARKETPLACE.md +++ b/docs/MARKETPLACE.md @@ -44,6 +44,10 @@ native ObjectType tables and tenant Records for reinstall or recovery. It is not a data erasure operation; export or delete retained data explicitly when required. +A manifest-only plugin can ship native `objectTypes` and seed `records` without +a `bridge.entry`. Executable adapters, actions, hooks, tools, or custom routes +require Bridge code. + Plugin Bridge code runs with host privileges. Install only trusted sources, review requested routes/actions and secret fields, and remember that custom Express routes must enforce authentication, tenant membership, and installed diff --git a/docs/PLUGIN_AUTHORING.md b/docs/PLUGIN_AUTHORING.md index ea986f4..19db4e7 100644 --- a/docs/PLUGIN_AUTHORING.md +++ b/docs/PLUGIN_AUTHORING.md @@ -43,6 +43,8 @@ This matches **Marketplace → Unofficial**. Custom Express routes registered vi { "name": "Invoice", "label": "Invoice", + "contractVersion": 1, + "schemaVersion": 1, "storage": { "kind": "native" }, "operations": ["list", "get", "create", "update", "delete"], "fields": [ @@ -130,6 +132,22 @@ service-backed ObjectTypes. `PluginRecordAdapter` supports optional `list`, declared by the definition must have a matching adapter implementation; do not declare capabilities the plugin cannot execute. +A representative action on `invoiceDefinition` is: + +```json +{ + "name": "approve", + "label": "Approve", + "target": "record", + "effect": "write", + "execution": "sync", + "roles": ["editor", "owner"], + "confirmation": { "required": true, "ttlSeconds": 300 }, + "idempotency": { "required": true }, + "inputSchema": { "type": "object", "additionalProperties": false } +} +``` + HTTP action input is the direct JSON request body. Clients send `Idempotency-Key`, `If-Match`, and `X-Kernel-Confirmation` headers when required by the action contract. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 64f546a..d1e7790 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -26,6 +26,7 @@ OSS core uses **email/password + HttpOnly session cookies** only. There is no OA | Federation API token | Remote command injection if token leaks | Rotate tokens; restrict network access | | First signup admin | Race on internet-exposed fresh installs | Use invite codes or pre-seed `INITIAL_ADMINS` | | Plugin bundles (`/api/plugins/*/web.js`) | Proprietary JS exposure | Requires authenticated tenant + installed plugin | +| Generic Records/actions (`/api/records/*`) | Cross-tenant or overbroad mutation | OperationContext, access/action policy, adapter scoping | | DuckDB analytics | SQL against attached timeseries | Platform admin only; SELECT-only subset | | Markdown rendering | `javascript:` links in assistant/wiki output | URL scheme allowlist in web UI | @@ -43,6 +44,10 @@ services remain responsible for resource-level checks and domain invariants. Secret fields and declared sensitive action paths are redacted from audit data. Asynchronous actions retain auditable `OperationRun` state. +Ordinary callers cannot forge `source: "system"`; trusted system dispatch +requires an internal capability. Retry, timeout, error-schema, and strict +cancellation metadata are not yet generic enforcement boundaries. + Plugin ObjectTypes are visible only when their owner is installed for the active tenant. This protection does not automatically wrap a plugin's custom Express routes; plugin authors must authenticate, resolve the tenant, and check diff --git a/docs/SHARED_FEDERATION.md b/docs/SHARED_FEDERATION.md index 766b19d..59c3d7d 100644 --- a/docs/SHARED_FEDERATION.md +++ b/docs/SHARED_FEDERATION.md @@ -9,6 +9,7 @@ Share live resources (divisions, agents, models, plugin-backed pages) across sep Grants live in `core.sqlite`. Share by user email from any resource's share UI. Share grants and other durable collaboration state are kernel Records dispatched with tenant/user context and adapter-level authorization. +The local models are `ShareGrant`, `BridgeConnection`, and `PeerConnection`. ## Cross-home (Tailscale) @@ -27,6 +28,7 @@ remain transport/control-plane protocol exceptions rather than generic Record CRUD. That classification does not weaken authentication or authorization: durable local state still uses kernel Records/actions and remote requests retain their token, tenant, grant, and ownership checks. +ObjectType authorization alone never authorizes remote federation dispatch. ## Health From 17ee21ff4a6f6379b232c1a83969a1dffe8cc4c0 Mon Sep 17 00:00:00 2001 From: ReBotics AI Date: Tue, 14 Jul 2026 20:47:25 -0600 Subject: [PATCH 04/11] Complete durable ObjectType kernel migration Route all durable domain mutations through enforced kernel contracts, add recovery and security guarantees, and establish zero-debt completion gates so the architecture is safe to deploy and extend. --- .github/workflows/ci.yml | 1 + apps/bridge/src/bootstrap.ts | 115 +- apps/bridge/src/core-db.ts | 326 ++-- apps/bridge/src/db.ts | 300 ++- .../kernel/__tests__/action-contract.test.ts | 542 +++++- .../kernel/__tests__/adapter-parity.test.ts | 104 + .../__tests__/ai-tool-kernel-cutover.test.ts | 193 ++ .../kernel/__tests__/core-services.test.ts | 40 +- .../src/kernel/__tests__/events-relay.test.ts | 57 + .../kernel/__tests__/identity-admin.test.ts | 403 ++++ .../kernel/__tests__/platform-actions.test.ts | 194 +- .../kernel/__tests__/platform-config.test.ts | 161 ++ .../plugin-lifecycle-boundary.test.ts | 89 + .../src/kernel/__tests__/record-api.test.ts | 30 +- .../__tests__/route-authorization.test.ts | 121 ++ .../src/kernel/__tests__/route-waves.test.ts | 101 + .../kernel/__tests__/runtime-actions.test.ts | 361 +++- .../shared-productivity-access.test.ts | 386 ++++ apps/bridge/src/kernel/adapter-registry.ts | 9 + .../src/kernel/adapters/core-services.ts | 137 ++ .../src/kernel/adapters/identity-admin.ts | 1005 ++++++++++ .../src/kernel/adapters/platform-actions.ts | 1051 +++++++++- .../src/kernel/adapters/platform-config.ts | 240 +++ .../src/kernel/adapters/productivity.ts | 328 +++- apps/bridge/src/kernel/adapters/runtime.ts | 1014 +++++++++- apps/bridge/src/kernel/adapters/sql-read.ts | 10 +- .../src/kernel/adapters/structure-node.ts | 7 + apps/bridge/src/kernel/core-object-types.ts | 136 +- apps/bridge/src/kernel/domains/automation.ts | 5 +- .../src/kernel/domains/collaboration.ts | 4 + .../bridge/src/kernel/domains/intelligence.ts | 2 + apps/bridge/src/kernel/domains/marketplace.ts | 4 +- apps/bridge/src/kernel/domains/platform.ts | 109 +- .../bridge/src/kernel/domains/productivity.ts | 2 +- apps/bridge/src/kernel/domains/runtime.ts | 83 +- apps/bridge/src/kernel/index.ts | 13 +- apps/bridge/src/kernel/native-storage.ts | 59 +- .../bridge/src/kernel/operation-run-worker.ts | 286 +++ apps/bridge/src/kernel/protocol-exceptions.ts | 51 +- apps/bridge/src/kernel/record-api.ts | 1029 +++++++--- apps/bridge/src/kernel/registry.ts | 21 +- apps/bridge/src/kernel/routes.ts | 52 +- apps/bridge/src/plugins/activate-plugin.ts | 67 - apps/bridge/src/plugins/loader.ts | 104 - apps/bridge/src/plugins/plugin-host-bridge.ts | 30 +- apps/bridge/src/plugins/plugin-install.ts | 255 +-- apps/bridge/src/plugins/runtime.ts | 98 +- apps/bridge/src/routes/admin-billing.ts | 18 - apps/bridge/src/routes/admin-users.ts | 93 - apps/bridge/src/routes/ai.ts | 1441 +------------- apps/bridge/src/routes/api-core.ts | 349 ---- apps/bridge/src/routes/auth.ts | 220 +-- apps/bridge/src/routes/connections.ts | 128 -- apps/bridge/src/routes/dm.ts | 320 +--- apps/bridge/src/routes/federation.ts | 270 ++- apps/bridge/src/routes/financial.ts | 161 -- apps/bridge/src/routes/hooks.ts | 140 -- apps/bridge/src/routes/inference.ts | 50 - apps/bridge/src/routes/integrations.ts | 34 - apps/bridge/src/routes/marketplace-catalog.ts | 117 -- apps/bridge/src/routes/marketplace.ts | 223 +-- apps/bridge/src/routes/network.ts | 74 - apps/bridge/src/routes/notifications.ts | 31 - apps/bridge/src/routes/onboarding.ts | 40 - apps/bridge/src/routes/plugins.ts | 45 - apps/bridge/src/routes/shares.ts | 211 --- apps/bridge/src/routes/support.ts | 169 -- apps/bridge/src/routes/user-productivity.ts | 356 ---- apps/bridge/src/routes/wiki.ts | 90 - .../services/__tests__/db-boot-smoke.test.ts | 44 + .../__tests__/db-migrations-upgrade.test.ts | 375 ++++ .../__tests__/fixtures/historical-core.sql | 154 ++ .../__tests__/fixtures/historical-tenant.sql | 88 + .../__tests__/marketplace-acquisition.test.ts | 238 +++ .../bridge/src/services/ai-dataset-builder.ts | 53 + apps/bridge/src/services/ai-tool-executor.ts | 1080 ++++++----- apps/bridge/src/services/ai-tools-registry.ts | 115 +- apps/bridge/src/services/ai-workflows.ts | 67 + .../bridge/src/services/bridge-connections.ts | 32 + apps/bridge/src/services/capability-index.ts | 11 +- .../src/services/connection-resolver.ts | 4 + .../src/services/data-management-migration.ts | 21 +- apps/bridge/src/services/db-migrations.ts | 117 +- apps/bridge/src/services/events-relay.ts | 156 +- apps/bridge/src/services/federation-client.ts | 15 +- apps/bridge/src/services/llm-manager.ts | 29 +- .../src/services/marketplace-catalog.ts | 40 +- .../src/services/marketplace-listings.ts | 478 +++++ apps/bridge/src/services/plugin-lifecycle.ts | 375 ++++ .../src/services/sc-levels-migration.ts | 61 + apps/bridge/src/services/share-service.ts | 177 +- .../src/services/structure-nodes-migration.ts | 50 +- apps/connector/src/index.ts | 4 + apps/connector/src/local-connector.ts | 13 +- .../src/__tests__/consumer-cutover.test.ts | 239 +++ apps/web/src/api.ts | 1687 +++++++++-------- apps/web/src/lib/agent-work-api.ts | 59 + apps/web/src/lib/api-holdings.ts | 131 +- apps/web/src/lib/coding-agent-api.ts | 54 + apps/web/src/lib/object-types-api.ts | 91 +- apps/web/src/pages/Structure.tsx | 79 +- apps/web/src/pages/records/RecordFormPage.tsx | 156 +- apps/web/src/pages/records/RecordListPage.tsx | 138 +- apps/web/src/plugins/runtime.tsx | 44 + package.json | 6 +- packages/kernel/src/__tests__/schema.test.ts | 34 + packages/kernel/src/builtins.ts | 16 + packages/kernel/src/schema.ts | 71 +- packages/kernel/src/types.ts | 10 + .../plugin-api/src/__tests__/manifest.test.ts | 25 +- packages/plugin-api/src/bridge-api.ts | 42 + packages/plugin-api/src/host-services.ts | 12 + packages/plugin-api/src/index.ts | 8 + packages/plugin-api/src/kernel-client.ts | 4 + packages/plugin-api/src/manifest.ts | 18 + packages/plugin-api/src/web-api.ts | 49 + packages/web-host/src/index.ts | 2 + scripts/__tests__/audit-kernel.test.mjs | 162 ++ scripts/audit-kernel-coverage.mjs | 637 ++----- scripts/audit-kernel-direct-writes.mjs | 135 ++ scripts/audit-kernel-lib.mjs | 863 +++++++++ scripts/audit-kernel-strict.mjs | 18 + vitest.config.ts | 1 + 123 files changed, 15322 insertions(+), 7581 deletions(-) create mode 100644 apps/bridge/src/kernel/__tests__/adapter-parity.test.ts create mode 100644 apps/bridge/src/kernel/__tests__/ai-tool-kernel-cutover.test.ts create mode 100644 apps/bridge/src/kernel/__tests__/events-relay.test.ts create mode 100644 apps/bridge/src/kernel/__tests__/identity-admin.test.ts create mode 100644 apps/bridge/src/kernel/__tests__/platform-config.test.ts create mode 100644 apps/bridge/src/kernel/__tests__/plugin-lifecycle-boundary.test.ts create mode 100644 apps/bridge/src/kernel/__tests__/route-authorization.test.ts create mode 100644 apps/bridge/src/kernel/__tests__/route-waves.test.ts create mode 100644 apps/bridge/src/kernel/__tests__/shared-productivity-access.test.ts create mode 100644 apps/bridge/src/kernel/adapters/identity-admin.ts create mode 100644 apps/bridge/src/kernel/adapters/platform-config.ts create mode 100644 apps/bridge/src/kernel/operation-run-worker.ts delete mode 100644 apps/bridge/src/plugins/activate-plugin.ts create mode 100644 apps/bridge/src/services/__tests__/db-boot-smoke.test.ts create mode 100644 apps/bridge/src/services/__tests__/db-migrations-upgrade.test.ts create mode 100644 apps/bridge/src/services/__tests__/fixtures/historical-core.sql create mode 100644 apps/bridge/src/services/__tests__/fixtures/historical-tenant.sql create mode 100644 apps/bridge/src/services/__tests__/marketplace-acquisition.test.ts create mode 100644 apps/bridge/src/services/marketplace-listings.ts create mode 100644 apps/bridge/src/services/plugin-lifecycle.ts create mode 100644 apps/bridge/src/services/sc-levels-migration.ts create mode 100644 apps/web/src/__tests__/consumer-cutover.test.ts create mode 100644 apps/web/src/lib/agent-work-api.ts create mode 100644 apps/web/src/lib/coding-agent-api.ts create mode 100644 packages/plugin-api/src/kernel-client.ts create mode 100644 scripts/__tests__/audit-kernel.test.mjs create mode 100644 scripts/audit-kernel-direct-writes.mjs create mode 100644 scripts/audit-kernel-lib.mjs create mode 100644 scripts/audit-kernel-strict.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a26d399..c768b0d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,7 @@ jobs: - run: npm run build:packages - run: npm run typecheck - run: npm run audit:kernel + - run: npm run test:audit - run: npm test - run: npm run build -w @godmode/bridge - run: npm run build -w @godmode/web diff --git a/apps/bridge/src/bootstrap.ts b/apps/bridge/src/bootstrap.ts index d2b8427..d6b81c0 100644 --- a/apps/bridge/src/bootstrap.ts +++ b/apps/bridge/src/bootstrap.ts @@ -6,7 +6,7 @@ import { WebSocketServer } from "ws"; import { EventEmitter } from "node:events"; import "dotenv/config"; import { config } from "./config.js"; -import { initCoreDb } from "./core-db.js"; +import { initCoreDb, listAllTenantIds } from "./core-db.js"; import { getTenantDb, pinTenantDb, closeAllTenantDbs } from "./tenant-registry.js"; import { ensurePlatformBootstrap, ensureInitialAdmins, repairNonOperatorTenantStructure, removeLegacyLifeDepartmentFromPersonalTenants } from "./services/tenant-bootstrap.js"; import { tenantDbMiddleware, attachAuthContext, requireAuth } from "./services/auth/middleware.js"; @@ -16,11 +16,15 @@ import { createMarketplaceCatalogRouter } from "./routes/marketplace-catalog.js" import { createNetworkRouter } from "./routes/network.js"; import { createOnboardingRouter } from "./routes/onboarding.js"; import { createSharesRouter } from "./routes/shares.js"; +import { shareChatSession } from "./services/share-service.js"; import { createDmRouter } from "./routes/dm.js"; import { createUserProductivityRouter } from "./routes/user-productivity.js"; import { createConnectionsRouter } from "./routes/connections.js"; import { legacyEndpointTelemetry } from "./services/legacy-endpoint-telemetry.js"; -import { configureRuntimeAdapterServices } from "./kernel/adapters/runtime.js"; +import { + assertRuntimeAdapterServicesConfigured, + configureRuntimeAdapterServices, +} from "./kernel/adapters/runtime.js"; import { createFederationRouter } from "./routes/federation.js"; import { createInferenceRouter } from "./routes/inference.js"; import { createNotificationsRouter } from "./routes/notifications.js"; @@ -57,7 +61,7 @@ import { startDbMaintenance } from "./services/db-maintenance.js"; import { startRetentionScheduler } from "./services/retention.js"; import { startMarketplaceBillingScheduler } from "./services/marketplace-billing.js"; import { refreshPeerHealth } from "./services/federation-peers.js"; -import { startEventsRelay } from "./services/events-relay.js"; +import { startTenantEventsRelay } from "./services/events-relay.js"; import { initTimeseriesStore, backfillSqliteTimeseries, @@ -66,7 +70,6 @@ import { } from "./services/timeseries-store.js"; import { backfillMemoryFts } from "./services/vector-rag.js"; import { createWorkerPool } from "./services/worker-pool.js"; -import { loadPluginsFromEnv } from "./plugins/loader.js"; import { initPluginHost } from "./plugins/plugin-host-bridge.js"; import { pluginRuntime } from "./plugins/runtime.js"; import { getPluginHost } from "@godmode/plugin-host"; @@ -74,18 +77,16 @@ import { createCoreApiRouter } from "./routes/api-core.js"; import { createKernelRouter, createSystemOperationContext, + executeCollectionAction, + assertCoreObjectTypeBootstrapComplete, materializeAllNativeTypes, + OperationRunWorker, + processClaimedOperationRun, recoverInterruptedOperationRuns, registerCoreObjectTypes, setKernelEventBus, } from "./kernel/index.js"; import { createPluginsRouter, createPluginsManifestHandler } from "./routes/plugins.js"; -import { - ensureOperatorPluginsInstalled, - ensureTenantPluginsTable, - reconcileInstalledPluginObjectTypes, - syncInstalledPluginKnowledge, -} from "./plugins/plugin-install.js"; export async function startBridge(): Promise { const coreDb = initCoreDb(); @@ -95,12 +96,37 @@ repairNonOperatorTenantStructure(coreDb); removeLegacyLifeDepartmentFromPersonalTenants(coreDb); const db: AppDatabase = getTenantDb(operatorTenantId); pinTenantDb(operatorTenantId); +const tenantDatabases = (): Array<{ tenantId: string; db: AppDatabase }> => { + return listAllTenantIds(coreDb).map((tenantId) => ({ + tenantId, + get db() { + return getTenantDb(tenantId); + }, + })); +}; registerCoreObjectTypes(); materializeAllNativeTypes(db, createSystemOperationContext()); -recoverInterruptedOperationRuns(db); +for (const tenant of tenantDatabases()) { + try { + recoverInterruptedOperationRuns(tenant.db); + } catch (error) { + console.warn( + `[kernel] recovery failed for tenant ${tenant.tenantId}:`, + error instanceof Error ? error.message : error + ); + } +} initPluginHost(); -const pluginLoad = await loadPluginsFromEnv(); +const lifecycleContext = () => + createSystemOperationContext({ tenantId: operatorTenantId }); +const pluginLoad = (await executeCollectionAction( + db, + "CatalogInstall", + "load_runtime", + {}, + lifecycleContext() +)) as { loaded: string[]; errors: Array<{ path: string; error: string }> }; if (pluginLoad.loaded.length === 0) { console.log("[plugins] none loaded — personal OS only (set GODMODE_PLUGIN_PATH or install from Marketplace)"); } else { @@ -113,10 +139,13 @@ for (const err of pluginLoad.errors) { const bus = new EventEmitter(); setKernelEventBus(bus); pluginRuntime.configure({ operatorTenantId, bus }); -ensureTenantPluginsTable(coreDb); -await ensureOperatorPluginsInstalled(coreDb, operatorTenantId, db); -reconcileInstalledPluginObjectTypes(coreDb); -syncInstalledPluginKnowledge(coreDb, operatorTenantId); +await executeCollectionAction( + db, + "CatalogInstall", + "reconcile_runtime", + { operator_tenant_id: operatorTenantId }, + lifecycleContext() +); const hasSierra = pluginRuntime.hasPlugin("sierra-chart"); @@ -153,7 +182,7 @@ startRetentionScheduler(db); if (config.isHub) { startMarketplaceBillingScheduler(); } -startEventsRelay(db, bus); +const stopEventsRelay = startTenantEventsRelay(tenantDatabases, bus); const workerPool = createWorkerPool(); void initTimeseriesStore().then(async (ts) => { @@ -192,25 +221,10 @@ const aiQueueWorker = new AiQueueWorker(db, llmManager, { bus, embeddings: embeddingManager, }); -configureRuntimeAdapterServices({ - llm: llmManager, - queue: aiQueueWorker, - training: aiTraining, - async sendMessage() { - throw Object.assign( - new Error("Chat streaming remains available through the authorized protocol endpoint"), - { status: 501 } - ); - }, - async syncIntegration() { - return { - ok: true, - queued: true, - message: - "Integration sync queued through the configured provider scheduler", - }; - }, -}); +const operationRunWorker = new OperationRunWorker( + tenantDatabases, + processClaimedOperationRun +); if (hasSierra) { getPluginHost().registerAutonomousRunnerKick?.((reason) => { if (aiQueueWorker.hasPendingOrRunningWorkflow("autonomous-task-runner")) return; @@ -225,6 +239,32 @@ const aiScheduler = new AiScheduler(db, bus, aiQueueWorker); registerAiScheduler(aiScheduler); const reflectionService = new ReflectionService(db, bus, llmManager, aiQueueWorker); const memoryMaintenance = new MemoryMaintenanceService(db, bus, aiQueueWorker); +configureRuntimeAdapterServices({ + llm: llmManager, + queue: aiQueueWorker, + training: aiTraining, + embeddings: embeddingManager, + memoryMaintenance, + shareChat(input) { + return shareChatSession(coreDb, { + db: input.db, + chatId: input.chatId, + agentId: input.agentId, + tenantId: input.context.tenantId, + userId: input.context.userId, + }); + }, + async syncIntegration() { + return { + ok: true, + queued: true, + message: + "Integration sync queued through the configured provider scheduler", + }; + }, +}); +assertRuntimeAdapterServicesConfigured(); +assertCoreObjectTypeBootstrapComplete(); // Self-discovering engines: provision per-department subagents, rules, skills, // tools, context, and seed memory. Reconcile at boot (safety net) and on the @@ -381,6 +421,7 @@ server.listen(config.port, config.host, () => { } })(); aiQueueWorker.start(); + operationRunWorker.start(); aiScheduler.start(); reflectionService.start(); memoryMaintenance.start(); @@ -419,6 +460,8 @@ server.listen(config.port, config.host, () => { }); function gracefulShutdown() { + stopEventsRelay(); + operationRunWorker.stop(); workerPool.shutdown(); stopScheduler(); aiScheduler.stop(); diff --git a/apps/bridge/src/core-db.ts b/apps/bridge/src/core-db.ts index b35393b..b56caa3 100644 --- a/apps/bridge/src/core-db.ts +++ b/apps/bridge/src/core-db.ts @@ -5,6 +5,11 @@ import { config } from "./config.js"; import { configureDbPragmas, logDbConfig } from "./services/db-config.js"; import { backfillWelcomeWikiPages } from "./services/welcome-wiki.js"; import { ensurePlatformGroups } from "./services/platform-groups.js"; +import { + addCol, + runMigrations, + type Migration, +} from "./services/db-migrations.js"; function ensurePlatformGroupsTables(db: CoreDatabase): void { ensurePlatformGroups(db); @@ -171,6 +176,7 @@ export interface CoreShareGrant { role: ShareGrantRole; bridge_url: string | null; federation_token: string | null; + expires_at: string | null; created_at: string; updated_at: string; } @@ -290,6 +296,55 @@ export function initCoreDb(): CoreDatabase { CREATE INDEX IF NOT EXISTS marketplace_purchases_buyer_idx ON marketplace_purchases(buyer_user_id, created_at DESC); + -- Durable coordinator for clone acquisitions that span core + one tenant DB. + -- Each database commits only its owned writes, audit, and outbox atomically; + -- receipts allow the coordinator to safely resume after process failure. + CREATE TABLE IF NOT EXISTS marketplace_acquisition_operations ( + id TEXT PRIMARY KEY, + idempotency_key TEXT NOT NULL, + listing_id TEXT NOT NULL, + buyer_user_id TEXT NOT NULL, + buyer_tenant_id TEXT NOT NULL, + listing_bundle_json TEXT NOT NULL, + listing_title TEXT NOT NULL, + status TEXT NOT NULL, + imported_kind TEXT, + imported_id TEXT, + purchase_id TEXT, + last_error TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + completed_at TEXT, + UNIQUE (buyer_tenant_id, buyer_user_id, idempotency_key) + ); + CREATE INDEX IF NOT EXISTS marketplace_acquisition_status_idx + ON marketplace_acquisition_operations(status, updated_at); + CREATE TABLE IF NOT EXISTS marketplace_acquisition_steps ( + operation_id TEXT NOT NULL, + step_name TEXT NOT NULL, + payload_json TEXT NOT NULL, + completed_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (operation_id, step_name) + ); + CREATE TABLE IF NOT EXISTS marketplace_acquisition_audit ( + id TEXT PRIMARY KEY, + operation_id TEXT NOT NULL, + owner_database TEXT NOT NULL, + tenant_id TEXT NOT NULL, + action TEXT NOT NULL, + payload_json TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS marketplace_acquisition_outbox ( + id TEXT PRIMARY KEY, + operation_id TEXT NOT NULL, + tenant_id TEXT NOT NULL, + event_type TEXT NOT NULL, + payload_json TEXT NOT NULL, + published_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS share_grants ( id TEXT PRIMARY KEY, owner_tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, @@ -299,6 +354,7 @@ export function initCoreDb(): CoreDatabase { grantee_user_id TEXT REFERENCES users(id) ON DELETE CASCADE, grantee_tenant_id TEXT REFERENCES tenants(id) ON DELETE CASCADE, role TEXT NOT NULL DEFAULT 'viewer', + expires_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), CHECK ( @@ -620,18 +676,8 @@ export function initCoreDb(): CoreDatabase { ON wiki_revisions(page_id, created_at DESC); `); - ensureUsersIsAdminColumn(db); - ensureUsersPasswordHashColumn(db); - ensureUserProfileExtendedColumns(db); - ensureMarketplaceListingEconomyColumns(db); - ensureShareGrantFederationColumns(db); - ensureDmAgentColumns(db); - ensureDmMembersAgentFkFix(db); - ensureHooksRunWorkflowAction(db); - ensureWikiPerTenantSlugIndexes(db); - ensureOssPlatformV2Tables(db); + runMigrations(db, CORE_MIGRATIONS); ensurePlatformGroupsTables(db); - ensureWikiSearchAndProposals(db); backfillWelcomeWikiPages(db); @@ -650,6 +696,43 @@ export function initCoreDb(): CoreDatabase { return db; } +export const CORE_MIGRATIONS: readonly Migration[] = [ + { version: 1, name: "core_user_columns_v1", up: ensureCoreUserColumns }, + { version: 2, name: "core_marketplace_columns_v1", up: ensureCoreMarketplaceColumns }, + { version: 3, name: "core_dm_agent_columns_v1", up: ensureDmAgentColumns }, + { + version: 4, + name: "core_hooks_workflow_action_v1", + up: ensureHooksRunWorkflowAction, + foreignKeysOff: true, + }, + { + version: 5, + name: "core_dm_members_agent_fk_v1", + up: ensureDmMembersAgentFkFix, + foreignKeysOff: true, + }, + { + version: 6, + name: "core_wiki_tenant_slugs_v1", + up: ensureWikiPerTenantSlugIndexes, + foreignKeysOff: true, + }, + { version: 7, name: "core_wiki_search_v1", up: ensureWikiSearchAndProposals }, + { version: 8, name: "core_oss_platform_v2", up: ensureOssPlatformV2Tables }, +]; + +function ensureCoreUserColumns(db: CoreDatabase): void { + ensureUsersIsAdminColumn(db); + ensureUsersPasswordHashColumn(db); + ensureUserProfileExtendedColumns(db); +} + +function ensureCoreMarketplaceColumns(db: CoreDatabase): void { + ensureMarketplaceListingEconomyColumns(db); + ensureShareGrantFederationColumns(db); +} + /** * Idempotent migration: databases created before the `run_workflow` hook action * existed carry a CHECK constraint that rejects it. SQLite cannot alter a CHECK @@ -663,11 +746,8 @@ function ensureHooksRunWorkflowAction(db: CoreDatabase): void { .get() as { sql?: string } | undefined; if (!row?.sql || row.sql.includes("'run_workflow'")) return; - const wasOn = db.pragma("foreign_keys", { simple: true }) === 1; - db.pragma("foreign_keys = OFF"); - try { - db.exec(` - BEGIN; + db.exec(` + DROP TABLE IF EXISTS hooks_new; CREATE TABLE hooks_new ( id TEXT PRIMARY KEY, owner_kind TEXT NOT NULL CHECK (owner_kind IN ('user', 'agent')), @@ -693,30 +773,18 @@ function ensureHooksRunWorkflowAction(db: CoreDatabase): void { CREATE INDEX IF NOT EXISTS hooks_owner_idx ON hooks(owner_kind, owner_id); CREATE INDEX IF NOT EXISTS hooks_event_idx ON hooks(trigger_kind, enabled, event_type); - COMMIT; - `); - } catch (err) { - db.exec("ROLLBACK"); - throw err; - } finally { - db.pragma(`foreign_keys = ${wasOn ? "ON" : "OFF"}`); - } + `); } /** Idempotent migration for databases created before is_admin existed. */ function ensureUsersIsAdminColumn(db: CoreDatabase): void { - const cols = db.prepare("PRAGMA table_info(users)").all() as Array<{ name: string }>; - if (!cols.some((c) => c.name === "is_admin")) { - db.exec("ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0"); - } + addCol(db, "users", "is_admin", "INTEGER NOT NULL DEFAULT 0"); } /** Idempotent migration for extended user_profiles columns. */ function ensureUserProfileExtendedColumns(db: CoreDatabase): void { - const cols = db.prepare("PRAGMA table_info(user_profiles)").all() as Array<{ name: string }>; - const has = (name: string) => cols.some((c) => c.name === name); const add = (name: string, def: string) => { - if (!has(name)) db.exec(`ALTER TABLE user_profiles ADD COLUMN "${name}" ${def}`); + addCol(db, "user_profiles", name, def); }; add("emoji", "TEXT"); add("birthday", "TEXT"); @@ -731,50 +799,18 @@ function ensureUserProfileExtendedColumns(db: CoreDatabase): void { /** Idempotent migration for databases created before password_hash existed. */ function ensureUsersPasswordHashColumn(db: CoreDatabase): void { - const cols = db.prepare("PRAGMA table_info(users)").all() as Array<{ name: string }>; - if (!cols.some((c) => c.name === "password_hash")) { - db.exec("ALTER TABLE users ADD COLUMN password_hash TEXT"); - } + addCol(db, "users", "password_hash", "TEXT"); } /** Idempotent migration for marketplace economy columns. */ function ensureMarketplaceListingEconomyColumns(db: CoreDatabase): void { - const cols = db.prepare("PRAGMA table_info(marketplace_listings)").all() as Array<{ - name: string; - }>; - const has = (name: string) => cols.some((c) => c.name === name); - const add = (sql: string) => { - try { - db.exec(sql); - } catch (error) { - if (!/duplicate column name/i.test(String(error))) throw error; - } - }; - if (!has("delivery_mode")) { - add( - "ALTER TABLE marketplace_listings ADD COLUMN delivery_mode TEXT NOT NULL DEFAULT 'clone'" - ); - } - if (!has("pricing_model")) { - add( - "ALTER TABLE marketplace_listings ADD COLUMN pricing_model TEXT NOT NULL DEFAULT 'one_time'" - ); - } - if (!has("price_period")) { - add("ALTER TABLE marketplace_listings ADD COLUMN price_period TEXT"); - } - if (!has("meter_unit")) { - add("ALTER TABLE marketplace_listings ADD COLUMN meter_unit TEXT"); - } - if (!has("meter_rate")) { - add("ALTER TABLE marketplace_listings ADD COLUMN meter_rate INTEGER"); - } - if (!has("license")) { - add("ALTER TABLE marketplace_listings ADD COLUMN license TEXT"); - } - if (!has("inference_endpoint_id")) { - add("ALTER TABLE marketplace_listings ADD COLUMN inference_endpoint_id TEXT"); - } + addCol(db, "marketplace_listings", "delivery_mode", "TEXT NOT NULL DEFAULT 'clone'"); + addCol(db, "marketplace_listings", "pricing_model", "TEXT NOT NULL DEFAULT 'one_time'"); + addCol(db, "marketplace_listings", "price_period", "TEXT"); + addCol(db, "marketplace_listings", "meter_unit", "TEXT"); + addCol(db, "marketplace_listings", "meter_rate", "INTEGER"); + addCol(db, "marketplace_listings", "license", "TEXT"); + addCol(db, "marketplace_listings", "inference_endpoint_id", "TEXT"); } /** @@ -784,37 +820,12 @@ function ensureMarketplaceListingEconomyColumns(db: CoreDatabase): void { */ /** Idempotent migration: mixed human+agent conversation members and agent senders. */ function ensureDmAgentColumns(db: CoreDatabase): void { - const memberCols = db - .prepare("PRAGMA table_info(dm_conversation_members)") - .all() as Array<{ name: string }>; - const memberHas = (name: string) => memberCols.some((c) => c.name === name); - if (!memberHas("member_kind")) { - db.exec( - `ALTER TABLE dm_conversation_members ADD COLUMN member_kind TEXT NOT NULL DEFAULT 'user'` - ); - } - if (!memberHas("agent_id")) { - db.exec(`ALTER TABLE dm_conversation_members ADD COLUMN agent_id TEXT`); - } - if (!memberHas("agent_tenant_id")) { - db.exec(`ALTER TABLE dm_conversation_members ADD COLUMN agent_tenant_id TEXT`); - } - - const msgCols = db.prepare("PRAGMA table_info(dm_messages)").all() as Array<{ - name: string; - }>; - const msgHas = (name: string) => msgCols.some((c) => c.name === name); - if (!msgHas("sender_kind")) { - db.exec( - `ALTER TABLE dm_messages ADD COLUMN sender_kind TEXT NOT NULL DEFAULT 'user'` - ); - } - if (!msgHas("sender_agent_id")) { - db.exec(`ALTER TABLE dm_messages ADD COLUMN sender_agent_id TEXT`); - } - if (!msgHas("sender_agent_tenant_id")) { - db.exec(`ALTER TABLE dm_messages ADD COLUMN sender_agent_tenant_id TEXT`); - } + addCol(db, "dm_conversation_members", "member_kind", "TEXT NOT NULL DEFAULT 'user'"); + addCol(db, "dm_conversation_members", "agent_id", "TEXT"); + addCol(db, "dm_conversation_members", "agent_tenant_id", "TEXT"); + addCol(db, "dm_messages", "sender_kind", "TEXT NOT NULL DEFAULT 'user'"); + addCol(db, "dm_messages", "sender_agent_id", "TEXT"); + addCol(db, "dm_messages", "sender_agent_tenant_id", "TEXT"); } /** @@ -830,11 +841,8 @@ function ensureDmMembersAgentFkFix(db: CoreDatabase): void { .all() as Array<{ table: string }>; if (!fks.some((f) => f.table === "users")) return; - const wasOn = db.pragma("foreign_keys", { simple: true }) === 1; - db.pragma("foreign_keys = OFF"); - try { - db.exec(` - BEGIN; + db.exec(` + DROP TABLE IF EXISTS dm_conversation_members_new; CREATE TABLE dm_conversation_members_new ( conversation_id TEXT NOT NULL REFERENCES dm_conversations(id) ON DELETE CASCADE, user_id TEXT NOT NULL, @@ -857,33 +865,21 @@ function ensureDmMembersAgentFkFix(db: CoreDatabase): void { ALTER TABLE dm_conversation_members_new RENAME TO dm_conversation_members; CREATE INDEX IF NOT EXISTS dm_conversation_members_user_idx ON dm_conversation_members(user_id, conversation_id); - COMMIT; - `); - } catch (err) { - db.exec("ROLLBACK"); - throw err; - } finally { - db.pragma(`foreign_keys = ${wasOn ? "ON" : "OFF"}`); - } + `); } /** * Internal wiki slugs are unique per tenant; external slugs stay globally unique. */ function ensureWikiPerTenantSlugIndexes(db: CoreDatabase): void { - db.exec("BEGIN IMMEDIATE"); - try { - const migrated = db - .prepare( - `SELECT 1 FROM sqlite_master WHERE type='index' AND name='wiki_pages_tenant_visibility_slug_idx'` - ) - .get(); - if (migrated) { - db.exec("COMMIT"); - return; - } + const migrated = db + .prepare( + `SELECT 1 FROM sqlite_master WHERE type='index' AND name='wiki_pages_tenant_visibility_slug_idx'` + ) + .get(); + if (migrated) return; - db.exec(` + db.exec(` DROP TABLE IF EXISTS wiki_pages__migrated; CREATE TABLE wiki_pages__migrated ( id TEXT PRIMARY KEY, @@ -909,24 +905,13 @@ function ensureWikiPerTenantSlugIndexes(db: CoreDatabase): void { ON wiki_pages(tenant_id, visibility, slug); CREATE UNIQUE INDEX wiki_pages_external_slug_idx ON wiki_pages(slug) WHERE visibility = 'external'; - `); - db.exec("COMMIT"); - } catch (error) { - db.exec("ROLLBACK"); - throw error; - } + `); } /** Wiki hybrid RAG (FTS + embeddings) and staged synthesize proposals. */ function ensureWikiSearchAndProposals(db: CoreDatabase): void { - const cols = db.prepare("PRAGMA table_info(wiki_pages)").all() as Array<{ name: string }>; - const has = (name: string) => cols.some((c) => c.name === name); - if (!has("embedding")) { - db.exec("ALTER TABLE wiki_pages ADD COLUMN embedding BLOB"); - } - if (!has("embedding_dim")) { - db.exec("ALTER TABLE wiki_pages ADD COLUMN embedding_dim INTEGER"); - } + addCol(db, "wiki_pages", "embedding", "BLOB"); + addCol(db, "wiki_pages", "embedding_dim", "INTEGER"); db.exec(` CREATE VIRTUAL TABLE IF NOT EXISTS wiki_pages_fts USING fts5( @@ -957,22 +942,11 @@ function ensureWikiSearchAndProposals(db: CoreDatabase): void { } function ensureShareGrantFederationColumns(db: CoreDatabase): void { - const cols = db.prepare("PRAGMA table_info(share_grants)").all() as Array<{ - name: string; - }>; - const has = (name: string) => cols.some((c) => c.name === name); - if (!has("bridge_url")) { - db.exec("ALTER TABLE share_grants ADD COLUMN bridge_url TEXT"); - } - if (!has("federation_token")) { - db.exec("ALTER TABLE share_grants ADD COLUMN federation_token TEXT"); - } - if (!has("grantee_email")) { - db.exec("ALTER TABLE share_grants ADD COLUMN grantee_email TEXT"); - } - if (!has("grantee_peer_connection_id")) { - db.exec("ALTER TABLE share_grants ADD COLUMN grantee_peer_connection_id TEXT"); - } + addCol(db, "share_grants", "bridge_url", "TEXT"); + addCol(db, "share_grants", "federation_token", "TEXT"); + addCol(db, "share_grants", "grantee_email", "TEXT"); + addCol(db, "share_grants", "grantee_peer_connection_id", "TEXT"); + addCol(db, "share_grants", "expires_at", "TEXT"); } /** OSS v2: catalog installs, peer federation, support routing, onboarding. */ @@ -999,6 +973,21 @@ function ensureOssPlatformV2Tables(db: CoreDatabase): void { ); CREATE INDEX IF NOT EXISTS catalog_installs_tenant_idx ON catalog_installs(tenant_id, installed_at DESC); + CREATE TABLE IF NOT EXISTS tenant_plugins ( + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + plugin_id TEXT NOT NULL, + version TEXT NOT NULL, + installed_at TEXT NOT NULL DEFAULT (datetime('now')), + plugin_root TEXT, + state TEXT NOT NULL DEFAULT 'active', + desired_state TEXT NOT NULL DEFAULT 'active', + last_error TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (tenant_id, plugin_id) + ); + CREATE INDEX IF NOT EXISTS tenant_plugins_tenant_idx + ON tenant_plugins(tenant_id, installed_at); + CREATE TABLE IF NOT EXISTS peer_connections ( id TEXT PRIMARY KEY, local_user_id TEXT NOT NULL, @@ -1033,21 +1022,14 @@ function ensureOssPlatformV2Tables(db: CoreDatabase): void { CREATE INDEX IF NOT EXISTS federated_share_invites_token_idx ON federated_share_invites(invite_token); `); - const ticketCols = db.prepare("PRAGMA table_info(support_tickets)").all() as Array<{ - name: string; - }>; - const ticketHas = (name: string) => ticketCols.some((c) => c.name === name); - if (!ticketHas("target_kind")) { - db.exec( - `ALTER TABLE support_tickets ADD COLUMN target_kind TEXT NOT NULL DEFAULT 'resource_owner'` - ); - } - if (!ticketHas("shared_grant_id")) { - db.exec(`ALTER TABLE support_tickets ADD COLUMN shared_grant_id TEXT`); - } - if (!ticketHas("owner_user_id")) { - db.exec(`ALTER TABLE support_tickets ADD COLUMN owner_user_id TEXT`); - } + addCol( + db, + "support_tickets", + "target_kind", + "TEXT NOT NULL DEFAULT 'resource_owner'" + ); + addCol(db, "support_tickets", "shared_grant_id", "TEXT"); + addCol(db, "support_tickets", "owner_user_id", "TEXT"); } export function getCoreDb(): CoreDatabase { diff --git a/apps/bridge/src/db.ts b/apps/bridge/src/db.ts index e877172..8f103f1 100644 --- a/apps/bridge/src/db.ts +++ b/apps/bridge/src/db.ts @@ -1,14 +1,19 @@ import fs from "node:fs"; import Database from "better-sqlite3"; +import { v4 as uuidv4 } from "uuid"; import { config } from "./config.js"; import { seedIntelligenceAgent, removeDeprecatedBuiltinAgents, ensureAgentPrincipalDefaults, ensureIntelligenceDescription, ensureIntelligenceCodeAccess, ensureIntelligenceLocalBackendWhenExternalLlm, ensureAgentDescriptions, ensureAgentReflectionDefaults, ensureAgentAutoApproveDefaults, ensureSpecialistCodeAccess } from "./services/agents/agents-db.js"; -import { createSchedule } from "./services/ai-scheduler.js"; import { configureDbPragmas, logDbConfig, runForeignKeyCheck } from "./services/db-config.js"; -import { runPendingMigrations } from "./services/db-migrations.js"; +import { + addCol as addColumn, + registerMigration, + runPendingMigrations, +} from "./services/db-migrations.js"; import { registerDataManagementMigrations, ensureCapabilityTables } from "./services/data-management-migration.js"; import { registerStructureNodesMigration, cleanupProvisionedStructureAgents } from "./services/structure-nodes-migration.js"; import { registerStructureRegroupMigration } from "./services/structure-regroup-migration.js"; import { registerGroupTabsMigration } from "./services/group-tabs-migration.js"; +import { registerScLevelsMigration } from "./services/sc-levels-migration.js"; /** * Canonical autonomous task-runner workflow graph (executor-shaped). Stored in @@ -187,6 +192,10 @@ export function migrateTenantDb(db: Database.Database): void { registerStructureNodesMigration(); registerStructureRegroupMigration(); registerGroupTabsMigration(); + registerScLevelsMigration(); + for (const migration of TENANT_BOOT_MIGRATIONS) { + registerMigration(migration.version, migration.name, migration.up); + } db.exec(` CREATE TABLE IF NOT EXISTS playbooks ( @@ -483,21 +492,6 @@ export function migrateTenantDb(db: Database.Database): void { ON structure_nodes(parent_id, sort_order); `); - try { - db.exec(`ALTER TABLE structure_nodes ADD COLUMN object_type TEXT`); - } catch { - /* already migrated */ - } - - migrateTradeMirrorSchema(db); - migrateUnifiedDataSchema(db); - migrateScLevelsKey(db); - migrateScLevelsStyle(db); - migrateBacktestNativeSchema(db); - migratePlaybookGraphSchema(db); - migrateArchiveLessonsSchema(db); - createHoldingsSchema(db); - runPendingMigrations(db); ensureCapabilityTables(db); cleanupProvisionedStructureAgents(db); @@ -511,6 +505,20 @@ export function migrateTenantDb(db: Database.Database): void { export type AppDatabase = Database.Database; +export const TENANT_BOOT_MIGRATIONS = [ + { version: 7, name: "structure_object_type_v1", up: migrateStructureObjectType }, + { version: 8, name: "trade_mirror_columns_v1", up: migrateTradeMirrorSchema }, + { version: 9, name: "unified_data_schema_v1", up: migrateUnifiedDataSchema }, + { version: 10, name: "backtest_native_schema_v1", up: migrateBacktestNativeSchema }, + { version: 11, name: "playbook_graph_schema_v1", up: migratePlaybookGraphSchema }, + { version: 12, name: "archive_lessons_schema_v1", up: migrateArchiveLessonsSchema }, + { version: 13, name: "holdings_schema_v1", up: createHoldingsSchema }, +] as const; + +function migrateStructureObjectType(db: Database.Database): void { + addColumn(db, "structure_nodes", "object_type", "TEXT"); +} + function createHoldingsSchema(db: Database.Database): void { db.exec(` CREATE TABLE IF NOT EXISTS holdings_connections ( @@ -556,11 +564,7 @@ function createHoldingsSchema(db: Database.Database): void { function migrateArchiveLessonsSchema(db: Database.Database): void { const addCol = (table: string, col: string, def: string) => { - try { - db.exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${def}`); - } catch { - /* exists */ - } + addColumn(db, table, col, def); }; addCol("playbook_signal_state", "metadata_json", "TEXT"); @@ -634,20 +638,12 @@ function migrateArchiveLessonsSchema(db: Database.Database): void { } function migratePlaybookGraphSchema(db: Database.Database): void { - try { - db.exec(`ALTER TABLE playbooks ADD COLUMN graph_json TEXT`); - } catch { - /* exists */ - } + addColumn(db, "playbooks", "graph_json", "TEXT"); } function migrateBacktestNativeSchema(db: Database.Database): void { const addCol = (table: string, col: string, def: string) => { - try { - db.exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${def}`); - } catch { - /* exists */ - } + addColumn(db, table, col, def); }; addCol("backtest_runs", "trade_account", "TEXT"); @@ -682,58 +678,9 @@ function migrateBacktestNativeSchema(db: Database.Database): void { `); } -function migrateScLevelsKey(db: Database.Database): void { - // Rebuild sc_levels if it still uses the legacy (symbol,label) PK. - // Each external source (chart, study, subgraph) now owns its own row. - try { - const cols = db - .prepare("PRAGMA table_info(sc_levels)") - .all() as Array<{ name: string }>; - const hasSourceKey = cols.some((c) => c.name === "source_key"); - if (hasSourceKey) return; - db.exec(` - DROP TABLE IF EXISTS sc_levels; - CREATE TABLE sc_levels ( - symbol TEXT NOT NULL, - source_key TEXT NOT NULL, - label TEXT NOT NULL, - price REAL NOT NULL, - kind TEXT, - chart_number INTEGER, - study_id INTEGER, - subgraph_index INTEGER, - color TEXT, - line_width INTEGER, - ts TEXT, - updated_at TEXT NOT NULL DEFAULT (datetime('now')), - PRIMARY KEY (symbol, source_key) - ); - CREATE INDEX IF NOT EXISTS sc_levels_by_symbol ON sc_levels(symbol, price DESC); - `); - } catch (err) { - console.warn("[migrate] sc_levels rebuild failed", err); - } -} - -function migrateScLevelsStyle(db: Database.Database): void { - const addCol = (col: string, def: string) => { - try { - db.exec(`ALTER TABLE sc_levels ADD COLUMN ${col} ${def}`); - } catch { - /* column exists */ - } - }; - addCol("color", "TEXT"); - addCol("line_width", "INTEGER"); -} - function migrateTradeMirrorSchema(db: Database.Database): void { const addCol = (table: string, col: string, def: string) => { - try { - db.exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${def}`); - } catch { - /* column exists */ - } + addColumn(db, table, col, def); }; addCol("sc_trades", "commission_usd", "REAL"); @@ -768,11 +715,7 @@ function migrateTradeMirrorSchema(db: Database.Database): void { function migrateUnifiedDataSchema(db: Database.Database): void { const addCol = (table: string, col: string, def: string) => { - try { - db.exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${def}`); - } catch { - /* exists */ - } + addColumn(db, table, col, def); }; db.exec(` @@ -1112,27 +1055,21 @@ function migrateUnifiedDataSchema(db: Database.Database): void { addCol("sc_trades", "source_acsil", "INTEGER NOT NULL DEFAULT 0"); addCol("sc_trades", "source_file", "INTEGER NOT NULL DEFAULT 0"); addCol("sc_trades", "backtest_run_id", "TEXT"); - try { - db.exec( - `CREATE INDEX IF NOT EXISTS sc_trades_by_backtest ON sc_trades(backtest_run_id, close_time)` - ); - } catch { - /* optional */ + db.exec( + `CREATE INDEX IF NOT EXISTS sc_trades_by_backtest ON sc_trades(backtest_run_id, close_time)` + ); + + const chartCols = db + .prepare(`PRAGMA table_info(sc_charts)`) + .all() as Array<{ name: string; pk: number }>; + const pkCols = chartCols.filter((c) => c.pk > 0); + if (!chartCols.some((c) => c.name === "chartbook_key")) { + addColumn(db, "sc_charts", "chartbook_key", "TEXT NOT NULL DEFAULT 'Platform'"); } - - try { - const chartCols = db - .prepare(`PRAGMA table_info(sc_charts)`) - .all() as Array<{ name: string; pk: number }>; - const pkCols = chartCols.filter((c) => c.pk > 0); - if (!chartCols.some((c) => c.name === "chartbook_key")) { - db.exec( - `ALTER TABLE sc_charts ADD COLUMN chartbook_key TEXT NOT NULL DEFAULT 'Platform'` - ); - } - if (pkCols.length === 1 && pkCols[0]?.name === "chart_number") { - db.exec(` - CREATE TABLE IF NOT EXISTS sc_charts_v2 ( + if (pkCols.length === 1 && pkCols[0]?.name === "chart_number") { + db.exec(` + DROP TABLE IF EXISTS sc_charts_v2; + CREATE TABLE sc_charts_v2 ( chartbook_key TEXT NOT NULL, chart_number INTEGER NOT NULL, name TEXT NOT NULL, @@ -1150,9 +1087,6 @@ function migrateUnifiedDataSchema(db: Database.Database): void { DROP TABLE sc_charts; ALTER TABLE sc_charts_v2 RENAME TO sc_charts; `); - } - } catch { - /* optional */ } addCol("backtest_runs", "chartbook_key", "TEXT"); @@ -1404,11 +1338,7 @@ function migrateUnifiedDataSchema(db: Database.Database): void { ); CREATE INDEX IF NOT EXISTS ai_knowledge_packs_name_idx ON ai_knowledge_packs(name); `); - try { - db.prepare(`UPDATE ai_memories SET agent_id = 'intelligence' WHERE agent_id IS NULL`).run(); - } catch { - /* optional */ - } + db.prepare(`UPDATE ai_memories SET agent_id = 'intelligence' WHERE agent_id IS NULL`).run(); db.exec(` CREATE TABLE IF NOT EXISTS ai_agent_rule_state ( @@ -1485,23 +1415,18 @@ function migrateUnifiedDataSchema(db: Database.Database): void { // Copy legacy global rule/skill enable state into the root agent's per-agent // tables on first run so Intelligence keeps its current toggles. Never overwrites // existing per-agent rows. - try { - db.prepare( - `INSERT OR IGNORE INTO ai_agent_rule_state (agent_id, rule_id, enabled, priority_override, updated_at) + db.prepare( + `INSERT OR IGNORE INTO ai_agent_rule_state (agent_id, rule_id, enabled, priority_override, updated_at) SELECT 'intelligence', rule_id, enabled, priority_override, updated_at FROM ai_rule_state` - ).run(); - db.prepare( - `INSERT OR IGNORE INTO ai_agent_skill_state (agent_id, skill_id, enabled, last_used_at, updated_at) + ).run(); + db.prepare( + `INSERT OR IGNORE INTO ai_agent_skill_state (agent_id, skill_id, enabled, last_used_at, updated_at) SELECT 'intelligence', skill_id, enabled, last_used_at, updated_at FROM ai_skill_state` - ).run(); - } catch { - /* optional */ - } + ).run(); // Seed default project + columns if empty - try { - const proj = db.prepare(`SELECT id FROM ai_projects LIMIT 1`).get(); - if (!proj) { + const proj = db.prepare(`SELECT id FROM ai_projects LIMIT 1`).get(); + if (!proj) { db.prepare(`INSERT INTO ai_projects (id, name) VALUES ('default', 'Intelligence Projects')`).run(); const cols = [ ["backlog", "Backlog", 0], @@ -1514,9 +1439,6 @@ function migrateUnifiedDataSchema(db: Database.Database): void { `INSERT INTO ai_project_columns (id, project_id, name, sort_order) VALUES (?, 'default', ?, ?)` ).run(id, name, order); } - } - } catch { - /* optional */ } // Seed the canonical autonomous task-runner workflow (idempotent). It is the @@ -1524,18 +1446,14 @@ function migrateUnifiedDataSchema(db: Database.Database): void { // subtasks → review → comments → address → accept → done. Left enabled so the // user can run it from the queue / attach a cron schedule; no schedule is // auto-created to avoid surprising autonomous activity. - try { - const existing = db + const existing = db .prepare(`SELECT id FROM ai_workflows WHERE id = 'autonomous-task-runner'`) .get(); - if (!existing) { + if (!existing) { db.prepare( `INSERT INTO ai_workflows (id, name, config_json, enabled) VALUES ('autonomous-task-runner', 'Autonomous Task Runner', ?, 1)` ).run(JSON.stringify(AUTONOMOUS_TASK_RUNNER_GRAPH)); - } - } catch { - /* optional */ } addCol("ai_project_cards", "prompt", "TEXT"); @@ -1561,22 +1479,14 @@ function migrateUnifiedDataSchema(db: Database.Database): void { // Per-user personal calendar/tasks (scoped by user_id in owner workspace tenant DB). addCol("ai_calendar_events", "user_id", "TEXT"); addCol("ai_projects", "user_id", "TEXT"); - try { - db.exec(` + db.exec(` CREATE INDEX IF NOT EXISTS ai_calendar_events_user_idx ON ai_calendar_events(user_id, start_at); CREATE INDEX IF NOT EXISTS ai_projects_user_idx ON ai_projects(user_id); `); - } catch { - /* optional */ - } - try { - db.prepare(`UPDATE ai_workflows SET agent_id = 'intelligence' WHERE agent_id IS NULL`).run(); - db.prepare(`UPDATE ai_projects SET agent_id = 'intelligence' WHERE agent_id IS NULL AND user_id IS NULL`).run(); - } catch { - /* optional */ - } + db.prepare(`UPDATE ai_workflows SET agent_id = 'intelligence' WHERE agent_id IS NULL`).run(); + db.prepare(`UPDATE ai_projects SET agent_id = 'intelligence' WHERE agent_id IS NULL AND user_id IS NULL`).run(); db.exec(` CREATE TABLE IF NOT EXISTS ai_agents ( @@ -1660,6 +1570,35 @@ function migrateUnifiedDataSchema(db: Database.Database): void { PRIMARY KEY (key, actor_id, object_type, record_id, action_name) ); + -- Tenant-owned half of cross-database marketplace clone acquisitions. + -- The import receipt, audit row, and outbox event commit with the import. + CREATE TABLE IF NOT EXISTS marketplace_acquisition_imports ( + operation_id TEXT PRIMARY KEY, + buyer_tenant_id TEXT NOT NULL, + listing_id TEXT NOT NULL, + imported_kind TEXT NOT NULL, + imported_id TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS marketplace_acquisition_audit ( + id TEXT PRIMARY KEY, + operation_id TEXT NOT NULL, + owner_database TEXT NOT NULL, + tenant_id TEXT NOT NULL, + action TEXT NOT NULL, + payload_json TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS marketplace_acquisition_outbox ( + id TEXT PRIMARY KEY, + operation_id TEXT NOT NULL, + tenant_id TEXT NOT NULL, + event_type TEXT NOT NULL, + payload_json TEXT NOT NULL, + published_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS kernel_operation_runs ( id TEXT PRIMARY KEY, tenant_id TEXT, @@ -1702,38 +1641,45 @@ function migrateUnifiedDataSchema(db: Database.Database): void { addCol("ai_agents", "parent_id", "TEXT"); addCol("ai_agents", "team", "TEXT"); - try { - migrateRootAgentMoneyAiToIntelligence(db); - seedIntelligenceAgent(db); - removeDeprecatedBuiltinAgents(db); - ensureAgentPrincipalDefaults(db); - ensureIntelligenceDescription(db); - ensureIntelligenceCodeAccess(db); - ensureIntelligenceLocalBackendWhenExternalLlm(db); - ensureSpecialistCodeAccess(db); - ensureAgentReflectionDefaults(db); - ensureAgentAutoApproveDefaults(db); - ensureAgentDescriptions(db); - ensureAutonomousTaskRunnerSchedule(db); - ensureLlmAutoStartIfModelSet(db); - } catch { - /* optional */ - } + migrateRootAgentMoneyAiToIntelligence(db); + seedIntelligenceAgent(db); + removeDeprecatedBuiltinAgents(db); + ensureAgentPrincipalDefaults(db); + ensureIntelligenceDescription(db); + ensureIntelligenceCodeAccess(db); + ensureIntelligenceLocalBackendWhenExternalLlm(db); + ensureSpecialistCodeAccess(db); + ensureAgentReflectionDefaults(db); + ensureAgentAutoApproveDefaults(db); + ensureAgentDescriptions(db); + ensureAutonomousTaskRunnerSchedule(db); + ensureLlmAutoStartIfModelSet(db); addCol("sc_positions", "position_key", "TEXT"); addCol("sc_positions", "source_dtc", "INTEGER NOT NULL DEFAULT 0"); addCol("sc_positions", "source_acsil", "INTEGER NOT NULL DEFAULT 0"); + addCol("ai_artifacts", "content", "TEXT"); + addCol("ai_memories", "embedding_model", "TEXT"); + addCol("ai_memories", "valid_from", "TEXT"); + addCol("ai_memories", "valid_until", "TEXT"); + + db.exec(` + CREATE INDEX IF NOT EXISTS ai_project_cards_by_project + ON ai_project_cards(project_id, sort_order); + CREATE INDEX IF NOT EXISTS ai_project_cards_by_column + ON ai_project_cards(column_id, sort_order); + CREATE INDEX IF NOT EXISTS ai_project_cards_by_parent + ON ai_project_cards(parent_card_id, sort_order); + CREATE INDEX IF NOT EXISTS ai_project_cards_by_agent_due + ON ai_project_cards(assigned_agent_id, due_at); + `); db.prepare( `UPDATE sc_positions SET position_key = playbook_id WHERE position_key IS NULL` ).run(); - try { - db.prepare(`UPDATE sc_fills SET source_acsil = 1 WHERE source = 'acsil'`).run(); - db.prepare(`UPDATE sc_trades SET source_acsil = 1 WHERE source = 'acsil'`).run(); - db.prepare(`UPDATE sc_positions SET source_acsil = 1`).run(); - } catch { - /* optional */ - } + db.prepare(`UPDATE sc_fills SET source_acsil = 1 WHERE source = 'acsil'`).run(); + db.prepare(`UPDATE sc_trades SET source_acsil = 1 WHERE source = 'acsil'`).run(); + db.prepare(`UPDATE sc_positions SET source_acsil = 1`).run(); const fkViolations = runForeignKeyCheck(db); if (fkViolations.length > 0) { @@ -1751,12 +1697,10 @@ function ensureAutonomousTaskRunnerSchedule(db: AppDatabase): void { .prepare(`SELECT id FROM ai_schedules WHERE workflow_id = 'autonomous-task-runner' LIMIT 1`) .get(); if (existing) return; - createSchedule(db, { - workflowId: "autonomous-task-runner", - cronExpr: "*/30 * * * *", - timezone: "America/Denver", - enabled: true, - }); + db.prepare( + `INSERT INTO ai_schedules (id, workflow_id, cron_expr, timezone, enabled) + VALUES (?, 'autonomous-task-runner', '*/30 * * * *', 'America/Denver', 1)` + ).run(uuidv4()); } function ensureLlmAutoStartIfModelSet(db: AppDatabase): void { diff --git a/apps/bridge/src/kernel/__tests__/action-contract.test.ts b/apps/bridge/src/kernel/__tests__/action-contract.test.ts index 7be4f0e..c3d6ac5 100644 --- a/apps/bridge/src/kernel/__tests__/action-contract.test.ts +++ b/apps/bridge/src/kernel/__tests__/action-contract.test.ts @@ -7,9 +7,17 @@ import { } from "../adapter-registry.js"; import { registerObjectType } from "../registry.js"; import { + cancelOperationRun, + executeCollectionAction, executeRecordAction, KernelError, + processClaimedOperationRun, + recoverInterruptedOperationRuns, } from "../record-api.js"; +import { + claimOperationRun, + OperationRunWorker, +} from "../operation-run-worker.js"; const definition: ObjectTypeDef = { name: "ActionContractItem", @@ -35,7 +43,7 @@ const definition: ObjectTypeDef = { execution: "sync", roles: ["owner"], confirmation: { required: true, ttlSeconds: 60 }, - idempotency: { required: true }, + idempotency: { required: true, ttlSeconds: 60 }, inputSchema: { type: "object", additionalProperties: false, @@ -62,6 +70,79 @@ const definition: ObjectTypeDef = { roles: ["owner"], inputSchema: { type: "object", additionalProperties: false }, }, + { + name: "reindex", + label: "Reindex", + target: "collection", + effect: "write", + execution: "sync", + roles: ["owner"], + inputSchema: { type: "object", additionalProperties: false }, + }, + { + name: "refresh", + label: "Refresh", + target: "record", + effect: "write", + execution: "async", + cancellable: false, + roles: ["owner"], + inputSchema: { type: "object", additionalProperties: false }, + }, + { + name: "durable", + label: "Durable", + target: "record", + effect: "external", + execution: "async", + cancellable: true, + roles: ["owner"], + idempotency: { required: true, ttlSeconds: 60 }, + inputSchema: { type: "object", additionalProperties: false }, + }, + { + name: "fails", + label: "Fails", + target: "record", + effect: "write", + execution: "async", + cancellable: true, + roles: ["owner"], + idempotency: { required: true }, + inputSchema: { type: "object", additionalProperties: false }, + }, + { + name: "flaky", + label: "Flaky", + target: "record", + effect: "write", + execution: "async", + cancellable: true, + roles: ["owner"], + retry: { + maxAttempts: 2, + backoffMs: 0, + retryableErrorCodes: ["TEST_RETRY"], + }, + idempotency: { required: true }, + errorSchema: { + type: "object", + required: ["code", "message", "retryable"], + }, + inputSchema: { type: "object", additionalProperties: false }, + }, + { + name: "times_out", + label: "Times Out", + target: "record", + effect: "write", + execution: "async", + cancellable: true, + roles: ["owner"], + idempotency: { required: true }, + timeoutMs: 1, + inputSchema: { type: "object", additionalProperties: false }, + }, ], }; @@ -72,10 +153,14 @@ const owner = { source: "http" as const, installedPluginIds: new Set(["action-contract-tests"]), }; +let flakyAttempts = 0; +let durableAttempts = 0; function setup() { const db = new Database(":memory:"); unregisterRecordAdapter("action_contract_test_adapter"); + flakyAttempts = 0; + durableAttempts = 0; registerRecordAdapter({ id: "action_contract_test_adapter", get(_db, def, id) { @@ -93,6 +178,34 @@ function setup() { await Promise.resolve(); return { ok: true }; }, + reindex() { + return { ok: true }; + }, + refresh() { + return { ok: true }; + }, + durable() { + durableAttempts += 1; + return { ok: true, attempt: durableAttempts }; + }, + fails() { + throw new KernelError(422, "expected failure", { + code: "TEST_FAILURE", + }); + }, + flaky() { + flakyAttempts += 1; + if (flakyAttempts === 1) { + throw new KernelError(503, "retry me", { + code: "TEST_RETRY", + }); + } + return { ok: true }; + }, + async times_out() { + await new Promise((resolve) => setTimeout(resolve, 20)); + return { ok: true }; + }, }, }); registerObjectType(definition); @@ -100,6 +213,17 @@ function setup() { } describe("ObjectType action contract", () => { + it("rejects unsupported bulk adapter registration", () => { + expect(() => + registerRecordAdapter({ + id: "unsupported_bulk_adapter", + bulk() { + return []; + }, + } as never) + ).toThrowError(/unsupported bulk operations/); + }); + it("validates input, denies absent roles, binds confirmation, and redacts output", async () => { const db = setup(); await expect( @@ -149,23 +273,425 @@ describe("ObjectType action contract", () => { { ...owner, idempotencyKey: "publish-one", confirmationId } ) ).resolves.toEqual({ ok: true, token: "[REDACTED]" }); + db.prepare( + `UPDATE kernel_action_idempotency + SET expires_at=datetime('now', '-1 second')` + ).run(); + await expect( + executeRecordAction( + db, + definition.name, + "one", + "publish", + { title: "Ready" }, + { ...owner, idempotencyKey: "publish-one" } + ) + ).rejects.toMatchObject({ code: "KERNEL_CONFIRMATION_REQUIRED" }); }); - it("returns a durable OperationRun for asynchronous actions", async () => { + it("keeps async idempotency pending until successful worker commit", async () => { const db = setup(); const accepted = (await executeRecordAction( db, definition.name, "one", - "rebuild", + "durable", {}, - owner + { ...owner, idempotencyKey: "durable-success" } )) as { operationRunId: string }; expect(accepted.operationRunId).toBeTruthy(); - await new Promise((resolve) => setTimeout(resolve, 10)); + expect( + db + .prepare( + `SELECT status, result_json, expires_at FROM kernel_action_idempotency + WHERE key='durable-success'` + ) + .get() + ).toEqual({ + status: "pending", + result_json: JSON.stringify({ + status: "accepted", + operationRunId: accepted.operationRunId, + }), + expires_at: null, + }); + await expect( + executeRecordAction(db, definition.name, "one", "durable", {}, { + ...owner, + idempotencyKey: "durable-success", + }) + ).resolves.toEqual(accepted); + const run = claimOperationRun(db, "test-worker"); + expect(run?.id).toBe(accepted.operationRunId); + await expect( + executeRecordAction(db, definition.name, "one", "durable", {}, { + ...owner, + idempotencyKey: "durable-success", + }) + ).resolves.toEqual(accepted); + await processClaimedOperationRun(db, run!, "test-worker"); const row = db - .prepare(`SELECT status FROM kernel_operation_runs WHERE id=?`) - .get(accepted.operationRunId) as { status: string }; - expect(row.status).toBe("succeeded"); + .prepare( + `SELECT status, result_json FROM kernel_operation_runs WHERE id=?` + ) + .get(accepted.operationRunId); + expect(row).toEqual({ + status: "succeeded", + result_json: JSON.stringify({ ok: true, attempt: 1 }), + }); + expect( + db + .prepare( + `SELECT status, result_json, error_json, expires_at IS NOT NULL AS has_expiry + FROM kernel_action_idempotency WHERE key='durable-success'` + ) + .get() + ).toEqual({ + status: "succeeded", + result_json: JSON.stringify({ ok: true, attempt: 1 }), + error_json: null, + has_expiry: 1, + }); + expect(durableAttempts).toBe(1); + }); + + it("reclaims interrupted runs through the durable worker", async () => { + const db = setup(); + const accepted = (await executeRecordAction( + db, + definition.name, + "one", + "durable", + {}, + { ...owner, idempotencyKey: "restart-safe" } + )) as { operationRunId: string }; + expect(claimOperationRun(db, "dead-worker")?.id).toBe( + accepted.operationRunId + ); + expect(recoverInterruptedOperationRuns(db)).toBe(1); + const worker = new OperationRunWorker( + () => [{ tenantId: owner.tenantId, db }], + processClaimedOperationRun + ); + expect(await worker.drainOnce()).toBe(1); + expect( + ( + db + .prepare(`SELECT status FROM kernel_operation_runs WHERE id=?`) + .get(accepted.operationRunId) as { status: string } + ).status + ).toBe("succeeded"); + expect( + ( + db + .prepare( + `SELECT status FROM kernel_action_idempotency + WHERE key='restart-safe'` + ) + .get() as { status: string } + ).status + ).toBe("succeeded"); + + const unsafe = (await executeRecordAction( + db, + definition.name, + "one", + "rebuild", + {}, + { ...owner, idempotencyKey: "unsafe-restart" } + )) as { operationRunId: string }; + expect(claimOperationRun(db, "dead-unsafe-worker")?.id).toBe( + unsafe.operationRunId + ); + expect(recoverInterruptedOperationRuns(db)).toBe(1); + expect( + db + .prepare( + `SELECT status, error_code FROM kernel_operation_runs WHERE id=?` + ) + .get(unsafe.operationRunId) + ).toEqual({ status: "failed", error_code: "KERNEL_REPLAY_UNSAFE" }); + expect( + ( + db + .prepare( + `SELECT status FROM kernel_action_idempotency + WHERE key='unsafe-restart'` + ) + .get() as { status: string } + ).status + ).toBe("failed"); + }); + + it("enforces action targets and cancellation declarations", async () => { + const db = setup(); + await expect( + executeRecordAction(db, definition.name, "one", "reindex", {}, owner) + ).rejects.toMatchObject({ code: "KERNEL_ACTION_TARGET_MISMATCH" }); + await expect( + executeCollectionAction(db, definition.name, "rebuild", {}, owner) + ).rejects.toMatchObject({ code: "KERNEL_ACTION_TARGET_MISMATCH" }); + + const accepted = (await executeRecordAction( + db, + definition.name, + "one", + "refresh", + {}, + owner + )) as { operationRunId: string }; + expect(() => + cancelOperationRun(db, accepted.operationRunId, owner) + ).toThrowError(/not cancellable/); + + const cancellable = (await executeRecordAction( + db, + definition.name, + "one", + "durable", + {}, + { ...owner, idempotencyKey: "cancel-durable" } + )) as { operationRunId: string }; + expect(cancelOperationRun(db, cancellable.operationRunId, owner)).toBe(true); + expect( + db + .prepare( + `SELECT status FROM kernel_action_idempotency + WHERE key='cancel-durable'` + ) + .get() + ).toEqual({ status: "cancelled" }); + await expect( + executeRecordAction(db, definition.name, "one", "durable", {}, { + ...owner, + idempotencyKey: "cancel-durable", + }) + ).rejects.toMatchObject({ + code: "KERNEL_IDEMPOTENT_ACTION_CANCELLED", + }); + }); + + it("tenant-binds confirmation and idempotency records", async () => { + const db = setup(); + let confirmationId = ""; + try { + await executeRecordAction( + db, + definition.name, + "one", + "publish", + { title: "Ready" }, + { ...owner, idempotencyKey: "tenant-key" } + ); + } catch (error) { + confirmationId = String( + (error as KernelError).details && + ((error as KernelError).details as { confirmationId: string }) + .confirmationId + ); + } + await expect( + executeRecordAction( + db, + definition.name, + "one", + "publish", + { title: "Ready" }, + { + ...owner, + tenantId: "other-tenant", + idempotencyKey: "tenant-key", + confirmationId, + } + ) + ).rejects.toMatchObject({ code: "KERNEL_CONFIRMATION_REQUIRED" }); + }); + + it("finalizes idempotency when an async worker fails", async () => { + const db = setup(); + const accepted = (await executeRecordAction( + db, + definition.name, + "one", + "fails", + {}, + { ...owner, idempotencyKey: "failed-run" } + )) as { operationRunId: string }; + const run = claimOperationRun(db, "failure-worker"); + await processClaimedOperationRun(db, run!, "failure-worker"); + expect( + db + .prepare( + `SELECT status, error_code FROM kernel_operation_runs WHERE id=?` + ) + .get(accepted.operationRunId) + ).toEqual({ status: "failed", error_code: "TEST_FAILURE" }); + expect( + db + .prepare( + `SELECT status, error_json FROM kernel_action_idempotency + WHERE key='failed-run'` + ) + .get() + ).toEqual({ + status: "failed", + error_json: expect.stringContaining("TEST_FAILURE"), + }); + }); + + it("enforces retries, timeout, and terminal failure state", async () => { + const db = setup(); + const flaky = (await executeRecordAction( + db, + definition.name, + "one", + "flaky", + {}, + { ...owner, idempotencyKey: "flaky-run" } + )) as { operationRunId: string }; + const first = claimOperationRun(db, "retry-worker"); + await processClaimedOperationRun(db, first!, "retry-worker"); + expect( + ( + db + .prepare(`SELECT status FROM kernel_operation_runs WHERE id=?`) + .get(flaky.operationRunId) as { status: string } + ).status + ).toBe("retrying"); + expect( + ( + db + .prepare( + `SELECT status FROM kernel_action_idempotency WHERE key='flaky-run'` + ) + .get() as { status: string } + ).status + ).toBe("pending"); + const second = claimOperationRun(db, "retry-worker"); + await processClaimedOperationRun(db, second!, "retry-worker"); + expect( + ( + db + .prepare(`SELECT status, attempt FROM kernel_operation_runs WHERE id=?`) + .get(flaky.operationRunId) as { status: string; attempt: number } + ) + ).toEqual({ status: "succeeded", attempt: 2 }); + expect( + ( + db + .prepare( + `SELECT status FROM kernel_action_idempotency WHERE key='flaky-run'` + ) + .get() as { status: string } + ).status + ).toBe("succeeded"); + + const timed = (await executeRecordAction( + db, + definition.name, + "one", + "times_out", + {}, + { ...owner, idempotencyKey: "timeout-run" } + )) as { operationRunId: string }; + const timeoutRun = claimOperationRun(db, "timeout-worker"); + await processClaimedOperationRun(db, timeoutRun!, "timeout-worker"); + expect( + db + .prepare( + `SELECT status, error_code FROM kernel_operation_runs WHERE id=?` + ) + .get(timed.operationRunId) + ).toEqual({ status: "failed", error_code: "KERNEL_ACTION_TIMEOUT" }); + expect( + db + .prepare( + `SELECT status, error_json FROM kernel_action_idempotency + WHERE key='timeout-run'` + ) + .get() + ).toEqual({ + status: "failed", + error_json: expect.stringContaining("KERNEL_ACTION_TIMEOUT"), + }); + }); + + it("commits core adapter writes with core audit and outbox ownership", async () => { + const tenant = new Database(":memory:"); + const core = new Database(":memory:"); + core.exec(` + CREATE TABLE core_owned_items (id TEXT PRIMARY KEY, state TEXT NOT NULL); + INSERT INTO core_owned_items VALUES ('one', 'ready'); + CREATE TABLE events ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + actor_kind TEXT NOT NULL, + actor_id TEXT, + tenant_id TEXT, + payload_json TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + `); + const coreDefinition: ObjectTypeDef = { + name: "CoreOwnedActionItem", + label: "Core Owned Action Item", + pluginId: "action-contract-tests", + database: "core", + storage: { kind: "adapter", adapterId: "core_owned_action_test" }, + fields: [{ name: "id", label: "Id", fieldType: "Data" }], + operations: ["get"], + permissions: [{ role: "owner", read: true }], + actions: [ + { + name: "activate", + label: "Activate", + target: "record", + effect: "write", + execution: "sync", + roles: ["owner"], + inputSchema: { type: "object", additionalProperties: false }, + }, + ], + }; + unregisterRecordAdapter("core_owned_action_test"); + registerRecordAdapter({ + id: "core_owned_action_test", + get(db, def, id) { + const row = db.prepare(`SELECT * FROM core_owned_items WHERE id=?`).get(id) as + | { id: string; state: string } + | undefined; + return row + ? { id, objectType: def.name, data: { id, state: row.state } } + : null; + }, + actions: { + activate(db, _def, id) { + db.prepare(`UPDATE core_owned_items SET state='active' WHERE id=?`).run(id); + return { ok: true }; + }, + }, + }); + registerObjectType(coreDefinition); + + await executeRecordAction(tenant, coreDefinition.name, "one", "activate", {}, { + ...owner, + data: { tenantDb: tenant, coreDb: core, declaredDatabase: "core" }, + }); + + expect(core.prepare(`SELECT state FROM core_owned_items WHERE id='one'`).get()).toEqual({ + state: "active", + }); + expect(core.prepare(`SELECT count(*) AS n FROM platform_action_log`).get()).toEqual({ + n: 1, + }); + expect(core.prepare(`SELECT count(*) AS n FROM events`).get()).toEqual({ n: 1 }); + expect( + tenant + .prepare( + `SELECT count(*) AS n FROM sqlite_master + WHERE type='table' AND name IN ('platform_action_log', 'events')` + ) + .get() + ).toEqual({ n: 0 }); }); }); diff --git a/apps/bridge/src/kernel/__tests__/adapter-parity.test.ts b/apps/bridge/src/kernel/__tests__/adapter-parity.test.ts new file mode 100644 index 0000000..15e7785 --- /dev/null +++ b/apps/bridge/src/kernel/__tests__/adapter-parity.test.ts @@ -0,0 +1,104 @@ +import Database from "better-sqlite3"; +import { describe, expect, it } from "vitest"; +import type { ObjectTypeDef } from "@godmode/kernel"; +import { validateObjectTypeDef } from "@godmode/kernel"; +import { + getRecordAdapter, + type RecordOperation, +} from "../adapter-registry.js"; +import { + assertCoreObjectTypeBootstrapComplete, + CORE_OBJECT_TYPE_NAMES, + registerCoreObjectTypes, +} from "../core-object-types.js"; +import { createSqlReadAdapter } from "../adapters/sql-read.js"; +import { listObjectTypes } from "../registry.js"; + +const RECORD_OPERATIONS: RecordOperation[] = [ + "list", + "get", + "create", + "update", + "delete", +]; + +describe("production core ObjectType bootstrap", () => { + it("registers the exact complete core declaration set", () => { + registerCoreObjectTypes(); + expect(assertCoreObjectTypeBootstrapComplete).not.toThrow(); + expect( + listObjectTypes() + .filter((def) => !def.pluginId) + .map((def) => def.name) + .sort() + ).toEqual(["StructureNode", ...CORE_OBJECT_TYPE_NAMES].sort()); + }); + + it("has exact declaration-handler parity and valid schemas", () => { + registerCoreObjectTypes(); + for (const def of listObjectTypes().filter((candidate) => !candidate.pluginId)) { + expect(validateObjectTypeDef(def), def.name).toEqual([]); + expect(def.database ?? "tenant", `${def.name} database ownership`).toMatch( + /^(tenant|core)$/ + ); + expect(def.accessPolicy, `${def.name} access policy`).toBeTruthy(); + for (const action of def.actions ?? []) { + expect(action.roles.length, `${def.name}.${action.name} authorization`).toBeGreaterThan(0); + expect(action.inputSchema, `${def.name}.${action.name} input schema`).toMatchObject({ + type: "object", + }); + } + expect(def.storage.kind, def.name).toBe("adapter"); + if (def.storage.kind !== "adapter") continue; + + const adapter = getRecordAdapter(def.storage.adapterId); + expect(adapter, `${def.name} production adapter`).toBeDefined(); + expect( + RECORD_OPERATIONS.filter( + (operation) => typeof adapter?.[operation] === "function" + ).sort(), + `${def.name} CRUD handlers` + ).toEqual([...(def.operations ?? [])].sort()); + expect( + Object.keys(adapter?.actions ?? {}).sort(), + `${def.name} action handlers` + ).toEqual((def.actions ?? []).map((action) => action.name).sort()); + } + }); + + it("uses the database handle selected by declared ownership", () => { + const selectedCore = new Database(":memory:"); + selectedCore.exec(` + CREATE TABLE owned_rows (id TEXT PRIMARY KEY, value TEXT NOT NULL); + INSERT INTO owned_rows VALUES ('core-row', 'authoritative'); + `); + const def: ObjectTypeDef = { + name: "OwnershipProbe", + label: "Ownership Probe", + database: "core", + accessPolicy: "platform-admin", + storage: { kind: "adapter", adapterId: "ownership_probe" }, + fields: [ + { name: "id", label: "Id", fieldType: "Data" }, + { name: "value", label: "Value", fieldType: "Data" }, + ], + operations: ["list", "get"], + permissions: [{ role: "owner", read: true }], + }; + const adapter = createSqlReadAdapter({ + id: "ownership_probe", + table: "owned_rows", + database: "core", + }); + + expect( + adapter.get!( + selectedCore, + def, + "core-row", + { role: "owner", source: "system" } + )?.data.value + ).toBe("authoritative"); + selectedCore.close(); + }); +}); diff --git a/apps/bridge/src/kernel/__tests__/ai-tool-kernel-cutover.test.ts b/apps/bridge/src/kernel/__tests__/ai-tool-kernel-cutover.test.ts new file mode 100644 index 0000000..73d6ef2 --- /dev/null +++ b/apps/bridge/src/kernel/__tests__/ai-tool-kernel-cutover.test.ts @@ -0,0 +1,193 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { getObjectType, registerObjectType } from "../registry.js"; +import { + executeTool, + setKernelToolDispatcherForTests, + type ToolExecContext, +} from "../../services/ai-tool-executor.js"; +import { + getToolSchemasForLlm, + STATIC_GENERATED_COLLISION_NAMES, +} from "../../services/ai-tools-registry.js"; + +beforeAll(() => { + if (!getObjectType("Notification")) { + registerObjectType({ + name: "Notification", + label: "Notification", + storage: { kind: "native" }, + operations: ["create"], + fields: [ + { name: "id", label: "Id", fieldType: "Data" }, + { name: "title", label: "Title", fieldType: "Data" }, + ], + }); + } +}); + +afterEach(() => { + setKernelToolDispatcherForTests(); +}); + +function context(): ToolExecContext { + return { + db: { + prepare() { + throw new Error("AI tool attempted a direct database access"); + }, + } as ToolExecContext["db"], + activeAgentId: "intelligence", + userId: "user-1", + chatId: "chat-1", + confirmationApproved: true, + }; +} + +describe("static AI tool kernel cutover", () => { + it("exposes one generated definition for superseded static names", () => { + const names = getToolSchemasForLlm().map((tool) => tool.function.name); + expect(names.filter((name) => name === "create_notification")).toEqual([ + "create_notification", + ]); + expect(STATIC_GENERATED_COLLISION_NAMES.has("create_notification")).toBe(true); + expect(new Set(names).size).toBe(names.length); + }); + + it("dispatches canonical generated mutation tools before legacy cases", async () => { + const dispatch = vi.fn(() => ({ + id: "notification-1", + objectType: "Notification", + data: {}, + })); + setKernelToolDispatcherForTests(dispatch); + + await executeTool( + "create_notification", + { + recipient_kind: "user", + recipient_id: "user-1", + title: "Ready", + }, + context() + ); + + expect(dispatch).toHaveBeenCalledOnce(); + expect(dispatch.mock.calls[0]?.[1]).toBe("create_notification"); + }); + + it.each([ + ["remember", { text: "Kernel-owned memory" }, "create_record", "Memory"], + [ + "create_project_card", + { title: "Kernel-owned task" }, + "create_record", + "TaskCard", + ], + [ + "comment_card", + { cardId: "card-1", body: "Kernel-owned comment" }, + "run_record_action", + "TaskCard", + ], + [ + "create_user_calendar_event", + { title: "Review", start_at: "2026-07-15T10:00:00Z" }, + "create_record", + "CalendarEvent", + ], + [ + "create_listing", + { kind: "skill", title: "Pack", priceCredits: 0 }, + "run_record_action", + "MarketplaceListing", + ], + ])( + "routes %s through generic kernel dispatch without DB access", + async (toolName, args, kernelName, objectType) => { + const dispatch = vi.fn(() => ({ id: "result-1", data: {} })); + setKernelToolDispatcherForTests(dispatch); + + await executeTool(toolName, args, context()); + + expect(dispatch).toHaveBeenCalled(); + expect(dispatch.mock.calls[0]?.[1]).toBe(kernelName); + expect(dispatch.mock.calls[0]?.[2]).toMatchObject({ objectType }); + } + ); + + it("persists todo_write exclusively through CRUD and action dispatch", async () => { + const dispatch = vi.fn( + (_db, name: string, args: Record) => { + if (name === "get_record") return null; + if (name === "list_records") return { records: [] }; + return { + id: String((args.data as { id?: string } | undefined)?.id ?? "ok"), + }; + } + ); + setKernelToolDispatcherForTests(dispatch); + + const result = await executeTool( + "todo_write", + { + todos: [ + { id: "one", content: "First", status: "in_progress" }, + { id: "two", content: "Second", status: "pending" }, + ], + }, + context() + ); + + expect(result).toMatchObject({ ok: true, count: 2 }); + expect(dispatch.mock.calls.map((call) => call[1])).toEqual( + expect.arrayContaining([ + "get_record", + "create_record", + "run_record_action", + "list_records", + ]) + ); + }); + + it("mirrors contributed memories through kernel dispatch on both databases", async () => { + const dispatch = vi.fn((_db, _name, args: Record) => ({ + id: String((args.data as { id?: string } | undefined)?.id ?? "memory"), + })); + setKernelToolDispatcherForTests(dispatch); + const ctx = context(); + ctx.contributeDb = { + prepare() { + throw new Error("Contribute database was accessed directly"); + }, + } as ToolExecContext["db"]; + + const result = await executeTool("remember", { text: "Shared fact" }, ctx); + + expect(result).toMatchObject({ contributed: true }); + expect(dispatch.mock.calls.map((call) => call[1])).toEqual([ + "create_record", + "create_record", + ]); + expect(dispatch.mock.calls[0]?.[0]).toBe(ctx.db); + expect(dispatch.mock.calls[1]?.[0]).toBe(ctx.contributeDb); + }); + + it("uses TaskCard actions for lane and lifecycle changes", async () => { + const dispatch = vi.fn(() => ({ id: "card-1", data: {} })); + setKernelToolDispatcherForTests(dispatch); + + await executeTool( + "update_card", + { cardId: "card-1", columnId: "review", status: "accepted" }, + context() + ); + + const actions = dispatch.mock.calls + .filter((call) => call[1] === "run_record_action") + .map((call) => call[2]); + expect(actions).toEqual([ + expect.objectContaining({ action: "move" }), + expect.objectContaining({ action: "transition" }), + ]); + }); +}); diff --git a/apps/bridge/src/kernel/__tests__/core-services.test.ts b/apps/bridge/src/kernel/__tests__/core-services.test.ts index 0963b88..0f1e7fe 100644 --- a/apps/bridge/src/kernel/__tests__/core-services.test.ts +++ b/apps/bridge/src/kernel/__tests__/core-services.test.ts @@ -43,6 +43,10 @@ describe("core service ObjectType adapters", () => { created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); + CREATE TABLE ai_workflow_comments ( + id TEXT PRIMARY KEY, workflow_id TEXT NOT NULL, author TEXT NOT NULL, + body TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); CREATE TABLE ai_projects ( id TEXT PRIMARY KEY, name TEXT NOT NULL, user_id TEXT, agent_id TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), @@ -125,6 +129,30 @@ describe("core service ObjectType adapters", () => { }); }); + it("persists workflow comments through the workflow service", () => { + const workflow = createRecord( + db, + "Workflow", + { name: "Reviewed", config_json: { nodes: [], edges: [] } }, + ctx + ); + const comment = createRecord( + db, + "WorkflowComment", + { workflow_id: workflow.id, author: "user", body: "Ship this" }, + ctx + ); + expect(comment.data).toMatchObject({ + workflow_id: workflow.id, + author: "user", + body: "Ship this", + }); + deleteRecord(db, "WorkflowComment", comment.id, ctx); + expect(() => getRecord(db, "WorkflowComment", comment.id, ctx)).toThrow( + /record not found/i + ); + }); + it("uses the authoritative Agent service and protects built-ins", () => { const agent = createRecord( db, @@ -138,8 +166,14 @@ describe("core service ObjectType adapters", () => { is_template: false, }); expect( - updateRecord(db, "Agent", agent.id, { team: "Operations" }, ctx).data.team - ).toBe("Operations"); + updateRecord( + db, + "Agent", + agent.id, + { team: "Operations" }, + ctx + ).data + ).toMatchObject({ team: "Operations" }); }); it("scopes productivity edits to the active user", () => { @@ -169,5 +203,7 @@ describe("core service ObjectType adapters", () => { userId: "another-user", }) ).toThrow(/not found/i); + deleteRecord(db, "TaskCard", card.id, ctx); + expect(() => getRecord(db, "TaskCard", card.id, ctx)).toThrow(/not found/i); }); }); diff --git a/apps/bridge/src/kernel/__tests__/events-relay.test.ts b/apps/bridge/src/kernel/__tests__/events-relay.test.ts new file mode 100644 index 0000000..cccdd01 --- /dev/null +++ b/apps/bridge/src/kernel/__tests__/events-relay.test.ts @@ -0,0 +1,57 @@ +import Database from "better-sqlite3"; +import { EventEmitter } from "node:events"; +import { describe, expect, it } from "vitest"; +import { + durableEventConsumer, + relayEventsOnce, +} from "../../services/events-relay.js"; + +describe("durable events relay", () => { + it("awaits consumers and records receipts only after success", async () => { + const db = new Database(":memory:"); + db.exec(` + CREATE TABLE events ( + id TEXT PRIMARY KEY, + seq INTEGER NOT NULL, + ts TEXT NOT NULL, + type TEXT NOT NULL, + actor_agent_id TEXT, + subject TEXT, + payload_json TEXT NOT NULL, + dispatched INTEGER NOT NULL DEFAULT 0 + ); + INSERT INTO events + (id, seq, ts, type, payload_json, dispatched) + VALUES ('evt-1', 1, datetime('now'), 'job.ready', '{"ok":true}', 0); + `); + const bus = new EventEmitter(); + let attempts = 0; + bus.on( + "job.ready", + durableEventConsumer("test-consumer", async () => { + await new Promise((resolve) => setTimeout(resolve, 5)); + attempts += 1; + if (attempts === 1) throw new Error("retry"); + }) + ); + + expect(await relayEventsOnce(db, bus, "relay-a")).toBe(0); + expect( + ( + db.prepare(`SELECT dispatched FROM events WHERE id='evt-1'`).get() as { + dispatched: number; + } + ).dispatched + ).toBe(0); + expect(await relayEventsOnce(db, bus, "relay-b")).toBe(1); + expect(attempts).toBe(2); + expect( + db + .prepare( + `SELECT 1 FROM event_consumer_receipts + WHERE event_id='evt-1' AND consumer_id='test-consumer'` + ) + .get() + ).toBeTruthy(); + }); +}); diff --git a/apps/bridge/src/kernel/__tests__/identity-admin.test.ts b/apps/bridge/src/kernel/__tests__/identity-admin.test.ts new file mode 100644 index 0000000..e5ffd69 --- /dev/null +++ b/apps/bridge/src/kernel/__tests__/identity-admin.test.ts @@ -0,0 +1,403 @@ +import Database from "better-sqlite3"; +import type { ObjectTypeDef } from "@godmode/kernel"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { hashPassword, verifyPassword } from "../../services/auth/password.js"; +import type { OperationContext } from "../adapter-registry.js"; +import { + configureIdentityAdminAdapterServices, + resetIdentityAdminAdapterServices, + tenantAdminAdapter, + tenantMembershipAdapter, + tenantProvisioningRunAdapter, + userAdminAdapter, + userCredentialAdapter, + userProfileAdapter, +} from "../adapters/identity-admin.js"; + +function definition(name: string, adapterId: string, fields: string[]): ObjectTypeDef { + return { + name, + label: name, + labelPlural: `${name}s`, + module: "platform", + database: "core", + storage: { kind: "adapter", adapterId }, + fields: fields.map((field) => ({ + name: field, + label: field, + fieldType: "Data", + })), + permissions: [{ role: "owner", read: true, create: true, update: true, delete: true }], + operations: ["list", "get", "create", "update", "delete"], + contractVersion: 1, + schemaVersion: 1, + }; +} + +function context( + db: Database.Database, + overrides: Partial = {} +): OperationContext { + return { + tenantId: "tenant-a", + userId: "user-a", + role: "owner", + source: "http", + data: { + coreDb: db, + tenantDb: db, + declaredDatabase: "core", + }, + ...overrides, + }; +} + +function createCore(): Database.Database { + const db = new Database(":memory:"); + db.pragma("foreign_keys = ON"); + db.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + avatar_url TEXT, + is_admin INTEGER NOT NULL DEFAULT 0, + password_hash TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE oauth_accounts ( + provider TEXT NOT NULL, + provider_user_id TEXT NOT NULL, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + PRIMARY KEY (provider, provider_user_id) + ); + CREATE TABLE tenants ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + is_operator INTEGER NOT NULL DEFAULT 0, + owner_user_id TEXT NOT NULL REFERENCES users(id), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE tenant_memberships ( + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + role TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (user_id, tenant_id) + ); + CREATE TABLE user_profiles ( + user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + headline TEXT, bio TEXT, pronouns TEXT, location TEXT, timezone TEXT, + phone TEXT, company TEXT, job_title TEXT, website TEXT, twitter TEXT, + github TEXT, linkedin TEXT, emoji TEXT, birthday TEXT, languages TEXT, + interests TEXT, "values" TEXT, goals TEXT, personality_notes TEXT, + decision_style TEXT, risk_tolerance TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE platform_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE credit_wallets ( + user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + balance INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE share_grants ( + id TEXT PRIMARY KEY, + owner_tenant_id TEXT, + grantee_tenant_id TEXT + ); + CREATE TABLE bridge_connections ( + id TEXT PRIMARY KEY, + owner_tenant_id TEXT + ); + `); + const insertUser = db.prepare( + `INSERT INTO users + (id, email, display_name, is_admin, password_hash) + VALUES (?, ?, ?, ?, ?)` + ); + insertUser.run("system-local", "local@godmode.platform", "System", 0, null); + insertUser.run("admin", "admin@example.test", "Admin", 1, hashPassword("admin-pass")); + insertUser.run("user-a", "a@example.test", "User A", 0, hashPassword("password-a")); + insertUser.run("user-b", "b@example.test", "User B", 0, hashPassword("password-b")); + db.prepare( + `INSERT INTO tenants (id, name, slug, is_operator, owner_user_id) + VALUES (?, ?, ?, ?, ?)` + ).run("operator", "Operator", "operator", 1, "admin"); + db.prepare( + `INSERT INTO tenants (id, name, slug, is_operator, owner_user_id) + VALUES (?, ?, ?, ?, ?)` + ).run("tenant-a", "Tenant A", "tenant-a", 0, "user-a"); + db.prepare( + `INSERT INTO tenants (id, name, slug, is_operator, owner_user_id) + VALUES (?, ?, ?, ?, ?)` + ).run("tenant-b", "Tenant B", "tenant-b", 0, "user-b"); + const membership = db.prepare( + `INSERT INTO tenant_memberships (user_id, tenant_id, role) VALUES (?, ?, ?)` + ); + membership.run("admin", "operator", "owner"); + membership.run("user-a", "tenant-a", "owner"); + membership.run("user-b", "tenant-b", "owner"); + return db; +} + +describe("identity and admin ObjectType adapters", () => { + let db: Database.Database; + + beforeEach(() => { + db = createCore(); + configureIdentityAdminAdapterServices({ + signup(core, input) { + const id = "signed-up-user"; + core.prepare( + `INSERT INTO users + (id, email, display_name, is_admin, password_hash) + VALUES (?, ?, ?, 0, ?)` + ).run(id, input.email, input.displayName ?? "Signed Up", hashPassword(input.password)); + return { + id, + email: input.email, + displayName: input.displayName ?? "Signed Up", + avatarUrl: null, + isAdmin: false, + createdAt: "now", + tenants: [], + }; + }, + refreshProfile() {}, + createTenant(core, userId, name, slug) { + const id = `tenant-${slug ?? name.toLowerCase().replace(/\s+/g, "-")}`; + core + .prepare( + `INSERT INTO tenants (id, name, slug, is_operator, owner_user_id) + VALUES (?, ?, ?, 0, ?)` + ) + .run(id, name, slug ?? id, userId); + core + .prepare( + `INSERT INTO tenant_memberships (user_id, tenant_id, role) + VALUES (?, ?, 'owner')` + ) + .run(userId, id); + return { id, name, slug: slug ?? id }; + }, + deleteTenant(core, tenantId) { + core.prepare(`DELETE FROM tenants WHERE id=? AND is_operator=0`).run(tenantId); + }, + }); + }); + + afterEach(() => { + resetIdentityAdminAdapterServices(); + db.close(); + }); + + it("allows signup only through trusted system transport", () => { + const def = definition("User", "user_admin_service", [ + "id", + "email", + "display_name", + "is_admin", + ]); + expect(() => + userAdminAdapter.actions!.signup( + db, + def, + "", + { email: "new@example.test", password: "password", display_name: "New" }, + context(db) + ) + ).toThrow(/trusted authentication transport/i); + + const created = userAdminAdapter.actions!.signup( + db, + def, + "", + { email: "new@example.test", password: "password", display_name: "New" }, + context(db, { source: "system", userId: undefined, agentId: "system" }) + ); + expect(created).toMatchObject({ + id: "signed-up-user", + data: { email: "new@example.test", display_name: "New" }, + }); + }); + + it("isolates profiles and memberships between two tenants", () => { + const profileDef = definition("UserProfile", "user_profile_service", [ + "id", + "user_id", + "display_name", + "headline", + ]); + const membershipDef = definition( + "TenantMembership", + "tenant_membership_service", + ["id", "user_id", "tenant_id", "role"] + ); + const ctxA = context(db); + const ctxB = context(db, { tenantId: "tenant-b", userId: "user-b" }); + + expect(userProfileAdapter.list!(db, profileDef, {}, ctxA).total).toBe(1); + expect(userProfileAdapter.get!(db, profileDef, "user-b", ctxA)).toBeNull(); + userProfileAdapter.update!( + db, + profileDef, + "user-a", + { headline: "Tenant A only" }, + ctxA + ); + expect( + userProfileAdapter.get!(db, profileDef, "user-a", ctxA)?.data.headline + ).toBe("Tenant A only"); + expect( + userProfileAdapter.get!(db, profileDef, "user-b", ctxB)?.data.headline + ).toBeNull(); + + expect(tenantMembershipAdapter.list!(db, membershipDef, {}, ctxA).total).toBe(1); + expect(tenantMembershipAdapter.list!(db, membershipDef, {}, ctxB).total).toBe(1); + expect( + tenantMembershipAdapter.get!( + db, + membershipDef, + "tenant-b:user-b", + ctxA + ) + ).toBeNull(); + }); + + it("protects system, operator, and last-owner identities", () => { + const userDef = definition("User", "user_admin_service", [ + "id", + "email", + "display_name", + "is_admin", + ]); + const membershipDef = definition( + "TenantMembership", + "tenant_membership_service", + ["id", "user_id", "tenant_id", "role"] + ); + const admin = context(db, { + tenantId: "operator", + userId: "admin", + isAdmin: true, + }); + + expect(userAdminAdapter.list!(db, userDef, {}, admin).records).toHaveLength(3); + expect(userAdminAdapter.get!(db, userDef, "system-local", admin)).toBeNull(); + expect(() => + userAdminAdapter.delete!(db, userDef, "admin", admin) + ).toThrow(/own account/i); + expect(() => + userAdminAdapter.update!( + db, + userDef, + "admin", + { is_admin: false }, + admin + ) + ).toThrow(/operator tenant owner/i); + expect(() => + tenantMembershipAdapter.delete!( + db, + membershipDef, + "operator:admin", + admin + ) + ).toThrow(/operator tenant owner/i); + expect(() => + tenantMembershipAdapter.delete!( + db, + membershipDef, + "tenant-a:user-a", + context(db) + ) + ).toThrow(/last workspace owner/i); + }); + + it("changes passwords without projecting credential material", () => { + const def = definition("UserCredential", "user_credential_service", [ + "id", + "user_id", + "has_password", + "has_oauth", + "updated_at", + ]); + const ctx = context(db); + const before = userCredentialAdapter.get!(db, def, "user-a", ctx)!; + expect(before.data).not.toHaveProperty("password_hash"); + expect(before.data).not.toHaveProperty("password"); + + const changed = userCredentialAdapter.actions!.change_password( + db, + def, + "user-a", + { + current_password: "password-a", + new_password: "replacement-a", + }, + ctx + ) as { data: Record }; + expect(changed.data).not.toHaveProperty("password_hash"); + const stored = db + .prepare(`SELECT password_hash FROM users WHERE id='user-a'`) + .get() as { password_hash: string }; + expect(verifyPassword("replacement-a", stored.password_hash)).toBe(true); + }); + + it("persists tenant provisioning and deprovisioning run state", () => { + const tenantDef = definition("Tenant", "tenant_admin_service", [ + "id", + "name", + "slug", + "owner_user_id", + ]); + const runDef = definition( + "TenantProvisioningRun", + "tenant_provisioning_run_service", + ["id", "operation", "status", "actor_user_id", "tenant_id", "error"] + ); + const admin = context(db, { + tenantId: "operator", + userId: "admin", + isAdmin: true, + }); + + const created = tenantAdminAdapter.actions!.provision( + db, + tenantDef, + "", + { owner_user_id: "user-a", name: "Second A", slug: "second-a" }, + admin + ) as { id: string }; + let runs = tenantProvisioningRunAdapter.list!(db, runDef, {}, admin); + expect(runs.records[0]?.data).toMatchObject({ + operation: "provision", + status: "succeeded", + tenant_id: created.id, + }); + + tenantAdminAdapter.actions!.deprovision( + db, + tenantDef, + created.id, + {}, + admin + ); + runs = tenantProvisioningRunAdapter.list!(db, runDef, {}, admin); + expect(runs.records[0]?.data).toMatchObject({ + operation: "deprovision", + status: "succeeded", + tenant_id: created.id, + }); + expect( + db.prepare(`SELECT id FROM tenants WHERE id=?`).get(created.id) + ).toBeUndefined(); + }); +}); diff --git a/apps/bridge/src/kernel/__tests__/platform-actions.test.ts b/apps/bridge/src/kernel/__tests__/platform-actions.test.ts index 1bb59f7..b66efdf 100644 --- a/apps/bridge/src/kernel/__tests__/platform-actions.test.ts +++ b/apps/bridge/src/kernel/__tests__/platform-actions.test.ts @@ -6,6 +6,7 @@ import { bridgeConnectionAdapter, catalogSourceAdapter, financeConnectionAdapter, + marketplaceListingAdapter, peerConnectionAdapter, platformActionAdapterRegistrations, } from "../adapters/platform-actions.js"; @@ -50,26 +51,117 @@ function context( describe("platform action adapters", () => { it("reports stable registration IDs and named actions", () => { - expect(platformActionAdapterRegistrations).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - adapterId: "share_grant_read", - actions: expect.arrayContaining(["grant", "revoke"]), - }), - expect.objectContaining({ - adapterId: "dm_message_read", - actions: ["send"], - }), - expect.objectContaining({ - adapterId: "marketplace_listing_read", - actions: expect.arrayContaining(["acquire_live", "publish", "archive"]), - }), - expect.objectContaining({ - adapterId: "finance_connection_service", - actions: expect.arrayContaining(["add_manual", "connect_external"]), - }), - ]) - ); + expect(platformActionAdapterRegistrations).toEqual([ + { + objectType: "ShareGrant", + adapterId: "share_grant_read", + actions: ["grant", "revoke", "share_model", "clone_shared"], + }, + { + objectType: "FederatedShareInvite", + adapterId: "federated_share_invite_service", + actions: ["accept"], + }, + { + objectType: "DirectConversation", + adapterId: "dm_conversation_read", + actions: ["start", "mark_read", "add_member", "remove_member", "share"], + }, + { + objectType: "DirectMessage", + adapterId: "dm_message_read", + actions: ["send"], + }, + { + objectType: "DmBlob", + adapterId: "dm_blob_service", + actions: ["upload"], + }, + { + objectType: "SupportTicket", + adapterId: "support_ticket_read", + actions: ["open", "reply", "set_status"], + }, + { + objectType: "SupportMessage", + adapterId: "support_message_read", + actions: ["reply"], + }, + { + objectType: "CatalogSource", + adapterId: "catalog_source_read", + actions: ["add", "remove", "fetch_external"], + }, + { + objectType: "CatalogInstall", + adapterId: "catalog_install_read", + actions: [ + "activate_plugin_path", + "install_entry", + "install_plugin", + "register_local_plugin", + "unregister_local_plugin", + "uninstall_plugin", + "load_runtime", + "reconcile_runtime", + ], + }, + { + objectType: "MarketplaceListing", + adapterId: "marketplace_listing_read", + actions: [ + "acquire", + "acquire_live", + "publish", + "archive", + "export_portable", + "import_portable", + ], + }, + { + objectType: "MarketplaceEntitlement", + adapterId: "marketplace_entitlement_read", + actions: ["cancel"], + }, + { + objectType: "BridgeConnection", + adapterId: "bridge_connection_read", + actions: ["register", "touch", "probe_remote"], + }, + { + objectType: "PeerConnection", + adapterId: "peer_connection_read", + actions: ["enable_tailscale", "invite", "accept", "refresh_health"], + }, + { + objectType: "InferenceEndpoint", + adapterId: "inference_endpoint_read", + actions: ["publish", "run_remote"], + }, + { + objectType: "FinanceConnection", + adapterId: "finance_connection_service", + actions: [ + "configure_moralis", + "configure_paypal", + "preview_crypto", + "add_manual", + "disconnect", + "connect_external", + "refresh_external", + ], + }, + { + objectType: "PlatformGroup", + adapterId: "platform_group_service", + actions: [], + }, + { + objectType: "PlatformGroupMember", + adapterId: "platform_group_member_service", + actions: ["add", "remove"], + }, + ]); }); it("scopes catalog sources to the authenticated user", () => { @@ -141,7 +233,59 @@ describe("platform action adapters", () => { expect(bridgeConnectionAdapter.get!(db, def, connection.id, ctxA)).not.toBeNull(); }); - it("keeps peer network operations explicit and disabled", () => { + it("publishes and archives tenant-owned live listings", () => { + const db = new Database(":memory:"); + db.exec(` + CREATE TABLE marketplace_listings ( + id TEXT PRIMARY KEY, seller_user_id TEXT NOT NULL, seller_tenant_id TEXT NOT NULL, + kind TEXT NOT NULL, resource_id TEXT NOT NULL, title TEXT NOT NULL, + description TEXT, price_credits INTEGER NOT NULL, bundle_json TEXT NOT NULL, + visibility TEXT NOT NULL, status TEXT NOT NULL, delivery_mode TEXT, + pricing_model TEXT, price_period TEXT, meter_unit TEXT, meter_rate REAL, + license TEXT, inference_endpoint_id TEXT, created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + `); + const def = definition("MarketplaceListing", [ + "id", + "seller_user_id", + "seller_tenant_id", + "kind", + "resource_id", + "title", + "delivery_mode", + "status", + ]); + const ctx = context(db); + const listing = marketplaceListingAdapter.actions!.publish( + db, + def, + "", + { + kind: "agent", + resource_id: "agent-live", + title: "Live Agent", + delivery_mode: "live", + }, + ctx + ) as { id: string; data: Record }; + + expect(listing.data).toMatchObject({ + seller_user_id: "user-a", + seller_tenant_id: "tenant-a", + status: "active", + }); + const archived = marketplaceListingAdapter.actions!.archive( + db, + def, + listing.id, + {}, + ctx + ) as { data: Record }; + expect(archived.data.status).toBe("archived"); + }); + + it("validates peer invitation input instead of returning 501", () => { const db = new Database(":memory:"); expect(() => peerConnectionAdapter.actions!.invite( @@ -151,10 +295,10 @@ describe("platform action adapters", () => { {}, context(db) ) - ).toThrow(/not available through the kernel/i); + ).toThrow(/email required/i); }); - it("supports only local manual finance connection operations", () => { + it("supports manual finance and validates external provider input", async () => { const db = new Database(":memory:"); db.exec(` CREATE TABLE holdings_connections ( @@ -214,8 +358,8 @@ describe("platform action adapters", () => { balance: 25, balance_cad: 25, }); - expect(() => + await expect( financeConnectionAdapter.actions!.connect_external(db, def, "", {}, ctx) - ).toThrow(/not available through the kernel/i); + ).rejects.toThrow(/provider required/i); }); }); diff --git a/apps/bridge/src/kernel/__tests__/platform-config.test.ts b/apps/bridge/src/kernel/__tests__/platform-config.test.ts new file mode 100644 index 0000000..2df4436 --- /dev/null +++ b/apps/bridge/src/kernel/__tests__/platform-config.test.ts @@ -0,0 +1,161 @@ +import Database from "better-sqlite3"; +import type { ObjectTypeDef } from "@godmode/kernel"; +import { afterEach, describe, expect, it } from "vitest"; +import type { OperationContext } from "../adapter-registry.js"; +import { + configurePlatformConfigAdapterServices, + platformBillingConfigAdapter, + resetPlatformConfigAdapterServices, + tenantOnboardingConfigAdapter, +} from "../adapters/platform-config.js"; + +function definition(name: string, adapterId: string, fields: string[]): ObjectTypeDef { + return { + name, + label: name, + module: "platform", + database: adapterId.includes("onboarding") ? "tenant" : "core", + storage: { kind: "adapter", adapterId }, + fields: fields.map((name) => ({ name, label: name, fieldType: "Data" })), + permissions: [{ role: "owner", read: true }], + operations: ["list", "get"], + contractVersion: 1, + schemaVersion: 1, + }; +} + +function context( + db: Database.Database, + tenantId: string, + admin = false +): OperationContext { + return { + tenantId, + userId: admin ? "admin" : `user-${tenantId}`, + isAdmin: admin, + role: "owner", + source: "http", + data: { + tenantDb: db, + coreDb: db, + declaredDatabase: "tenant", + }, + }; +} + +function tenantDb(): Database.Database { + const db = new Database(":memory:"); + db.exec(` + CREATE TABLE ai_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE ai_secrets ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + value TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + `); + return db; +} + +afterEach(() => resetPlatformConfigAdapterServices()); + +describe("platform configuration ObjectType adapters", () => { + it("exposes billing capability without projecting the secret", async () => { + const db = new Database(":memory:"); + let configuredSecret = ""; + configurePlatformConfigAdapterServices({ + getBillingConfig: () => ({ + configured: Boolean(configuredSecret), + publishableKey: "pk_test_public", + creditsPerUsd: 100, + hasSecretKey: Boolean(configuredSecret), + }), + setBillingConfig(input) { + configuredSecret = input.secretKey ?? configuredSecret; + return { + configured: Boolean(configuredSecret), + publishableKey: input.publishableKey ?? "pk_test_public", + creditsPerUsd: input.creditsPerUsd ?? 100, + hasSecretKey: Boolean(configuredSecret), + }; + }, + testBillingConnection: async () => ({ ok: true }), + }); + const def = definition("PlatformBillingConfig", "platform_billing_config_service", [ + "id", + "configured", + "publishable_key", + "credits_per_usd", + "has_secret_key", + ]); + const ctx = context(db, "operator", true); + + const updated = platformBillingConfigAdapter.actions!.configure( + db, + def, + "platform-billing", + { secret_key: "sk_test_private" }, + ctx + ) as { data: Record }; + expect(configuredSecret).toBe("sk_test_private"); + expect(updated.data).not.toHaveProperty("secret_key"); + expect(updated.data).not.toHaveProperty("secret"); + await expect( + platformBillingConfigAdapter.actions!.test_connection( + db, + def, + "platform-billing", + {}, + ctx + ) + ).resolves.toEqual({ ok: true }); + db.close(); + }); + + it("keeps onboarding completion tenant-local", () => { + const a = tenantDb(); + const b = tenantDb(); + const def = definition( + "TenantOnboardingConfig", + "tenant_onboarding_config_service", + ["id", "tenant_id", "completed", "llm_ready", "cursor_connected"] + ); + + tenantOnboardingConfigAdapter.actions!.complete( + a, + def, + "tenant-a", + {}, + context(a, "tenant-a") + ); + const statusA = tenantOnboardingConfigAdapter.get!( + a, + def, + "tenant-a", + context(a, "tenant-a") + ); + const statusB = tenantOnboardingConfigAdapter.get!( + b, + def, + "tenant-b", + context(b, "tenant-b") + ); + expect(statusA?.data.completed).toBe(true); + expect(statusB?.data.completed).toBe(false); + expect( + tenantOnboardingConfigAdapter.get!( + b, + def, + "tenant-a", + context(b, "tenant-b") + ) + ).toBeNull(); + a.close(); + b.close(); + }); +}); diff --git a/apps/bridge/src/kernel/__tests__/plugin-lifecycle-boundary.test.ts b/apps/bridge/src/kernel/__tests__/plugin-lifecycle-boundary.test.ts new file mode 100644 index 0000000..864b7ca --- /dev/null +++ b/apps/bridge/src/kernel/__tests__/plugin-lifecycle-boundary.test.ts @@ -0,0 +1,89 @@ +import { readFileSync } from "node:fs"; +import Database from "better-sqlite3"; +import { describe, expect, it } from "vitest"; +import { ensureTenantPluginsStorage } from "../../services/plugin-lifecycle.js"; +import { listInstalledPlugins } from "../../plugins/plugin-install.js"; + +function source(relative: string): string { + return readFileSync(new URL(relative, import.meta.url), "utf8"); +} + +describe("plugin lifecycle kernel boundary", () => { + it("keeps durable mutation services behind the CatalogInstall adapter", () => { + const entrypoints = [ + source("../../plugins/loader.ts"), + source("../../plugins/plugin-host-bridge.ts"), + source("../../plugins/plugin-install.ts"), + source("../../routes/plugins.ts"), + source("../../routes/marketplace-catalog.ts"), + ].join("\n"); + expect(entrypoints).not.toMatch( + /\b(?:INSERT|UPDATE|DELETE|CREATE|ALTER|DROP)\b|\b(?:mkdir|rm|writeFile|symlink)Sync\s*\(/ + ); + expect(entrypoints).not.toMatch( + /services\/plugin-lifecycle|installPluginForTenant|uninstallPluginForTenant/ + ); + + const adapter = source("../adapters/platform-actions.ts"); + expect(adapter).toMatch(/services\/plugin-lifecycle\.js/); + expect(adapter).toMatch(/activate_plugin_path/); + expect(adapter).toMatch(/uninstall_plugin/); + expect(adapter).toMatch(/reconcile_runtime/); + }); + + it("dispatches boot and AI activation through declared kernel actions", () => { + const bootstrap = source("../../bootstrap.ts"); + expect(bootstrap).toMatch( + /executeCollectionAction\(\s*db,\s*"CatalogInstall",\s*"load_runtime"/ + ); + expect(bootstrap).toMatch( + /executeCollectionAction\(\s*db,\s*"CatalogInstall",\s*"reconcile_runtime"/ + ); + + const ai = source("../../services/ai-tool-executor.ts"); + expect(ai).toMatch( + /objectType:\s*"CatalogInstall"[\s\S]*action:\s*"activate_plugin_path"/ + ); + expect(ai).not.toMatch(/activatePluginForTenant/); + + const runtime = source("../../plugins/runtime.ts"); + expect(runtime).toMatch( + /async installTenant[\s\S]*executeCollectionAction\([\s\S]*"CatalogInstall"[\s\S]*"install_plugin"/ + ); + }); + + it("upgrades historical lifecycle rows and preserves tenant visibility", () => { + const db = new Database(":memory:"); + db.exec(` + CREATE TABLE tenant_plugins ( + tenant_id TEXT NOT NULL, + plugin_id TEXT NOT NULL, + version TEXT NOT NULL, + installed_at TEXT NOT NULL, + plugin_root TEXT, + PRIMARY KEY (tenant_id, plugin_id) + ); + INSERT INTO tenant_plugins + (tenant_id, plugin_id, version, installed_at, plugin_root) + VALUES + ('tenant-a', 'alpha', '1.0.0', '2026-01-01', '/alpha'), + ('tenant-b', 'beta', '1.0.0', '2026-01-01', '/beta'); + `); + + ensureTenantPluginsStorage(db); + + expect( + db.prepare(`SELECT state, desired_state, updated_at FROM tenant_plugins WHERE plugin_id='alpha'`).get() + ).toEqual({ + state: "active", + desired_state: "active", + updated_at: "2026-01-01", + }); + expect(listInstalledPlugins(db, "tenant-a").map((row) => row.plugin_id)).toEqual([ + "alpha", + ]); + expect(listInstalledPlugins(db, "tenant-b").map((row) => row.plugin_id)).toEqual([ + "beta", + ]); + }); +}); diff --git a/apps/bridge/src/kernel/__tests__/record-api.test.ts b/apps/bridge/src/kernel/__tests__/record-api.test.ts index 5002ac3..b2e497e 100644 --- a/apps/bridge/src/kernel/__tests__/record-api.test.ts +++ b/apps/bridge/src/kernel/__tests__/record-api.test.ts @@ -73,11 +73,35 @@ describe("record API", () => { checked: true, meta: { answer: 42 }, }); - updateRecord(db, def.name, "one", { status: "done" }, owner); + expect(created.version).toBe("1"); + const updated = updateRecord( + db, + def.name, + "one", + { status: "done" }, + { ...owner, expectedVersion: created.version } + ); + expect(updated.version).toBe("2"); + expect(() => + updateRecord( + db, + def.name, + "one", + { status: "open" }, + { ...owner, expectedVersion: created.version } + ) + ).toThrowError(/version conflict/); createRecord(db, def.name, { id: "two", title: "Two" }, owner); - expect(listRecords(db, def.name, { limit: 1, offset: 1 }, owner)).toMatchObject({ + expect( + listRecords( + db, + def.name, + { limit: 1, offset: 1, sort: "id", direction: "desc" }, + owner + ) + ).toMatchObject({ total: 2, - records: [{ id: "two" }], + records: [{ id: "one" }], }); deleteRecord(db, def.name, "one", owner); expect(() => getRecord(db, def.name, "one", owner)).toThrowError(KernelError); diff --git a/apps/bridge/src/kernel/__tests__/route-authorization.test.ts b/apps/bridge/src/kernel/__tests__/route-authorization.test.ts new file mode 100644 index 0000000..ce4c936 --- /dev/null +++ b/apps/bridge/src/kernel/__tests__/route-authorization.test.ts @@ -0,0 +1,121 @@ +import Database from "better-sqlite3"; +import { describe, expect, it } from "vitest"; +import { + authorizeFederationScCommand, + validateScCommandLine, +} from "../../routes/federation.js"; +import { authorizeTypingEvent } from "../../routes/dm.js"; + +function federationDb(): Database.Database { + const db = new Database(":memory:"); + db.exec(` + CREATE TABLE share_grants ( + id TEXT PRIMARY KEY, + owner_tenant_id TEXT NOT NULL, + resource_kind TEXT NOT NULL, + resource_id TEXT NOT NULL, + grantee_user_id TEXT, + grantee_tenant_id TEXT, + role TEXT NOT NULL, + federation_token TEXT, + expires_at TEXT + ); + CREATE TABLE tenant_memberships ( + user_id TEXT NOT NULL, + tenant_id TEXT NOT NULL + ); + `); + db.prepare( + `INSERT INTO share_grants + (id, owner_tenant_id, resource_kind, resource_id, grantee_tenant_id, + role, federation_token, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + "grant-1", + "owner-tenant", + "department", + "resource-1", + "target-tenant", + "editor", + "sc-token", + new Date(Date.now() + 60_000).toISOString() + ); + return db; +} + +const validBinding = { + verb: "execute", + resourceKind: "department", + resourceId: "resource-1", + ownerTenantId: "owner-tenant", + targetTenantId: "target-tenant", +}; + +describe("specialized route authorization", () => { + it("binds SC tokens to verb, resource, tenants, role, and expiry", () => { + const db = federationDb(); + expect( + authorizeFederationScCommand(db, "sc-token", validBinding).id + ).toBe("grant-1"); + + for (const binding of [ + { ...validBinding, verb: "delete" }, + { ...validBinding, resourceId: "resource-2" }, + { ...validBinding, ownerTenantId: "other-owner" }, + { ...validBinding, targetTenantId: "other-target" }, + ]) { + expect(() => + authorizeFederationScCommand(db, "sc-token", binding) + ).toThrow(); + } + db.prepare("UPDATE share_grants SET expires_at=? WHERE id=?").run( + new Date(Date.now() - 60_000).toISOString(), + "grant-1" + ); + expect(() => + authorizeFederationScCommand(db, "sc-token", validBinding) + ).toThrow(/expired/); + db.close(); + }); + + it("rejects arbitrary or injected SC command lines", () => { + expect(validateScCommandLine("RECALC|4")).toBe("RECALC|4"); + expect(() => validateScCommandLine("DROP_DATABASE|now")).toThrow( + /not permitted/ + ); + expect(() => validateScCommandLine("PING|ok\nFLATTEN|1")).toThrow( + /Malformed/ + ); + }); + + it("requires DM membership and authenticated sender identity", () => { + const db = new Database(":memory:"); + db.exec(` + CREATE TABLE dm_conversation_members ( + conversation_id TEXT NOT NULL, + user_id TEXT NOT NULL, + role TEXT NOT NULL, + member_kind TEXT + ); + INSERT INTO dm_conversation_members + (conversation_id, user_id, role, member_kind) + VALUES + ('conversation-1', 'user-1', 'member', 'user'), + ('conversation-1', 'user-2', 'member', 'user'); + `); + expect( + authorizeTypingEvent(db, "conversation-1", "user-1", { + userId: "user-1", + }) + ).toEqual(["user-1", "user-2"]); + expect(() => + authorizeTypingEvent(db, "conversation-1", "outsider", {}) + ).toThrow(/Not a member/); + expect(() => + authorizeTypingEvent(db, "conversation-1", "user-1", { + senderUserId: "user-2", + }) + ).toThrow(/authenticated user/); + db.close(); + }); +}); diff --git a/apps/bridge/src/kernel/__tests__/route-waves.test.ts b/apps/bridge/src/kernel/__tests__/route-waves.test.ts new file mode 100644 index 0000000..6add8b2 --- /dev/null +++ b/apps/bridge/src/kernel/__tests__/route-waves.test.ts @@ -0,0 +1,101 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const routesDir = fileURLToPath(new URL("../../routes/", import.meta.url)); +const routeSources = new Map( + readdirSync(routesDir) + .filter((name) => name.endsWith(".ts")) + .map((name) => [ + name, + readFileSync(new URL(`../../routes/${name}`, import.meta.url), "utf8"), + ]) +); + +function declaredRoutes( + methodPattern = "get|post|put|patch|delete" +): string[] { + const routes: string[] = []; + const expression = new RegExp( + `router\\.(${methodPattern})\\s*\\(\\s*["']([^"']+)["']`, + "g" + ); + for (const [file, source] of routeSources) { + for (const match of source.matchAll(expression)) { + routes.push(`${file}:${match[1]}:${match[2]}`); + } + } + return routes.sort(); +} + +describe("legacy route wave", () => { + it("exposes only the approved specialized POST transports", () => { + expect(declaredRoutes("post|put|patch|delete")).toEqual([ + "ai.ts:post:/chat", + "api-core.ts:post:/analytics/timeseries/query", + "auth.ts:post:/change-password", + "auth.ts:post:/login", + "auth.ts:post:/logout", + "auth.ts:post:/signup", + "dm.ts:post:/conversations/:id/typing", + "dm.ts:post:/uploads", + "federation.ts:post:/invites/:token/accept", + "federation.ts:post:/sc/:verb", + ]); + }); + + it("keeps representative read routes while removing duplicate mutations", () => { + const routes = declaredRoutes(); + expect(routes).toEqual( + expect.arrayContaining([ + "ai.ts:get:/chats", + "api-core.ts:get:/health", + "api-core.ts:get:/structure", + "auth.ts:get:/me", + "dm.ts:get:/conversations", + "federation.ts:get:/health", + "marketplace.ts:get:/listings", + ]) + ); + for (const removed of [ + "ai.ts:post:/chats", + "api-core.ts:post:/nodes", + "auth.ts:patch:/profile", + "dm.ts:post:/conversations", + "marketplace.ts:post:/wallet/purchase", + "user-productivity.ts:post:/projects/cards", + ]) { + expect(routes).not.toContain(removed); + } + }); + + it("delegates anonymous signup provisioning to a kernel action", () => { + expect(routeSources.get("auth.ts")).not.toMatch(/INSERT INTO users/); + expect(routeSources.get("auth.ts")).toMatch( + /executeCollectionAction\(\s*core,\s*"User",\s*"signup"/ + ); + }); + + it("delegates streaming chat persistence to kernel CRUD", () => { + expect(routeSources.get("ai.ts")).not.toMatch(/INSERT INTO ai_messages/); + expect(routeSources.get("ai.ts")).toMatch( + /createRecord\(\s*workDb,\s*"ChatMessage"/ + ); + }); + + it("delegates DM blob persistence to a kernel action", () => { + expect(routeSources.get("dm.ts")).not.toMatch(/storeDmBlob\(/); + expect(routeSources.get("dm.ts")).toMatch( + /executeCollectionAction\(\s*getCoreDb\(\),\s*"DmBlob",\s*"upload"/ + ); + }); + + it("delegates federation invite acceptance to a kernel action", () => { + expect(routeSources.get("federation.ts")).not.toMatch( + /createShareGrant\(|UPDATE federated_share_invites/ + ); + expect(routeSources.get("federation.ts")).toMatch( + /executeCollectionAction\(\s*getCoreDb\(\),\s*"FederatedShareInvite",\s*"accept"/ + ); + }); +}); diff --git a/apps/bridge/src/kernel/__tests__/runtime-actions.test.ts b/apps/bridge/src/kernel/__tests__/runtime-actions.test.ts index 63fd308..3711889 100644 --- a/apps/bridge/src/kernel/__tests__/runtime-actions.test.ts +++ b/apps/bridge/src/kernel/__tests__/runtime-actions.test.ts @@ -8,9 +8,15 @@ import { configureRuntimeAdapterServices, chatMessageRuntimeAdapter, chatSessionRuntimeAdapter, + embeddingRuntimeAdapter, + memoryMaintenanceRuntimeAdapter, + modelAdapterRuntimeAdapter, modelRuntimeAdapter, promptQueueRuntimeAdapter, + providerCredentialRuntimeAdapter, + REQUIRED_RUNTIME_ADAPTER_SERVICE_KEYS, runtimeAdapterRegistrations, + vaultSecretRuntimeAdapter, type RuntimeAdapterServices, } from "../adapters/runtime.js"; @@ -63,18 +69,54 @@ function fakeServices(overrides: Partial = {}) { isReady: vi.fn(() => true), getServerBaseUrl: vi.fn(() => "http://127.0.0.1:8080"), getEnabledAdapterPaths: vi.fn(() => []), + getSettings: vi.fn(() => ({ + systemPrompt: "test", + enableThinking: false, + thinkingEfficiency: "normal", + nativeTools: true, + })), + updateSettings: vi.fn((_patch) => ({ + systemPrompt: "test", + enableThinking: false, + thinkingEfficiency: "normal", + nativeTools: true, + })), } as unknown as RuntimeAdapterServices["llm"]; return { llm, - queue: { enqueue: vi.fn(() => "queue-1") }, + queue: { + enqueue: vi.fn(() => "queue-1"), + hasPendingOrRunningWorkflow: vi.fn(() => false), + }, training: { listJobs: vi.fn(() => []), getJob: vi.fn(() => null), startJob: vi.fn(async () => "training-1"), cancelJob: vi.fn(() => false), }, - sendMessage: vi.fn(async () => ({ ok: true, messageId: "assistant-1" })), + embeddings: { + getStatus: vi.fn(() => ({ + enabled: true, + enabledOverride: true, + embedder: { + state: "running", + healthOk: true, + pid: 84, + port: 8081, + error: null, + }, + })), + start: vi.fn(async () => ({ state: "running" })), + stop: vi.fn(async () => ({ state: "stopped" })), + setEnabled: vi.fn(async (enabled: boolean) => ({ enabled })), + getEmbeddingClient: vi.fn(() => undefined), + } as unknown as RuntimeAdapterServices["embeddings"], + memoryMaintenance: { + enqueueDistill: vi.fn(() => "distill-1"), + enqueueWikiSynthesize: vi.fn(() => "wiki-1"), + }, syncIntegration: vi.fn(async () => ({ ok: true, queued: true })), + shareChat: vi.fn(() => ({ ok: true, session: { id: "shared-1" } })), ...overrides, } satisfies RuntimeAdapterServices; } @@ -91,7 +133,8 @@ describe("runtime ObjectType actions", () => { ); CREATE TABLE ai_messages ( id TEXT PRIMARY KEY, chat_id TEXT NOT NULL, role TEXT NOT NULL, - content_json TEXT NOT NULL, created_at TEXT NOT NULL + content_json TEXT NOT NULL, user_id TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE ai_prompt_queue ( id TEXT PRIMARY KEY, status TEXT NOT NULL DEFAULT 'pending', @@ -99,6 +142,25 @@ describe("runtime ObjectType actions", () => { result_json TEXT, error TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), started_at TEXT, finished_at TEXT ); + CREATE TABLE ai_adapters ( + id TEXT PRIMARY KEY, name TEXT NOT NULL, path TEXT NOT NULL, + description TEXT, domain TEXT, enabled INTEGER NOT NULL DEFAULT 1, + default_scale REAL NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE ai_secrets ( + id TEXT PRIMARY KEY, name TEXT NOT NULL, value TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE ai_agent_accounts ( + id TEXT PRIMARY KEY, agent_id TEXT NOT NULL, kind TEXT NOT NULL, + provider TEXT, provider_user_id TEXT, email TEXT, display_name TEXT, + avatar_url TEXT, access_token TEXT, refresh_token TEXT, scopes_json TEXT, + status TEXT NOT NULL DEFAULT 'active', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); `); configureRuntimeAdapterServices(fakeServices()); }); @@ -112,34 +174,75 @@ describe("runtime ObjectType actions", () => { expect(runtimeAdapterRegistrations.map((entry) => entry.adapterId)).toEqual([ "chat_session_runtime", "chat_message_runtime", + "model_adapter_runtime", + "embedding_runtime", + "capability_index_runtime", + "intelligence_settings_runtime", + "prompt_flow_runtime", + "vault_secret_runtime", + "provider_credential_runtime", "model_runtime", "prompt_queue_runtime", "dataset_runtime", + "memory_maintenance_runtime", + "autonomous_runtime", "training_job_runtime", "inference_runtime", "integration_runtime", ]); expect(CHAT_SESSION_ACTIONS.map(({ name, execution }) => [name, execution])).toEqual([ - ["send_message", "async"], + ["share", "async"], ["confirm_tool", "sync"], ["truncate", "sync"], + ["distill", "sync"], ]); const actionNames = runtimeAdapterRegistrations.flatMap((entry) => entry.actions.map((action) => action.name) ); - expect(actionNames).toEqual( - expect.arrayContaining([ - "select_model", - "start", - "stop", - "restart", - "enqueue", - "cancel", - "build_dataset", - "run_inference", - "sync", - ]) - ); + expect(actionNames).toEqual([ + "share", + "confirm_tool", + "truncate", + "distill", + "start", + "stop", + "set_enabled", + "rebuild", + "select_model", + "start", + "stop", + "restart", + "enqueue", + "cancel", + "build_dataset", + "import_dataset", + "wiki_synthesize", + "kick", + "enqueue", + "cancel", + "run_inference", + "sync", + ]); + }); + + it("rejects production runtime wiring when any declared service is omitted", () => { + const complete = fakeServices(); + expect(REQUIRED_RUNTIME_ADAPTER_SERVICE_KEYS).toEqual([ + "llm", + "queue", + "training", + "embeddings", + "memoryMaintenance", + "syncIntegration", + "shareChat", + ]); + for (const key of REQUIRED_RUNTIME_ADAPTER_SERVICE_KEYS) { + const incomplete = { ...complete, [key]: undefined }; + expect(() => + configureRuntimeAdapterServices(incomplete as unknown as RuntimeAdapterServices) + ).toThrow(`Runtime adapter service "${key}" is required`); + } + configureRuntimeAdapterServices(complete); }); it("scopes chat sessions and messages to their authenticated owner", () => { @@ -177,31 +280,124 @@ describe("runtime ObjectType actions", () => { ).toBeNull(); }); - it("delegates chat sends to the configured policy runtime", async () => { + it("persists streamed chat messages through owner-scoped CRUD", () => { + db.prepare( + `INSERT INTO ai_chats (id, title, user_id, created_at, updated_at) + VALUES ('chat-a', 'A', 'user-a', '1', '1'), + ('chat-b', 'B', 'user-b', '1', '1')` + ).run(); + const def = definition("ChatMessage", "chat_message_runtime"); + const created = chatMessageRuntimeAdapter.create!( + db, + def, + { + chat_id: "chat-a", + role: "assistant", + content: { content: "stream complete" }, + }, + owner + ); + expect(created.data).toMatchObject({ + chat_id: "chat-a", + role: "assistant", + content: { content: "stream complete" }, + }); + expect(() => + chatMessageRuntimeAdapter.create!( + db, + def, + { chat_id: "chat-b", role: "user", content: { text: "no" } }, + owner + ) + ).toThrow(/chat session not found/i); + }); + + it("delegates chat share and distill to the configured production services", async () => { const active = fakeServices(); configureRuntimeAdapterServices(active); db.prepare( `INSERT INTO ai_chats (id, title, user_id, created_at, updated_at) VALUES ('chat-a', 'A', 'user-a', '1', '1')` ).run(); + const def = definition("ChatSession", "chat_session_runtime"); await expect( - chatSessionRuntimeAdapter.actions!.send_message( + chatSessionRuntimeAdapter.actions!.share( db, - definition("ChatSession", "chat_session_runtime"), + def, "chat-a", - { content: "hello", agent_id: "intelligence" }, + { agent_id: "agent-a" }, owner ) ).resolves.toMatchObject({ ok: true }); - expect(active.sendMessage).toHaveBeenCalledWith( - expect.objectContaining({ + expect(active.shareChat).toHaveBeenCalledWith({ + db, + chatId: "chat-a", + agentId: "agent-a", + context: owner, + }); + + expect( + chatSessionRuntimeAdapter.actions!.distill( db, - chatId: "chat-a", - content: "hello", - agentId: "intelligence", - context: owner, - }) + def, + "chat-a", + { agent_id: "agent-a", force: true }, + owner + ) + ).toEqual({ ok: true, jobId: "distill-1" }); + expect(active.memoryMaintenance.enqueueDistill).toHaveBeenCalledWith({ + chatId: "chat-a", + agentId: "agent-a", + tenantId: "tenant-a", + force: true, + }); + }); + + it("uses the configured embedding and wiki-maintenance services", async () => { + const active = fakeServices(); + configureRuntimeAdapterServices(active); + const embeddingDef = definition("EmbeddingRuntime", "embedding_runtime"); + + expect( + embeddingRuntimeAdapter.list!(db, embeddingDef, {}, owner).records[0]?.data + ).toMatchObject({ + id: "runtime", + enabled: true, + state: "running", + health_ok: true, + }); + expect( + embeddingRuntimeAdapter.get!(db, embeddingDef, "runtime", owner)?.data + ).toMatchObject({ enabled: true, port: 8081 }); + await expect( + embeddingRuntimeAdapter.actions!.start(db, embeddingDef, "runtime", {}, owner) + ).resolves.toEqual({ state: "running" }); + await expect( + embeddingRuntimeAdapter.actions!.stop(db, embeddingDef, "runtime", {}, owner) + ).resolves.toEqual({ state: "stopped" }); + await expect( + embeddingRuntimeAdapter.actions!.set_enabled( + db, + embeddingDef, + "runtime", + { enabled: false }, + owner + ) + ).resolves.toEqual({ enabled: false }); + + expect( + memoryMaintenanceRuntimeAdapter.actions!.wiki_synthesize( + db, + definition("MemoryMaintenance", "memory_maintenance_runtime"), + "", + { agent_id: "agent-a" }, + owner + ) + ).toEqual({ ok: true, jobId: "wiki-1" }); + expect(active.memoryMaintenance.enqueueWikiSynthesize).toHaveBeenCalledWith( + "tenant-a", + "agent-a" ); }); @@ -227,6 +423,115 @@ describe("runtime ObjectType actions", () => { ).toEqual({ ok: true, deleted: 1 }); }); + it("creates and deletes owned chats and individual messages", () => { + const chatDef = definition("ChatSession", "chat_session_runtime"); + const messageDef = definition("ChatMessage", "chat_message_runtime"); + const created = chatSessionRuntimeAdapter.create!( + db, + chatDef, + { title: "Lifecycle" }, + owner + ); + db.prepare( + `INSERT INTO ai_messages (id, chat_id, role, content_json, created_at) + VALUES ('message-1', ?, 'user', '{}', '1')` + ).run(created.id); + chatMessageRuntimeAdapter.delete!(db, messageDef, "message-1", owner); + expect(chatMessageRuntimeAdapter.get!(db, messageDef, "message-1", owner)).toBeNull(); + chatSessionRuntimeAdapter.delete!(db, chatDef, created.id, owner); + expect(chatSessionRuntimeAdapter.get!(db, chatDef, created.id, owner)).toBeNull(); + }); + + it("uses the model adapter service for CRUD", () => { + const def = definition("ModelAdapter", "model_adapter_runtime"); + const created = modelAdapterRuntimeAdapter.create!( + db, + def, + { name: "Finance", path: "C:\\models\\finance.gguf" }, + owner + ); + expect(created.data).toMatchObject({ name: "Finance", enabled: true }); + expect( + modelAdapterRuntimeAdapter.update!( + db, + def, + created.id, + { default_scale: 0.5 }, + owner + ).data.default_scale + ).toBe(0.5); + modelAdapterRuntimeAdapter.delete!(db, def, created.id, owner); + expect(modelAdapterRuntimeAdapter.get!(db, def, created.id, owner)).toBeNull(); + }); + + it("never projects Vault secret values", () => { + const def = definition("VaultSecret", "vault_secret_runtime"); + const created = vaultSecretRuntimeAdapter.create!( + db, + def, + { name: "provider_key", value: "sk-super-secret-value" }, + owner + ); + expect(created.data).not.toHaveProperty("value"); + expect(created.data.masked).toBeTruthy(); + expect(vaultSecretRuntimeAdapter.list!(db, def, {}, owner).records[0]?.data).not.toHaveProperty( + "value" + ); + }); + + it("never projects provider credential material", () => { + const def = definition("ProviderCredential", "provider_credential_runtime"); + const created = providerCredentialRuntimeAdapter.create!( + db, + def, + { + agent_id: "intelligence", + provider: "openai", + api_key: "sk-provider-secret", + }, + owner + ); + expect(created.data).not.toHaveProperty("api_key"); + expect(created.data).not.toHaveProperty("access_token"); + expect(created.data.masked_token).toBeTruthy(); + expect( + providerCredentialRuntimeAdapter.list!( + db, + def, + { filters: { agent_id: "intelligence" } }, + owner + ).records[0]?.data + ).not.toHaveProperty("access_token"); + }); + + it("stores Cursor credentials under the fixed redacted ProviderCredential id", () => { + const def = definition("ProviderCredential", "provider_credential_runtime"); + const created = providerCredentialRuntimeAdapter.create!( + db, + def, + { + agent_id: "intelligence", + provider: "cursor", + api_key: "cursor-super-secret", + }, + owner + ); + + expect(created.id).toBe("cursor-api-key"); + expect(created.data).toMatchObject({ + provider: "cursor", + status: "active", + }); + expect(JSON.stringify(created)).not.toContain("cursor-super-secret"); + expect(providerCredentialRuntimeAdapter.get!(db, def, "cursor-api-key", owner)).not.toBeNull(); + + providerCredentialRuntimeAdapter.delete!(db, def, "cursor-api-key", owner); + expect(providerCredentialRuntimeAdapter.get!(db, def, "cursor-api-key", owner)).toBeNull(); + expect(() => + providerCredentialRuntimeAdapter.delete!(db, def, "cursor-api-key", owner) + ).not.toThrow(); + }); + it("uses the live queue and rejects non-operator enqueue attempts", () => { const active = fakeServices(); configureRuntimeAdapterServices(active); diff --git a/apps/bridge/src/kernel/__tests__/shared-productivity-access.test.ts b/apps/bridge/src/kernel/__tests__/shared-productivity-access.test.ts new file mode 100644 index 0000000..8023611 --- /dev/null +++ b/apps/bridge/src/kernel/__tests__/shared-productivity-access.test.ts @@ -0,0 +1,386 @@ +import Database from "better-sqlite3"; +import type { ObjectTypeDef } from "@godmode/kernel"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const tenantRegistry = vi.hoisted(() => ({ + databases: new Map(), +})); + +vi.mock("../../tenant-registry.js", () => ({ + getTenantDb(tenantId: string) { + const db = tenantRegistry.databases.get(tenantId); + if (!db) throw new Error(`Unknown tenant: ${tenantId}`); + return db; + }, +})); + +import { + calendarEventServiceAdapter, + taskCardServiceAdapter, +} from "../adapters/productivity.js"; +import { shareGrantAdapter } from "../adapters/platform-actions.js"; +import { + createShareGrant, + resolveShareAccess, +} from "../../services/share-service.js"; + +const taskDef: ObjectTypeDef = { + name: "TaskCard", + label: "Task Card", + storage: { kind: "adapter", adapterId: "task_card_service" }, + fields: ["id", "project_id", "column_id", "title", "status", "sort_order"].map( + (name) => ({ name, label: name, fieldType: "Data" }) + ), +}; + +const eventDef: ObjectTypeDef = { + name: "CalendarEvent", + label: "Calendar Event", + storage: { kind: "adapter", adapterId: "calendar_event_service" }, + fields: ["id", "user_id", "title", "start_at", "status"].map((name) => ({ + name, + label: name, + fieldType: "Data", + })), +}; + +function tenantDb(): Database.Database { + const db = new Database(":memory:"); + db.exec(` + CREATE TABLE ai_projects ( + id TEXT PRIMARY KEY, name TEXT NOT NULL, user_id TEXT, agent_id TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE ai_project_columns ( + id TEXT PRIMARY KEY, project_id TEXT, name TEXT NOT NULL, sort_order INTEGER + ); + CREATE TABLE ai_project_cards ( + id TEXT PRIMARY KEY, project_id TEXT NOT NULL, column_id TEXT NOT NULL, + title TEXT NOT NULL, description TEXT, prompt TEXT, context_json TEXT, + tags_json TEXT, due_at TEXT, linked_chat_id TEXT, linked_workflow_id TEXT, + priority INTEGER, parent_card_id TEXT, status TEXT, assigned_agent_id TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE ai_card_comments ( + id TEXT PRIMARY KEY, card_id TEXT NOT NULL, author TEXT NOT NULL, + body TEXT NOT NULL, kind TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE ai_calendar_events ( + id TEXT PRIMARY KEY, agent_id TEXT, user_id TEXT, kind TEXT NOT NULL, + title TEXT NOT NULL, description TEXT, start_at TEXT NOT NULL, end_at TEXT, + all_day INTEGER NOT NULL DEFAULT 0, location TEXT, linked_card_id TEXT, + linked_run_id TEXT, status TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE ai_workflows ( + id TEXT PRIMARY KEY, agent_id TEXT, name TEXT NOT NULL, + config_json TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + `); + for (const [id, name, order] of [ + ["backlog", "Backlog", 0], + ["in_progress", "In Progress", 1], + ["review", "Review", 2], + ["done", "Done", 3], + ] as const) { + db.prepare( + `INSERT INTO ai_project_columns (id, project_id, name, sort_order) + VALUES (?, 'default', ?, ?)` + ).run(id, name, order); + } + return db; +} + +function coreDb(): Database.Database { + const db = new Database(":memory:"); + db.exec(` + CREATE TABLE users (id TEXT PRIMARY KEY); + CREATE TABLE tenants (id TEXT PRIMARY KEY, owner_user_id TEXT); + CREATE TABLE tenant_memberships ( + user_id TEXT, tenant_id TEXT, role TEXT, + PRIMARY KEY (user_id, tenant_id) + ); + CREATE TABLE share_grants ( + id TEXT PRIMARY KEY, + owner_tenant_id TEXT NOT NULL, + owner_user_id TEXT NOT NULL, + resource_kind TEXT NOT NULL, + resource_id TEXT NOT NULL, + grantee_user_id TEXT, + grantee_tenant_id TEXT, + role TEXT NOT NULL, + bridge_url TEXT, + federation_token TEXT, + expires_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE bridge_connections ( + id TEXT PRIMARY KEY, owner_tenant_id TEXT, owner_user_id TEXT, + label TEXT, mode TEXT, status TEXT, created_at TEXT DEFAULT (datetime('now')) + ); + CREATE TABLE inference_endpoints ( + id TEXT PRIMARY KEY, owner_tenant_id TEXT, owner_user_id TEXT, + status TEXT + ); + CREATE TABLE events ( + id TEXT PRIMARY KEY, type TEXT, actor_kind TEXT, actor_id TEXT, + tenant_id TEXT, payload_json TEXT, created_at TEXT DEFAULT (datetime('now')) + ); + `); + for (const id of ["owner-user", "viewer-user", "editor-user", "stranger-user"]) { + db.prepare(`INSERT INTO users (id) VALUES (?)`).run(id); + } + db.prepare(`INSERT INTO tenants VALUES ('owner-tenant', 'owner-user')`).run(); + db.prepare(`INSERT INTO tenants VALUES ('viewer-tenant', 'viewer-user')`).run(); + db.prepare(`INSERT INTO tenants VALUES ('editor-tenant', 'editor-user')`).run(); + db.prepare( + `INSERT INTO tenant_memberships VALUES ('owner-user', 'owner-tenant', 'owner')` + ).run(); + db.prepare( + `INSERT INTO tenant_memberships VALUES ('viewer-user', 'viewer-tenant', 'owner')` + ).run(); + db.prepare( + `INSERT INTO tenant_memberships VALUES ('editor-user', 'editor-tenant', 'owner')` + ).run(); + return db; +} + +function insertGrant( + core: Database.Database, + id: string, + kind: "user_tasks" | "user_calendar" | "workflow", + granteeUserId: string, + role: "viewer" | "editor", + resourceId = "owner-user", + expiresAt: string | null = null +): void { + core.prepare( + `INSERT INTO share_grants + (id, owner_tenant_id, owner_user_id, resource_kind, resource_id, + grantee_user_id, role, expires_at) + VALUES (?, 'owner-tenant', 'owner-user', ?, ?, ?, ?, ?)` + ).run(id, kind, resourceId, granteeUserId, role, expiresAt); +} + +describe("two-tenant shared productivity authorization", () => { + let core: Database.Database; + let ownerDb: Database.Database; + let viewerDb: Database.Database; + let editorDb: Database.Database; + + beforeEach(() => { + tenantRegistry.databases.clear(); + core = coreDb(); + ownerDb = tenantDb(); + viewerDb = tenantDb(); + editorDb = tenantDb(); + tenantRegistry.databases.set("owner-tenant", ownerDb); + tenantRegistry.databases.set("viewer-tenant", viewerDb); + tenantRegistry.databases.set("editor-tenant", editorDb); + + ownerDb + .prepare(`INSERT INTO ai_projects (id, name, user_id) VALUES ('owner-project', 'Tasks', 'owner-user')`) + .run(); + ownerDb + .prepare( + `INSERT INTO ai_project_cards + (id, project_id, column_id, title, status, sort_order) + VALUES ('owner-card', 'owner-project', 'backlog', 'Owner task', 'pending', 0)` + ) + .run(); + ownerDb + .prepare( + `INSERT INTO ai_calendar_events + (id, agent_id, user_id, kind, title, start_at, status) + VALUES ('owner-event', '', 'owner-user', 'event', 'Owner event', + '2026-07-15T09:00:00Z', 'scheduled')` + ) + .run(); + viewerDb + .prepare(`INSERT INTO ai_projects (id, name, user_id) VALUES ('viewer-project', 'Mine', 'viewer-user')`) + .run(); + }); + + it("creates grants only for an exact resource the caller can share", () => { + expect(() => + createShareGrant(core, { + ownerTenantId: "owner-tenant", + ownerUserId: "owner-user", + resourceKind: "user_tasks", + resourceId: "viewer-user", + granteeUserId: "viewer-user", + }) + ).toThrow(/does not own/i); + + expect(() => + createShareGrant(core, { + ownerTenantId: "owner-tenant", + ownerUserId: "owner-user", + resourceKind: "workflow", + resourceId: "guessed-workflow", + granteeUserId: "viewer-user", + }) + ).toThrow(/not found/i); + + const grantId = createShareGrant(core, { + ownerTenantId: "owner-tenant", + ownerUserId: "owner-user", + resourceKind: "user_tasks", + resourceId: "owner-user", + granteeUserId: "viewer-user", + role: "viewer", + }); + expect( + core.prepare(`SELECT resource_id FROM share_grants WHERE id=?`).get(grantId) + ).toEqual({ resource_id: "owner-user" }); + }); + + it("gives viewers read parity but denies every shared mutation", async () => { + insertGrant(core, "tasks-view", "user_tasks", "viewer-user", "viewer"); + insertGrant(core, "calendar-view", "user_calendar", "viewer-user", "viewer"); + const ctx = { + tenantId: "viewer-tenant", + userId: "viewer-user", + role: "owner" as const, + source: "http" as const, + data: { tenantDb: viewerDb, coreDb: core, declaredDatabase: "tenant" as const }, + }; + + expect(taskCardServiceAdapter.get!(viewerDb, taskDef, "owner-card", ctx)?.data.title).toBe( + "Owner task" + ); + expect( + calendarEventServiceAdapter.get!(viewerDb, eventDef, "owner-event", ctx)?.data.title + ).toBe("Owner event"); + expect(taskCardServiceAdapter.list!(viewerDb, taskDef, {}, ctx).records).toHaveLength(1); + expect(calendarEventServiceAdapter.list!(viewerDb, eventDef, {}, ctx).records).toHaveLength(1); + + expect(() => + taskCardServiceAdapter.update!(viewerDb, taskDef, "owner-card", { title: "stolen" }, ctx) + ).toThrow(/Requires editor/); + expect(() => + taskCardServiceAdapter.delete!(viewerDb, taskDef, "owner-card", ctx) + ).toThrow(/Requires editor/); + expect(() => + taskCardServiceAdapter.actions!.transition( + viewerDb, + taskDef, + "owner-card", + { status: "done" }, + ctx + ) + ).toThrow(/Requires editor/); + expect(() => + calendarEventServiceAdapter.update!( + viewerDb, + eventDef, + "owner-event", + { title: "stolen" }, + ctx + ) + ).toThrow(/Requires editor/); + }); + + it("routes editor mutations to the owner DB without exposing guessed local IDs", async () => { + insertGrant(core, "tasks-edit", "user_tasks", "editor-user", "editor"); + insertGrant(core, "calendar-edit", "user_calendar", "editor-user", "editor"); + editorDb + .prepare( + `INSERT INTO ai_calendar_events + (id, agent_id, user_id, kind, title, start_at, status) + VALUES ('foreign-local', '', 'stranger-user', 'event', 'Hidden', + '2026-07-15T10:00:00Z', 'scheduled')` + ) + .run(); + const ctx = { + tenantId: "editor-tenant", + userId: "editor-user", + role: "owner" as const, + source: "http" as const, + data: { tenantDb: editorDb, coreDb: core, declaredDatabase: "tenant" as const }, + }; + + taskCardServiceAdapter.update!( + editorDb, + taskDef, + "owner-card", + { title: "Edited through grant" }, + ctx + ); + await calendarEventServiceAdapter.actions!.transition( + editorDb, + eventDef, + "owner-event", + { status: "completed" }, + ctx + ); + expect(ownerDb.prepare(`SELECT title FROM ai_project_cards WHERE id='owner-card'`).get()).toEqual( + { title: "Edited through grant" } + ); + expect( + ownerDb.prepare(`SELECT status FROM ai_calendar_events WHERE id='owner-event'`).get() + ).toEqual({ status: "completed" }); + expect(calendarEventServiceAdapter.get!(editorDb, eventDef, "foreign-local", ctx)).toBeNull(); + expect(taskCardServiceAdapter.get!(editorDb, taskDef, "guessed-id", ctx)).toBeNull(); + }); + + it("fails closed for missing, revoked, expired, wrong-resource, and clone access", async () => { + insertGrant( + core, + "expired", + "user_tasks", + "viewer-user", + "viewer", + "owner-user", + "2000-01-01T00:00:00Z" + ); + expect( + resolveShareAccess(core, { + userId: "viewer-user", + tenantId: "viewer-tenant", + resourceKind: "user_tasks", + resourceId: "owner-user", + }) + ).toBeNull(); + + core.prepare(`DELETE FROM share_grants WHERE id='expired'`).run(); + insertGrant(core, "revoked", "user_tasks", "viewer-user", "viewer"); + core.prepare(`DELETE FROM share_grants WHERE id='revoked'`).run(); + expect( + resolveShareAccess(core, { + userId: "viewer-user", + tenantId: "viewer-tenant", + resourceKind: "user_tasks", + resourceId: "owner-user", + }) + ).toBeNull(); + + insertGrant(core, "wrong-kind", "user_calendar", "viewer-user", "viewer"); + const ctx = { + tenantId: "viewer-tenant", + userId: "viewer-user", + role: "owner" as const, + source: "http" as const, + data: { tenantDb: viewerDb, coreDb: core, declaredDatabase: "core" as const }, + }; + expect(taskCardServiceAdapter.get!(viewerDb, taskDef, "owner-card", ctx)).toBeNull(); + + expect(() => + shareGrantAdapter.actions!.clone_shared( + viewerDb, + {} as ObjectTypeDef, + "", + { kind: "workflow", resource_id: "guessed-workflow" }, + ctx + ) + ).toThrow(/No access/); + }); +}); diff --git a/apps/bridge/src/kernel/adapter-registry.ts b/apps/bridge/src/kernel/adapter-registry.ts index 5693f4c..cdac4d5 100644 --- a/apps/bridge/src/kernel/adapter-registry.ts +++ b/apps/bridge/src/kernel/adapter-registry.ts @@ -103,6 +103,15 @@ export function withKernelEventBus(ctx: OperationContext): OperationContext { } export function registerRecordAdapter(adapter: RecordAdapter): void { + const unsupported = adapter as RecordAdapter & { + bulk?: unknown; + bulkActions?: unknown; + }; + if (unsupported.bulk != null || unsupported.bulkActions != null) { + throw new Error( + `Record adapter ${adapter.id} declares unsupported bulk operations` + ); + } if (adapters.has(adapter.id)) { throw new Error(`Record adapter already registered: ${adapter.id}`); } diff --git a/apps/bridge/src/kernel/adapters/core-services.ts b/apps/bridge/src/kernel/adapters/core-services.ts index c1c36a8..7b7e62c 100644 --- a/apps/bridge/src/kernel/adapters/core-services.ts +++ b/apps/bridge/src/kernel/adapters/core-services.ts @@ -4,8 +4,12 @@ import type { AppDatabase } from "../../db.js"; import { getCoreDb } from "../../core-db.js"; import { createWorkflow, + createWorkflowComment, + deleteWorkflowComment, deleteWorkflow, + getWorkflowComment, getWorkflow, + listWorkflowComments, listWorkflows, updateWorkflow, } from "../../services/ai-workflows.js"; @@ -282,6 +286,53 @@ export const workflowServiceAdapter: RecordAdapter = { }, }; +function workflowCommentRecord( + def: ObjectTypeDef, + row: NonNullable> +): RecordRow { + return record(def, row.id, { + workflow_id: row.workflow_id, + author: row.author, + body: row.body, + created_at: row.created_at, + }); +} + +export const workflowCommentServiceAdapter: RecordAdapter = { + id: "workflow_comment_service", + list(db, def, query) { + const workflowId = + typeof query.filters?.workflow_id === "string" + ? query.filters.workflow_id + : query.parentId ?? undefined; + const result = page(listWorkflowComments(db, workflowId), query); + return { + objectType: def.name, + records: result.rows.map((row) => workflowCommentRecord(def, row)), + total: result.total, + }; + }, + get(db, def, id) { + const row = getWorkflowComment(db, id); + return row ? workflowCommentRecord(def, row) : null; + }, + create(db, def, data) { + return workflowCommentRecord( + def, + createWorkflowComment(db, { + workflowId: requiredText(data, "workflow_id"), + author: data.author === "agent" ? "agent" : "user", + body: requiredText(data, "body"), + }) + ); + }, + delete(db, _def, id) { + if (!deleteWorkflowComment(db, id)) { + notFound("Workflow comment not found"); + } + }, +}; + function workflowRunRecord( def: ObjectTypeDef, row: Record @@ -533,6 +584,92 @@ export const agentServiceAdapter: RecordAdapter = { } }, actions: { + create_configured(db, def, _id, input) { + return agentRecord( + def, + createAgent(db, { + name: requiredText(input, "name"), + description: + typeof input.description === "string" ? input.description : undefined, + icon: typeof input.icon === "string" ? input.icon : undefined, + backend: typeof input.backend === "string" ? (input.backend as never) : undefined, + systemPrompt: + typeof input.system_prompt === "string" ? input.system_prompt : undefined, + sampling: + input.sampling && typeof input.sampling === "object" + ? (input.sampling as never) + : undefined, + thinking: + input.thinking && typeof input.thinking === "object" + ? (input.thinking as never) + : undefined, + toolAllow: + input.tool_allow === null || Array.isArray(input.tool_allow) + ? (input.tool_allow as string[] | null) + : undefined, + autoApprove: Array.isArray(input.auto_approve) + ? input.auto_approve.map(String) + : undefined, + modelPath: + input.model_path === undefined + ? undefined + : (input.model_path as string | null), + adapterIds: Array.isArray(input.adapter_ids) + ? input.adapter_ids.map(String) + : undefined, + config: + input.config && typeof input.config === "object" + ? (input.config as Record) + : undefined, + parentId: + input.parent_id === undefined + ? undefined + : (input.parent_id as string | null), + team: input.team === undefined ? undefined : (input.team as string | null), + }) + ); + }, + update_config(db, def, id, input) { + const row = updateAgent(db, id, { + name: typeof input.name === "string" ? input.name : undefined, + description: + input.description === undefined ? undefined : (input.description as string | null), + icon: input.icon === undefined ? undefined : (input.icon as string | null), + backend: typeof input.backend === "string" ? (input.backend as never) : undefined, + enabled: input.enabled === undefined ? undefined : Boolean(input.enabled), + systemPrompt: + typeof input.system_prompt === "string" ? input.system_prompt : undefined, + sampling: + input.sampling && typeof input.sampling === "object" + ? (input.sampling as never) + : undefined, + thinking: + input.thinking && typeof input.thinking === "object" + ? (input.thinking as never) + : undefined, + toolAllow: + input.tool_allow === null || Array.isArray(input.tool_allow) + ? (input.tool_allow as string[] | null) + : undefined, + autoApprove: Array.isArray(input.auto_approve) + ? input.auto_approve.map(String) + : undefined, + modelPath: + input.model_path === undefined ? undefined : (input.model_path as string | null), + adapterIds: Array.isArray(input.adapter_ids) + ? input.adapter_ids.map(String) + : undefined, + config: + input.config && typeof input.config === "object" + ? (input.config as Record) + : undefined, + parentId: + input.parent_id === undefined ? undefined : (input.parent_id as string | null), + team: input.team === undefined ? undefined : (input.team as string | null), + }); + if (!row) notFound("Agent not found"); + return agentRecord(def, row); + }, clone(db, def, id, input) { const source = getAgent(db, id); if (!source) notFound("Agent not found"); diff --git a/apps/bridge/src/kernel/adapters/identity-admin.ts b/apps/bridge/src/kernel/adapters/identity-admin.ts new file mode 100644 index 0000000..832470b --- /dev/null +++ b/apps/bridge/src/kernel/adapters/identity-admin.ts @@ -0,0 +1,1005 @@ +import type { + ActionDef, + ObjectTypeDef, + RecordData, + RecordRow, +} from "@godmode/kernel"; +import { randomUUID } from "node:crypto"; +import type { + CoreDatabase, + CoreTenant, + CoreTenantMembership, + CoreUser, + CoreUserProfile, + MembershipRole, +} from "../../core-db.js"; +import { + createAdminTenantForUser, + createAdminUser, + deleteAdminTenant, + deleteAdminUser, + getAdminUser, + listAdminUsers, + updateAdminTenant, + updateAdminUser, + type AdminUserDto, +} from "../../services/admin-users.js"; +import { hashPassword, verifyPassword } from "../../services/auth/password.js"; +import { refreshUserAgentPrompt } from "../../services/agents/user-agent.js"; +import { getUserOwnerTenantDb } from "../../services/user-scope.js"; +import { + promoteFirstSignupAdmin, + SYSTEM_USER_ID, +} from "../../services/tenant-bootstrap.js"; +import type { + OperationContext, + RecordAdapter, + RecordQuery, +} from "../adapter-registry.js"; + +type TenantLifecycleOperation = "provision" | "deprovision"; +type TenantLifecycleStatus = "running" | "succeeded" | "failed"; + +interface TenantLifecycleRun { + id: string; + operation: TenantLifecycleOperation; + status: TenantLifecycleStatus; + actor_user_id: string; + owner_user_id: string | null; + tenant_id: string | null; + tenant_name: string | null; + tenant_slug: string | null; + error: string | null; + created_at: string; + updated_at: string; +} + +export interface IdentityAdminAdapterServices { + signup( + core: CoreDatabase, + input: { email: string; password: string; displayName?: string } + ): AdminUserDto; + createTenant( + core: CoreDatabase, + userId: string, + name: string, + slug?: string + ): { id: string; name: string; slug: string }; + deleteTenant(core: CoreDatabase, tenantId: string): void; + refreshProfile(userId: string): void; +} + +const defaultServices: IdentityAdminAdapterServices = { + signup(core, input) { + const created = createAdminUser(core, { + email: input.email, + password: input.password, + displayName: input.displayName, + isAdmin: false, + provisionDefaultTenant: true, + }); + promoteFirstSignupAdmin(core, created.id); + return getAdminUser(core, created.id)!; + }, + createTenant: createAdminTenantForUser, + deleteTenant: deleteAdminTenant, + refreshProfile(userId) { + try { + refreshUserAgentPrompt(getUserOwnerTenantDb(userId), userId); + } catch { + // A profile can exist before its first workspace is provisioned. + } + }, +}; + +let services: IdentityAdminAdapterServices = defaultServices; + +export function configureIdentityAdminAdapterServices( + next: Partial +): void { + services = { ...defaultServices, ...next }; +} + +export function resetIdentityAdminAdapterServices(): void { + services = defaultServices; +} + +function httpError(status: number, message: string): Error { + return Object.assign(new Error(message), { status }); +} + +function requireUser(ctx: OperationContext): string { + if (!ctx.userId) throw httpError(401, "Authenticated user required"); + return ctx.userId; +} + +function requireAdmin(ctx: OperationContext): void { + requireUser(ctx); + if (!ctx.isAdmin && ctx.source !== "system") { + throw httpError(403, "Platform administrator required"); + } +} + +function requireTenantOwner(ctx: OperationContext): string { + const userId = requireUser(ctx); + if (!ctx.isAdmin && ctx.role !== "owner") { + throw httpError(403, "Workspace owner required"); + } + return userId; +} + +function requiredText(data: RecordData, name: string): string { + const value = data[name]; + if (typeof value !== "string" || !value.trim()) { + throw httpError(400, `${name} required`); + } + return value.trim(); +} + +function optionalText(value: unknown): string | null | undefined { + if (value === undefined) return undefined; + if (value === null) return null; + const text = String(value).trim(); + return text || null; +} + +function page(rows: T[], query: RecordQuery): { rows: T[]; total: number } { + const offset = Math.max(Number(query.offset) || 0, 0); + const limit = Math.min(Math.max(Number(query.limit) || 100, 1), 500); + return { rows: rows.slice(offset, offset + limit), total: rows.length }; +} + +function record(def: ObjectTypeDef, id: string, data: RecordData): RecordRow { + return { id, objectType: def.name, data: { id, ...data } }; +} + +function userRecord( + def: ObjectTypeDef, + user: NonNullable> +): RecordRow { + return record(def, user.id, { + email: user.email, + display_name: user.displayName, + avatar_url: user.avatarUrl, + is_admin: user.isAdmin, + tenant_count: user.tenants.length, + created_at: user.createdAt, + }); +} + +export const userAdminAdapter: RecordAdapter = { + id: "user_admin_service", + list(core, def, query, ctx) { + requireAdmin(ctx); + const result = page(listAdminUsers(core), query); + return { + objectType: def.name, + records: result.rows.map((user) => userRecord(def, user)), + total: result.total, + }; + }, + get(core, def, id, ctx) { + requireAdmin(ctx); + const user = getAdminUser(core, id); + return user ? userRecord(def, user) : null; + }, + update(core, def, id, data, ctx) { + requireAdmin(ctx); + if (data.is_admin === false) { + const ownsOperator = core + .prepare( + `SELECT 1 FROM tenants + WHERE owner_user_id=? AND is_operator=1` + ) + .get(id); + if (ownsOperator) { + throw httpError(400, "Cannot demote the operator tenant owner"); + } + } + const user = updateAdminUser(core, id, { + email: typeof data.email === "string" ? data.email : undefined, + displayName: + typeof data.display_name === "string" ? data.display_name : undefined, + isAdmin: + data.is_admin === undefined ? undefined : Boolean(data.is_admin), + }); + return userRecord(def, user); + }, + delete(core, _def, id, ctx) { + requireAdmin(ctx); + deleteAdminUser(core, id, requireUser(ctx)); + }, + actions: { + create_account(core, def, _id, input, ctx) { + requireAdmin(ctx); + const user = createAdminUser(core, { + email: requiredText(input, "email"), + password: requiredText(input, "password"), + displayName: + typeof input.display_name === "string" + ? input.display_name + : undefined, + isAdmin: input.is_admin === true, + // Workspace lifecycle is exposed separately so its run state is durable. + provisionDefaultTenant: false, + }); + return userRecord(def, user); + }, + signup(core, def, _id, input, ctx) { + if (ctx.source !== "system") { + throw httpError(403, "Signup requires the trusted authentication transport"); + } + const created = services.signup(core, { + email: requiredText(input, "email"), + password: requiredText(input, "password"), + displayName: + typeof input.display_name === "string" + ? input.display_name + : undefined, + }); + return userRecord(def, created); + }, + }, +}; + +const PROFILE_COLUMNS = [ + "headline", + "bio", + "pronouns", + "location", + "timezone", + "phone", + "company", + "job_title", + "website", + "twitter", + "github", + "linkedin", + "emoji", + "birthday", + "languages", + "interests", + "values", + "goals", + "personality_notes", + "decision_style", + "risk_tolerance", +] as const; + +function profileRecord( + core: CoreDatabase, + def: ObjectTypeDef, + userId: string +): RecordRow | null { + const user = core + .prepare( + `SELECT id, email, display_name, avatar_url, created_at, updated_at + FROM users WHERE id=? AND id<>?` + ) + .get(userId, SYSTEM_USER_ID) as + | Pick< + CoreUser, + "id" | "email" | "display_name" | "avatar_url" | "created_at" | "updated_at" + > + | undefined; + if (!user) return null; + const profile = core + .prepare(`SELECT * FROM user_profiles WHERE user_id=?`) + .get(userId) as CoreUserProfile | undefined; + const data: RecordData = { + user_id: user.id, + email: user.email, + display_name: user.display_name, + avatar_url: user.avatar_url, + created_at: profile?.created_at ?? user.created_at, + updated_at: profile?.updated_at ?? user.updated_at, + }; + for (const column of PROFILE_COLUMNS) data[column] = profile?.[column] ?? null; + return record(def, user.id, data); +} + +function assertSelfOrAdmin(id: string, ctx: OperationContext): string { + const userId = requireUser(ctx); + if (id !== userId && !ctx.isAdmin) throw httpError(404, "Profile not found"); + if (id === SYSTEM_USER_ID) throw httpError(404, "Profile not found"); + return userId; +} + +function updateProfile( + core: CoreDatabase, + def: ObjectTypeDef, + userId: string, + data: RecordData +): RecordRow { + const existing = profileRecord(core, def, userId); + if (!existing) throw httpError(404, "Profile not found"); + core.transaction(() => { + if (data.display_name !== undefined) { + const displayName = requiredText(data, "display_name"); + core + .prepare( + `UPDATE users SET display_name=?, updated_at=datetime('now') WHERE id=?` + ) + .run(displayName, userId); + } + if (data.avatar_url !== undefined) { + core + .prepare( + `UPDATE users SET avatar_url=?, updated_at=datetime('now') WHERE id=?` + ) + .run(optionalText(data.avatar_url), userId); + } + core + .prepare( + `INSERT INTO user_profiles (user_id) VALUES (?) + ON CONFLICT(user_id) DO NOTHING` + ) + .run(userId); + const sets: string[] = []; + const values: Array = []; + for (const column of PROFILE_COLUMNS) { + if (data[column] !== undefined) { + sets.push(`"${column}"=?`); + values.push(optionalText(data[column]) ?? null); + } + } + if (sets.length) { + core + .prepare( + `UPDATE user_profiles + SET ${sets.join(", ")}, updated_at=datetime('now') + WHERE user_id=?` + ) + .run(...values, userId); + } + })(); + services.refreshProfile(userId); + return profileRecord(core, def, userId)!; +} + +export const userProfileAdapter: RecordAdapter = { + id: "user_profile_service", + list(core, def, query, ctx) { + const userId = requireUser(ctx); + const ids = ctx.isAdmin + ? ( + core + .prepare(`SELECT id FROM users WHERE id<>? ORDER BY created_at`) + .all(SYSTEM_USER_ID) as Array<{ id: string }> + ).map((row) => row.id) + : [userId]; + const rows = ids + .map((id) => profileRecord(core, def, id)) + .filter((row): row is RecordRow => Boolean(row)); + const result = page(rows, query); + return { objectType: def.name, records: result.rows, total: result.total }; + }, + get(core, def, id, ctx) { + const userId = requireUser(ctx); + if ((id !== userId && !ctx.isAdmin) || id === SYSTEM_USER_ID) return null; + return profileRecord(core, def, id); + }, + update(core, def, id, data, ctx) { + assertSelfOrAdmin(id, ctx); + return updateProfile(core, def, id, data); + }, + actions: { + update_profile(core, def, id, input, ctx) { + assertSelfOrAdmin(id, ctx); + return updateProfile(core, def, id, input); + }, + }, +}; + +function credentialRecord( + core: CoreDatabase, + def: ObjectTypeDef, + userId: string +): RecordRow | null { + const row = core + .prepare( + `SELECT u.id, u.password_hash, u.updated_at, + EXISTS(SELECT 1 FROM oauth_accounts o WHERE o.user_id=u.id) AS has_oauth + FROM users u WHERE u.id=? AND u.id<>?` + ) + .get(userId, SYSTEM_USER_ID) as + | { + id: string; + password_hash: string | null; + updated_at: string; + has_oauth: number; + } + | undefined; + return row + ? record(def, row.id, { + user_id: row.id, + has_password: Boolean(row.password_hash), + has_oauth: Boolean(row.has_oauth), + updated_at: row.updated_at, + }) + : null; +} + +function setPassword( + core: CoreDatabase, + def: ObjectTypeDef, + id: string, + input: RecordData, + ctx: OperationContext +): RecordRow { + assertSelfOrAdmin(id, ctx); + const row = core + .prepare(`SELECT password_hash FROM users WHERE id=? AND id<>?`) + .get(id, SYSTEM_USER_ID) as { password_hash: string | null } | undefined; + if (!row) throw httpError(404, "Credential not found"); + const next = requiredText(input, "new_password"); + if (next.length < 6) throw httpError(400, "new_password must be at least 6 characters"); + if (!ctx.isAdmin) { + const current = requiredText(input, "current_password"); + if (!verifyPassword(current, row.password_hash)) { + throw httpError(401, "Current password is incorrect"); + } + } + core + .prepare( + `UPDATE users SET password_hash=?, updated_at=datetime('now') WHERE id=?` + ) + .run(hashPassword(next), id); + return credentialRecord(core, def, id)!; +} + +export const userCredentialAdapter: RecordAdapter = { + id: "user_credential_service", + list(core, def, query, ctx) { + const id = requireUser(ctx); + const row = credentialRecord(core, def, id); + const result = page(row ? [row] : [], query); + return { objectType: def.name, records: result.rows, total: result.total }; + }, + get(core, def, id, ctx) { + const userId = requireUser(ctx); + if ((id !== userId && !ctx.isAdmin) || id === SYSTEM_USER_ID) return null; + return credentialRecord(core, def, id); + }, + actions: { + change_password(core, def, id, input, ctx) { + return setPassword(core, def, id, input, ctx); + }, + reset_password(core, def, id, input, ctx) { + requireAdmin(ctx); + return setPassword(core, def, id, input, ctx); + }, + }, +}; + +function tenantRecord(def: ObjectTypeDef, row: CoreTenant): RecordRow { + return record(def, row.id, { + name: row.name, + slug: row.slug, + is_operator: Boolean(row.is_operator), + owner_user_id: row.owner_user_id, + created_at: row.created_at, + updated_at: row.updated_at, + }); +} + +function visibleTenants( + core: CoreDatabase, + ctx: OperationContext +): CoreTenant[] { + const userId = requireUser(ctx); + if (ctx.isAdmin) { + return core.prepare(`SELECT * FROM tenants ORDER BY is_operator DESC, name`).all() as CoreTenant[]; + } + return core + .prepare( + `SELECT t.* FROM tenants t + JOIN tenant_memberships m ON m.tenant_id=t.id + WHERE m.user_id=? AND (? IS NULL OR t.id=?) + ORDER BY t.name` + ) + .all(userId, ctx.tenantId ?? null, ctx.tenantId ?? null) as CoreTenant[]; +} + +const RUN_PREFIX = "kernel.tenant_lifecycle."; + +function now(): string { + return new Date().toISOString(); +} + +function saveRun(core: CoreDatabase, run: TenantLifecycleRun): void { + core + .prepare( + `INSERT INTO platform_meta (key, value, updated_at) + VALUES (?, ?, datetime('now')) + ON CONFLICT(key) DO UPDATE + SET value=excluded.value, updated_at=datetime('now')` + ) + .run(`${RUN_PREFIX}${run.id}`, JSON.stringify(run)); +} + +function listRuns(core: CoreDatabase): TenantLifecycleRun[] { + const rows = core + .prepare( + `SELECT value FROM platform_meta WHERE key LIKE ? + ORDER BY rowid DESC` + ) + .all(`${RUN_PREFIX}%`) as Array<{ value: string }>; + return rows.flatMap((row) => { + try { + return [JSON.parse(row.value) as TenantLifecycleRun]; + } catch { + return []; + } + }); +} + +function lifecycleRecord( + def: ObjectTypeDef, + run: TenantLifecycleRun +): RecordRow { + return record(def, run.id, run as unknown as RecordData); +} + +function startLifecycleRun( + core: CoreDatabase, + input: Omit< + TenantLifecycleRun, + "id" | "status" | "error" | "created_at" | "updated_at" + > +): TenantLifecycleRun { + const timestamp = now(); + const run: TenantLifecycleRun = { + ...input, + id: randomUUID(), + status: "running", + error: null, + created_at: timestamp, + updated_at: timestamp, + }; + saveRun(core, run); + return run; +} + +function finishRun( + core: CoreDatabase, + run: TenantLifecycleRun, + status: Extract, + patch: Partial = {} +): TenantLifecycleRun { + Object.assign(run, patch, { status, updated_at: now() }); + saveRun(core, run); + return run; +} + +function provisionTenant( + core: CoreDatabase, + data: RecordData, + ctx: OperationContext +): CoreTenant { + const actorId = requireTenantOwner(ctx); + const ownerId = + typeof data.owner_user_id === "string" ? data.owner_user_id : actorId; + if (ownerId !== actorId && !ctx.isAdmin) { + throw httpError(403, "Only administrators can provision for another user"); + } + const name = requiredText(data, "name"); + const slug = typeof data.slug === "string" ? data.slug : undefined; + const run = startLifecycleRun(core, { + operation: "provision", + actor_user_id: actorId, + owner_user_id: ownerId, + tenant_id: null, + tenant_name: name, + tenant_slug: slug ?? null, + }); + try { + const created = services.createTenant(core, ownerId, name, slug); + const tenant = core + .prepare(`SELECT * FROM tenants WHERE id=?`) + .get(created.id) as CoreTenant | undefined; + if (!tenant) throw new Error("Tenant service returned no durable tenant row"); + finishRun(core, run, "succeeded", { + tenant_id: tenant.id, + tenant_slug: tenant.slug, + }); + return tenant; + } catch (error) { + finishRun(core, run, "failed", { + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } +} + +function deprovisionTenant( + core: CoreDatabase, + tenantId: string, + ctx: OperationContext +): void { + requireAdmin(ctx); + const tenant = core + .prepare(`SELECT * FROM tenants WHERE id=?`) + .get(tenantId) as CoreTenant | undefined; + if (!tenant) throw httpError(404, "Tenant not found"); + if (tenant.is_operator) throw httpError(400, "Cannot delete the operator tenant"); + const run = startLifecycleRun(core, { + operation: "deprovision", + actor_user_id: requireUser(ctx), + owner_user_id: tenant.owner_user_id, + tenant_id: tenant.id, + tenant_name: tenant.name, + tenant_slug: tenant.slug, + }); + try { + services.deleteTenant(core, tenantId); + finishRun(core, run, "succeeded"); + } catch (error) { + finishRun(core, run, "failed", { + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } +} + +export const tenantAdminAdapter: RecordAdapter = { + id: "tenant_admin_service", + list(core, def, query, ctx) { + const result = page(visibleTenants(core, ctx), query); + return { + objectType: def.name, + records: result.rows.map((tenant) => tenantRecord(def, tenant)), + total: result.total, + }; + }, + get(core, def, id, ctx) { + const tenant = visibleTenants(core, ctx).find((row) => row.id === id); + return tenant ? tenantRecord(def, tenant) : null; + }, + create(core, def, data, ctx) { + return tenantRecord(def, provisionTenant(core, data, ctx)); + }, + update(core, def, id, data, ctx) { + requireTenantOwner(ctx); + const tenant = visibleTenants(core, ctx).find((row) => row.id === id); + if (!tenant) throw httpError(404, "Tenant not found"); + if (tenant.is_operator && !ctx.isAdmin) { + throw httpError(403, "Operator tenant changes require an administrator"); + } + const updated = updateAdminTenant(core, id, { + name: typeof data.name === "string" ? data.name : undefined, + slug: typeof data.slug === "string" ? data.slug : undefined, + }); + return tenantRecord(def, { + ...tenant, + name: updated.name, + slug: updated.slug, + }); + }, + delete(core, _def, id, ctx) { + deprovisionTenant(core, id, ctx); + }, + actions: { + provision(core, def, _id, input, ctx) { + return tenantRecord(def, provisionTenant(core, input, ctx)); + }, + deprovision(core, _def, id, _input, ctx) { + deprovisionTenant(core, id, ctx); + return { ok: true }; + }, + }, +}; + +function membershipId(tenantId: string, userId: string): string { + return `${tenantId}:${userId}`; +} + +function membershipRecord( + def: ObjectTypeDef, + row: CoreTenantMembership +): RecordRow { + return record(def, membershipId(row.tenant_id, row.user_id), { + user_id: row.user_id, + tenant_id: row.tenant_id, + role: row.role, + created_at: row.created_at, + }); +} + +function targetTenant(data: RecordData, ctx: OperationContext): string { + const tenantId = + typeof data.tenant_id === "string" ? data.tenant_id : ctx.tenantId; + if (!tenantId) throw httpError(400, "tenant_id required"); + if (!ctx.isAdmin && tenantId !== ctx.tenantId) { + throw httpError(404, "Membership not found"); + } + return tenantId; +} + +function parseMembershipRole(value: unknown): MembershipRole { + if (value !== "viewer" && value !== "editor" && value !== "owner") { + throw httpError(400, "role must be viewer, editor, or owner"); + } + return value; +} + +function getMembership( + core: CoreDatabase, + id: string +): CoreTenantMembership | undefined { + const separator = id.indexOf(":"); + if (separator < 1) return undefined; + return core + .prepare( + `SELECT * FROM tenant_memberships WHERE tenant_id=? AND user_id=?` + ) + .get(id.slice(0, separator), id.slice(separator + 1)) as + | CoreTenantMembership + | undefined; +} + +function protectMembershipMutation( + core: CoreDatabase, + row: CoreTenantMembership, + nextRole?: MembershipRole +): void { + if (row.user_id === SYSTEM_USER_ID) { + throw httpError(400, "Cannot modify the system identity"); + } + const tenant = core + .prepare(`SELECT is_operator, owner_user_id FROM tenants WHERE id=?`) + .get(row.tenant_id) as + | { is_operator: number; owner_user_id: string } + | undefined; + if (!tenant) throw httpError(404, "Tenant not found"); + if (tenant.is_operator && row.user_id === tenant.owner_user_id) { + throw httpError(400, "Cannot modify the operator tenant owner"); + } + if (row.role === "owner" && nextRole !== "owner") { + const owners = ( + core + .prepare( + `SELECT COUNT(*) AS count FROM tenant_memberships + WHERE tenant_id=? AND role='owner'` + ) + .get(row.tenant_id) as { count: number } + ).count; + if (owners <= 1) throw httpError(400, "Cannot remove the last workspace owner"); + } +} + +export const tenantMembershipAdapter: RecordAdapter = { + id: "tenant_membership_service", + list(core, def, query, ctx) { + requireUser(ctx); + const tenantId = targetTenant(query.filters ?? {}, ctx); + const rows = core + .prepare( + `SELECT * FROM tenant_memberships WHERE tenant_id=? + ORDER BY role DESC, created_at` + ) + .all(tenantId) as CoreTenantMembership[]; + const result = page(rows, query); + return { + objectType: def.name, + records: result.rows.map((row) => membershipRecord(def, row)), + total: result.total, + }; + }, + get(core, def, id, ctx) { + requireUser(ctx); + const row = getMembership(core, id); + if (!row || (!ctx.isAdmin && row.tenant_id !== ctx.tenantId)) return null; + return membershipRecord(def, row); + }, + create(core, def, data, ctx) { + requireTenantOwner(ctx); + const tenantId = targetTenant(data, ctx); + const userId = requiredText(data, "user_id"); + if (userId === SYSTEM_USER_ID) throw httpError(400, "Cannot add the system identity"); + const user = core.prepare(`SELECT id FROM users WHERE id=?`).get(userId); + if (!user) throw httpError(404, "User not found"); + const role = parseMembershipRole(data.role ?? "viewer"); + try { + core + .prepare( + `INSERT INTO tenant_memberships (user_id, tenant_id, role) + VALUES (?, ?, ?)` + ) + .run(userId, tenantId, role); + } catch { + throw httpError(409, "Membership already exists"); + } + return membershipRecord(def, getMembership(core, membershipId(tenantId, userId))!); + }, + update(core, def, id, data, ctx) { + requireTenantOwner(ctx); + const row = getMembership(core, id); + if (!row || (!ctx.isAdmin && row.tenant_id !== ctx.tenantId)) { + throw httpError(404, "Membership not found"); + } + const role = parseMembershipRole(data.role); + protectMembershipMutation(core, row, role); + core + .prepare(`UPDATE tenant_memberships SET role=? WHERE user_id=? AND tenant_id=?`) + .run(role, row.user_id, row.tenant_id); + return membershipRecord(def, { ...row, role }); + }, + delete(core, _def, id, ctx) { + requireTenantOwner(ctx); + const row = getMembership(core, id); + if (!row || (!ctx.isAdmin && row.tenant_id !== ctx.tenantId)) { + throw httpError(404, "Membership not found"); + } + protectMembershipMutation(core, row); + core + .prepare(`DELETE FROM tenant_memberships WHERE user_id=? AND tenant_id=?`) + .run(row.user_id, row.tenant_id); + }, + actions: { + set_role(core, def, id, input, ctx) { + return tenantMembershipAdapter.update!(core, def, id, input, ctx); + }, + remove(core, def, id, _input, ctx) { + tenantMembershipAdapter.delete!(core, def, id, ctx); + return { ok: true }; + }, + }, +}; + +export const tenantProvisioningRunAdapter: RecordAdapter = { + id: "tenant_provisioning_run_service", + list(core, def, query, ctx) { + const actorId = requireUser(ctx); + const rows = listRuns(core).filter( + (run) => ctx.isAdmin || run.actor_user_id === actorId + ); + const result = page(rows, query); + return { + objectType: def.name, + records: result.rows.map((run) => lifecycleRecord(def, run)), + total: result.total, + }; + }, + get(core, def, id, ctx) { + const actorId = requireUser(ctx); + const run = listRuns(core).find((candidate) => candidate.id === id); + return run && (ctx.isAdmin || run.actor_user_id === actorId) + ? lifecycleRecord(def, run) + : null; + }, +}; + +const writeRoles: ActionDef["roles"] = ["editor", "owner", "intelligence"]; +const schema = ( + properties: Record, + required: string[] = [] +): Record => ({ + type: "object", + additionalProperties: false, + properties, + ...(required.length ? { required } : {}), +}); +const action = ( + name: string, + options: Partial = {} +): ActionDef => ({ + name, + label: name + .split("_") + .map((part) => part[0]!.toUpperCase() + part.slice(1)) + .join(" "), + target: "record", + effect: "write", + execution: "sync", + roles: writeRoles, + inputSchema: schema({}), + ...options, +}); + +export const IDENTITY_ADMIN_ACTIONS: Record = { + User: [ + action("create_account", { + target: "collection", + confirmation: { required: true }, + idempotency: { required: true }, + sensitiveInputPaths: ["password"], + inputSchema: schema( + { + email: { type: "string" }, + password: { type: "string", minLength: 6 }, + display_name: { type: "string" }, + is_admin: { type: "boolean" }, + }, + ["email", "password"] + ), + }), + action("signup", { + target: "collection", + sensitiveInputPaths: ["password"], + inputSchema: schema( + { + email: { type: "string" }, + password: { type: "string", minLength: 6 }, + display_name: { type: "string" }, + }, + ["email", "password"] + ), + }), + ], + UserProfile: [ + action("update_profile", { + inputSchema: schema( + Object.fromEntries( + ["display_name", "avatar_url", ...PROFILE_COLUMNS].map((name) => [ + name, + { type: ["string", "null"] }, + ]) + ) + ), + }), + ], + UserCredential: [ + action("change_password", { + sensitiveInputPaths: ["current_password", "new_password"], + inputSchema: schema( + { + current_password: { type: "string", minLength: 1 }, + new_password: { type: "string", minLength: 6 }, + }, + ["new_password"] + ), + }), + action("reset_password", { + roles: ["owner", "intelligence"], + confirmation: { required: true }, + sensitiveInputPaths: ["new_password"], + inputSchema: schema( + { new_password: { type: "string", minLength: 6 } }, + ["new_password"] + ), + }), + ], + Tenant: [ + action("provision", { + target: "collection", + confirmation: { required: true }, + idempotency: { required: true }, + inputSchema: schema( + { + owner_user_id: { type: "string" }, + name: { type: "string" }, + slug: { type: "string" }, + }, + ["name"] + ), + }), + action("deprovision", { + effect: "destructive", + confirmation: { required: true }, + idempotency: { required: true }, + }), + ], + TenantMembership: [ + action("set_role", { + confirmation: { required: true }, + inputSchema: schema( + { role: { enum: ["viewer", "editor", "owner"] } }, + ["role"] + ), + }), + action("remove", { + effect: "destructive", + confirmation: { required: true }, + }), + ], +}; + +export const identityAdminAdapters = [ + userAdminAdapter, + userProfileAdapter, + userCredentialAdapter, + tenantAdminAdapter, + tenantMembershipAdapter, + tenantProvisioningRunAdapter, +] as const; diff --git a/apps/bridge/src/kernel/adapters/platform-actions.ts b/apps/bridge/src/kernel/adapters/platform-actions.ts index 646694b..a011e7a 100644 --- a/apps/bridge/src/kernel/adapters/platform-actions.ts +++ b/apps/bridge/src/kernel/adapters/platform-actions.ts @@ -4,20 +4,32 @@ import type { RecordData, RecordRow, } from "@godmode/kernel"; +import { randomUUID } from "node:crypto"; +import { config } from "../../config.js"; import type { AppDatabase } from "../../db.js"; import { createShareGrant, listShareGrantsForUser, + resolveShareAccess, revokeShareGrant, } from "../../services/share-service.js"; import { + addConversationMember, createConversation, createMessage, getConversationForUser, listConversationsForUser, listMessages, markConversationRead, + removeConversationMember, + shareResourceToConversation, + userCanAccessBlob, } from "../../services/dm-service.js"; +import { + BlobStoreError, + getDmBlob, + storeDmBlob, +} from "../../services/blob-store.js"; import { addMessage, createTicket, @@ -30,9 +42,22 @@ import { } from "../../services/support-service.js"; import { addCatalogSource, + fetchOfficialCatalog, + fetchUnofficialCatalog, + installCatalogEntry, + installDiscoveredPlugin, + listCatalogInstalls, listCatalogSources, + registerLocalPluginFolder, + removeLocalPluginFolder, removeCatalogSource, } from "../../services/marketplace-catalog.js"; +import { + activatePluginForTenant, + loadPluginsForBoot, + reconcilePluginLifecycle, + uninstallPluginForTenant, +} from "../../services/plugin-lifecycle.js"; import { acquireLiveListing, cancelEntitlement, @@ -43,18 +68,43 @@ import { deleteBridgeConnection, getBridgeConnection, listBridgeConnections, + probeBridgeConnection, touchBridgeConnection, } from "../../services/bridge-connections.js"; -import { listPeerConnections } from "../../services/federation-peers.js"; +import { + acceptPeerConnection, + enableTailscaleFederation, + invitePeerByEmail, + listPeerConnections, + refreshPeerHealth, +} from "../../services/federation-peers.js"; import { createInferenceEndpoint, + findActiveEndpointByModelPath, getInferenceEndpoint, listInferenceEndpoints, } from "../../services/inference-service.js"; +import { + acquireCloneListing, + archiveMarketplaceListing, + publishMarketplaceListing, +} from "../../services/marketplace-listings.js"; +import { exportEntity, importEntity, type PortableBundle } from "../../services/portability.js"; +import { + addGroupMember, + ensurePlatformGroups, + listGroupMembers, + removeGroupMember, +} from "../../services/platform-groups.js"; import { HoldingsService, type HoldingCategory, } from "../../services/holdings/holdings-service.js"; +import { CredentialStore } from "../../services/holdings/credential-store.js"; +import { CryptoProvider } from "../../services/holdings/crypto-provider.js"; +import { PayPalService } from "../../services/holdings/paypal-service.js"; +import { runConfiguredRemoteInference } from "./runtime.js"; +import { getShareBroker } from "../../ws-broker.js"; import type { OperationContext, RecordAdapter, @@ -83,13 +133,6 @@ function requiredText(data: RecordData, name: string): string { return value.trim(); } -function unsupported(operation: string): never { - throw httpError( - 501, - `${operation} is not available through the kernel; use the policy-enforcing platform workflow` - ); -} - function page(rows: T[], query: RecordQuery): { rows: T[]; total: number } { const offset = Math.max(Number(query.offset) || 0, 0); const limit = Math.min(Math.max(Number(query.limit) || 100, 1), 500); @@ -174,6 +217,8 @@ export const shareGrantAdapter: RecordAdapter = { granteeTenantId: typeof data.grantee_tenant_id === "string" ? data.grantee_tenant_id : undefined, role: typeof data.role === "string" ? (data.role as never) : undefined, + expiresAt: + typeof data.expires_at === "string" ? data.expires_at : undefined, }); return this.get!(core, def, id, ctx)!; }, @@ -188,6 +233,134 @@ export const shareGrantAdapter: RecordAdapter = { revokeShareGrant(ctx.data!.coreDb, id, requireUser(ctx)); return { ok: true }; }, + share_model(_db, _def, _id, input, ctx) { + const core = ctx.data!.coreDb; + const userId = requireUser(ctx); + const tenantId = requireTenant(ctx); + const modelPath = requiredText(input, "model_path"); + let granteeUserId = + typeof input.grantee_user_id === "string" && input.grantee_user_id.trim() + ? input.grantee_user_id.trim() + : undefined; + if (!granteeUserId && typeof input.grantee_email === "string") { + const user = core + .prepare("SELECT id FROM users WHERE email=?") + .get(input.grantee_email.trim().toLowerCase()) as { id: string } | undefined; + granteeUserId = user?.id; + } + if (!granteeUserId) throw httpError(404, "Share recipient not found"); + if (granteeUserId === userId) throw httpError(400, "Cannot share a model with yourself"); + const existing = findActiveEndpointByModelPath(core, userId, modelPath); + const endpointId: string = + (typeof existing?.id === "string" ? existing.id : undefined) ?? + createInferenceEndpoint(core, { + ownerTenantId: tenantId, + ownerUserId: userId, + name: + (typeof input.name === "string" && input.name.trim()) || + modelPath.split(/[\\/]/).pop()!.replace(/\.gguf$/i, ""), + baseModelPath: modelPath, + }); + const id = createShareGrant(core, { + ownerTenantId: tenantId, + ownerUserId: userId, + resourceKind: "model", + resourceId: endpointId, + granteeUserId, + role: "viewer", + bridgeUrl: null, + federationToken: null, + }); + getShareBroker().broadcastResource("model", endpointId, { + type: "share_granted", + grantId: id, + }); + return { id, endpointId }; + }, + clone_shared(db, _def, _id, input, ctx) { + const kind = requiredText(input, "kind"); + const resourceId = requiredText(input, "resource_id"); + const access = resolveShareAccess(ctx.data!.coreDb, { + userId: requireUser(ctx), + tenantId: requireTenant(ctx), + resourceKind: kind as never, + resourceId, + minRole: "viewer", + }); + if (!access) { + throw httpError(403, "No access to shared resource"); + } + const bundle = exportEntity(access.db, kind as never, resourceId); + return { ok: true, ...importEntity(db, bundle) }; + }, + }, +}; + +export const federatedShareInviteAdapter: RecordAdapter = { + id: "federated_share_invite_service", + actions: { + accept(_db, _def, _id, input, ctx) { + const core = ctx.data!.coreDb; + const actorId = requireUser(ctx); + const token = requiredText(input, "invite_token"); + return core.transaction(() => { + const invite = core + .prepare( + `SELECT * FROM federated_share_invites + WHERE invite_token=? AND status='pending'` + ) + .get(token) as Record | undefined; + if (!invite) throw httpError(404, "Invite not found or already accepted"); + const expiresAt = invite.expires_at + ? Date.parse(String(invite.expires_at)) + : NaN; + if (Number.isFinite(expiresAt) && expiresAt < Date.now()) { + throw httpError(410, "Invite expired"); + } + const actor = core + .prepare("SELECT email FROM users WHERE id=?") + .get(actorId) as { email: string } | undefined; + if ( + !actor || + actor.email.trim().toLowerCase() !== + String(invite.invitee_email).trim().toLowerCase() + ) { + throw httpError(403, "Invite is bound to a different account email"); + } + const granteeTenantId = + typeof input.grantee_tenant_id === "string" && input.grantee_tenant_id.trim() + ? input.grantee_tenant_id.trim() + : requireTenant(ctx); + const membership = core + .prepare( + "SELECT 1 FROM tenant_memberships WHERE user_id=? AND tenant_id=?" + ) + .get(actorId, granteeTenantId); + if (!membership) throw httpError(403, "No access to grantee workspace"); + + const federationToken = randomUUID(); + const grantId = createShareGrant(core, { + ownerTenantId: String(invite.owner_tenant_id), + ownerUserId: String(invite.owner_user_id), + resourceKind: invite.resource_kind as never, + resourceId: String(invite.resource_id), + granteeUserId: actorId, + granteeTenantId, + role: (invite.role as never) ?? "viewer", + bridgeUrl: config.federation.publicUrl, + federationToken, + }); + core.prepare( + `UPDATE federated_share_invites + SET status='accepted' WHERE id=? AND status='pending'` + ).run(String(invite.id)); + return { + grantId, + federationToken, + ownerBridgeUrl: config.federation.publicUrl, + }; + })(); + }, }, }; @@ -248,6 +421,35 @@ export const directConversationAdapter: RecordAdapter = { ); return { ok: true }; }, + add_member(_db, _def, id, input, ctx) { + return addConversationMember( + ctx.data!.coreDb, + id, + requireUser(ctx), + requiredText(input, "user_id") + ); + }, + remove_member(_db, _def, id, input, ctx) { + removeConversationMember( + ctx.data!.coreDb, + id, + requireUser(ctx), + requiredText(input, "user_id") + ); + return { ok: true }; + }, + share(_db, _def, id, input, ctx) { + return { + grants: shareResourceToConversation(ctx.data!.coreDb, { + conversationId: id, + actorUserId: requireUser(ctx), + actorTenantId: requireTenant(ctx), + resourceKind: requiredText(input, "resource_kind") as never, + resourceId: requiredText(input, "resource_id"), + role: typeof input.role === "string" ? (input.role as never) : undefined, + }), + }; + }, }, }; @@ -307,6 +509,46 @@ export const directMessageAdapter: RecordAdapter = { }, }; +export const dmBlobAdapter: RecordAdapter = { + id: "dm_blob_service", + list(_db, def, query, ctx) { + const rows = ctx.data!.coreDb + .prepare( + `SELECT id, owner_user_id, filename, mime, size, created_at + FROM dm_blobs WHERE owner_user_id=? ORDER BY created_at DESC` + ) + .all(requireUser(ctx)) as Array>; + return result(def, rows, query); + }, + get(_db, def, id, ctx) { + const core = ctx.data!.coreDb; + const row = getDmBlob(core, id); + if (!row || !userCanAccessBlob(core, id, requireUser(ctx))) return null; + const { path: _path, ...metadata } = row; + return record(def, metadata as unknown as Record); + }, + actions: { + upload(_db, def, _id, input, ctx) { + if (!Buffer.isBuffer(input.buffer)) { + throw httpError(400, "Binary upload buffer required"); + } + try { + const row = storeDmBlob(ctx.data!.coreDb, { + ownerUserId: requireUser(ctx), + filename: requiredText(input, "filename"), + mime: requiredText(input, "mime"), + buffer: input.buffer, + }); + const { path: _path, ...metadata } = row; + return record(def, metadata as unknown as Record); + } catch (error) { + if (error instanceof BlobStoreError) throw httpError(400, error.message); + throw error; + } + }, + }, +}; + function canAccessTicket( row: NonNullable>, ctx: OperationContext @@ -488,8 +730,91 @@ export const catalogSourceAdapter: RecordAdapter = { } return { ok: true }; }, - fetch_external() { - return unsupported("External catalog fetch"); + async fetch_external(_db, _def, _id, _input, ctx) { + const [official, unofficial] = await Promise.all([ + fetchOfficialCatalog(), + fetchUnofficialCatalog(ctx.data!.coreDb, requireUser(ctx)), + ]); + return { official, unofficial }; + }, + }, +}; + +export const catalogInstallAdapter: RecordAdapter = { + id: "catalog_install_read", + list(_db, def, query, ctx) { + return result(def, listCatalogInstalls(ctx.data!.coreDb, requireTenant(ctx)), query); + }, + get(_db, def, id, ctx) { + const row = listCatalogInstalls(ctx.data!.coreDb, requireTenant(ctx)).find( + (candidate) => String(candidate.id) === id + ); + return row ? record(def, row) : null; + }, + actions: { + async activate_plugin_path(_db, _def, _id, input, ctx) { + return activatePluginForTenant( + ctx.data!.coreDb, + requireTenant(ctx), + requiredText(input, "path"), + { + buildIfNeeded: input.build_if_needed !== false, + installForTenant: input.install_for_tenant !== false, + reload: input.reload !== false, + } + ); + }, + install_entry(_db, _def, _id, input, ctx) { + return installCatalogEntry(ctx.data!.coreDb, ctx.data!.tenantDb, { + userId: requireUser(ctx), + tenantId: requireTenant(ctx), + entryId: requiredText(input, "entry_id"), + sourceCatalog: + typeof input.source_catalog === "string" ? input.source_catalog : undefined, + }); + }, + async install_plugin(_db, _def, _id, input, ctx) { + await installDiscoveredPlugin( + ctx.data!.coreDb, + requireTenant(ctx), + requiredText(input, "plugin_id") + ); + return { ok: true, pluginId: input.plugin_id }; + }, + register_local_plugin(_db, _def, _id, input, ctx) { + return registerLocalPluginFolder( + ctx.data!.coreDb, + requireTenant(ctx), + requiredText(input, "path"), + { userId: requireUser(ctx), installForTenant: true } + ); + }, + unregister_local_plugin(_db, _def, _id, input, ctx) { + if (!removeLocalPluginFolder(ctx.data!.coreDb, requiredText(input, "path"))) { + throw httpError(404, "Local plugin registration not found"); + } + return { ok: true }; + }, + async uninstall_plugin(_db, _def, _id, input, ctx) { + await uninstallPluginForTenant( + ctx.data!.coreDb, + requireTenant(ctx), + requiredText(input, "plugin_id") + ); + return { ok: true, pluginId: input.plugin_id }; + }, + async load_runtime(_db, _def, _id, _input, ctx) { + if (ctx.source !== "system") throw httpError(403, "System lifecycle action required"); + return loadPluginsForBoot(); + }, + async reconcile_runtime(_db, _def, _id, input, ctx) { + if (ctx.source !== "system") throw httpError(403, "System lifecycle action required"); + await reconcilePluginLifecycle( + ctx.data!.coreDb, + requiredText(input, "operator_tenant_id"), + ctx.data!.tenantDb + ); + return { ok: true }; }, }, }; @@ -517,8 +842,8 @@ export const marketplaceListingAdapter: RecordAdapter = { return row ? record(def, row) : null; }, actions: { - acquire_live(_db, _def, id, _input, ctx) { - const listing = ctx.data!.coreDb + acquire(coreDb, _def, id, _input, ctx) { + const listing = coreDb .prepare( `SELECT * FROM marketplace_listings WHERE id=? AND status='active' AND visibility='public'` @@ -526,19 +851,79 @@ export const marketplaceListingAdapter: RecordAdapter = { .get(id) as Record | undefined; if (!listing) throw httpError(404, "Listing not found"); if (listing.delivery_mode !== "live") { - return unsupported("Clone marketplace acquisition"); + return acquireCloneListing( + { core: coreDb, buyerTenant: ctx.data!.tenantDb }, + { + listingId: id, + buyerUserId: requireUser(ctx), + buyerTenantId: requireTenant(ctx), + idempotencyKey: ctx.idempotencyKey!, + } + ); } - return acquireLiveListing(ctx.data!.coreDb, { + return acquireLiveListing(coreDb, { listing, buyerUserId: requireUser(ctx), buyerTenantId: requireTenant(ctx), }); }, - publish() { - return unsupported("Marketplace publishing"); + acquire_live(db, def, id, input, ctx) { + return marketplaceListingAdapter.actions!.acquire(db, def, id, input, ctx); + }, + publish(_db, def, _id, input, ctx) { + const row = publishMarketplaceListing(ctx.data!.coreDb, ctx.data!.tenantDb, { + sellerUserId: requireUser(ctx), + sellerTenantId: requireTenant(ctx), + kind: requiredText(input, "kind") as never, + resourceId: + typeof input.resource_id === "string" ? input.resource_id : undefined, + title: typeof input.title === "string" ? input.title : undefined, + description: + typeof input.description === "string" ? input.description : undefined, + priceCredits: + typeof input.price_credits === "number" ? input.price_credits : undefined, + deliveryMode: + typeof input.delivery_mode === "string" ? (input.delivery_mode as never) : undefined, + pricingModel: + typeof input.pricing_model === "string" ? (input.pricing_model as never) : undefined, + pricePeriod: + typeof input.price_period === "string" ? input.price_period : undefined, + meterUnit: typeof input.meter_unit === "string" ? input.meter_unit : undefined, + meterRate: typeof input.meter_rate === "number" ? input.meter_rate : undefined, + license: typeof input.license === "string" ? input.license : undefined, + inferenceEndpointId: + typeof input.inference_endpoint_id === "string" + ? input.inference_endpoint_id + : undefined, + bundleChildren: Array.isArray(input.bundle_children) + ? (input.bundle_children as PortableBundle[]) + : undefined, + }); + return record(def, row); + }, + archive(_db, def, id, _input, ctx) { + return record( + def, + archiveMarketplaceListing(ctx.data!.coreDb, { + listingId: id, + sellerUserId: requireUser(ctx), + sellerTenantId: requireTenant(ctx), + }) + ); }, - archive() { - return unsupported("Marketplace archival"); + export_portable(_db, _def, _id, input, ctx) { + return { + bundle: exportEntity( + ctx.data!.tenantDb, + requiredText(input, "kind") as never, + requiredText(input, "resource_id") + ), + }; + }, + import_portable(_db, _def, _id, input, ctx) { + const bundle = input.bundle as PortableBundle | undefined; + if (!bundle || bundle.version !== 1) throw httpError(400, "Valid bundle required"); + return importEntity(ctx.data!.tenantDb, bundle); }, }, }; @@ -625,8 +1010,12 @@ export const bridgeConnectionAdapter: RecordAdapter = { touchBridgeConnection(ctx.data!.coreDb, id); return { ok: true }; }, - probe_remote() { - return unsupported("Remote bridge probing"); + probe_remote(_db, _def, id, _input, ctx) { + const row = getBridgeConnection(ctx.data!.coreDb, id); + if (!row || row.owner_tenant_id !== requireTenant(ctx)) { + throw httpError(404, "Bridge connection not found"); + } + return probeBridgeConnection(ctx.data!.coreDb, row, ctx.signal); }, }, }; @@ -651,14 +1040,53 @@ export const peerConnectionAdapter: RecordAdapter = { : null; }, actions: { - invite() { - return unsupported("Peer invitation"); + enable_tailscale() { + return enableTailscaleFederation(); }, - accept() { - return unsupported("Peer acceptance"); + invite(_db, _def, _id, input, ctx) { + return invitePeerByEmail( + ctx.data!.coreDb, + requireUser(ctx), + requiredText(input, "email"), + typeof input.remote_bridge_url === "string" + ? input.remote_bridge_url + : undefined + ); }, - refresh_health() { - return unsupported("Peer health refresh"); + accept(_db, _def, _id, input, ctx) { + return { + id: acceptPeerConnection(ctx.data!.coreDb, requireUser(ctx), { + remoteBridgeUrl: requiredText(input, "remote_bridge_url"), + federationToken: requiredText(input, "federation_token"), + remoteUserId: + typeof input.remote_user_id === "string" ? input.remote_user_id : undefined, + remoteDisplayName: + typeof input.remote_display_name === "string" + ? input.remote_display_name + : undefined, + remoteEmail: + typeof input.remote_email === "string" ? input.remote_email : undefined, + tailscaleNodeId: + typeof input.tailscale_node_id === "string" + ? input.tailscale_node_id + : undefined, + tailscaleDnsName: + typeof input.tailscale_dns_name === "string" + ? input.tailscale_dns_name + : undefined, + }), + }; + }, + async refresh_health(_db, _def, id, _input, ctx) { + const userId = requireUser(ctx); + if ( + id && + !listPeerConnections(ctx.data!.coreDb, userId).some((peer) => peer.id === id) + ) { + throw httpError(404, "Peer connection not found"); + } + await refreshPeerHealth(ctx.data!.coreDb, userId); + return { ok: true }; }, }, }; @@ -703,8 +1131,28 @@ export const inferenceEndpointAdapter: RecordAdapter = { publish(db, def, _id, input, ctx) { return inferenceEndpointAdapter.create!(db, def, input, ctx); }, - run_remote() { - return unsupported("Remote inference"); + async run_remote(_db, _def, id, input, ctx) { + const rawMessages = Array.isArray(input.messages) ? input.messages : []; + const content = await runConfiguredRemoteInference({ + core: ctx.data!.coreDb, + endpointId: id || requiredText(input, "endpoint_id"), + buyerUserId: requireUser(ctx), + buyerTenantId: requireTenant(ctx), + messages: rawMessages.map((message) => { + const value = message as Record; + return { + role: value.role as never, + content: String(value.content ?? ""), + }; + }), + sampling: + input.sampling && typeof input.sampling === "object" + ? (input.sampling as never) + : undefined, + priority: typeof input.priority === "number" ? input.priority : undefined, + signal: ctx.signal, + }); + return { ok: true, content }; }, }, }; @@ -760,6 +1208,37 @@ export const financeConnectionAdapter: RecordAdapter = { } }, actions: { + async configure_moralis(db, _def, _id, input) { + const credentials = new CredentialStore(db); + credentials.setMoralisApiKey(requiredText(input, "api_key")); + const test = await new CryptoProvider(credentials).testConnection(); + if (!test.ok) { + throw httpError(400, test.error ?? "Moralis key rejected"); + } + return { ok: true, configured: true }; + }, + async configure_paypal(db, _def, _id, input) { + const credentials = new CredentialStore(db); + const env = input.env === "live" ? "live" : "sandbox"; + credentials.setPayPalCredentials({ + clientId: requiredText(input, "client_id"), + clientSecret: requiredText(input, "client_secret"), + env, + }); + const test = await new PayPalService(credentials).testConnection(); + if (!test.ok) { + throw httpError(400, test.error ?? "PayPal credentials rejected"); + } + return { ok: true, configured: true, env }; + }, + async preview_crypto(db, _def, _id, input) { + return new CryptoProvider(new CredentialStore(db)).fetchPortfolio( + requiredText(input, "address"), + Array.isArray(input.chains) + ? input.chains.filter((chain): chain is string => typeof chain === "string") + : undefined + ); + }, add_manual(db, def, _id, input, ctx) { return financeConnectionAdapter.create!(db, def, input, ctx); }, @@ -769,43 +1248,252 @@ export const financeConnectionAdapter: RecordAdapter = { } return { ok: true }; }, - connect_external() { - return unsupported("External finance connection"); + async connect_external(db, def, _id, input) { + const credentials = new CredentialStore(db); + const holdings = new HoldingsService(db); + const provider = requiredText(input, "provider"); + if (provider === "paypal") { + if ( + typeof input.client_id === "string" && + typeof input.client_secret === "string" + ) { + credentials.setPayPalCredentials({ + clientId: input.client_id, + clientSecret: input.client_secret, + env: input.env === "live" ? "live" : "sandbox", + }); + } + const balance = await new PayPalService(credentials).fetchBalance(); + return record( + def, + holdings.upsertPayPal( + typeof input.label === "string" ? input.label : "PayPal Business", + balance + ) as unknown as Record, + FINANCE_ALIASES + ); + } + if (provider === "moralis" || provider === "crypto") { + if (typeof input.api_key === "string") { + credentials.setMoralisApiKey(input.api_key); + } + const address = requiredText(input, "address"); + const portfolio = await new CryptoProvider(credentials).fetchPortfolio( + address, + Array.isArray(input.chains) + ? input.chains.filter((chain): chain is string => typeof chain === "string") + : undefined + ); + return record( + def, + holdings.upsertCryptoWallet( + typeof input.wallet_provider === "string" + ? input.wallet_provider + : "wallet", + typeof input.label === "string" ? input.label : "Crypto Wallet", + portfolio + ) as unknown as Record, + FINANCE_ALIASES + ); + } + throw httpError(400, "provider must be paypal, moralis, or crypto"); }, - refresh_external() { - return unsupported("External finance refresh"); + async refresh_external(db, def, id) { + const credentials = new CredentialStore(db); + const holdings = new HoldingsService(db); + const connection = holdings.get(id); + if (!connection) throw httpError(404, "Finance connection not found"); + try { + if (connection.category === "wallet" && connection.reference) { + const portfolio = await new CryptoProvider(credentials).fetchPortfolio( + connection.reference + ); + return record( + def, + holdings.updateBalance( + id, + portfolio.totalUsd, + "USD", + portfolio.totalCad, + { tokens: portfolio.tokens } + ) as unknown as Record, + FINANCE_ALIASES + ); + } + if (connection.category === "paypal") { + const balance = await new PayPalService(credentials).fetchBalance(); + return record( + def, + holdings.updateBalance( + id, + balance.total, + balance.currency, + balance.totalCad, + balance.raw + ) as unknown as Record, + FINANCE_ALIASES + ); + } + throw httpError(400, "Refresh not supported for this connection type"); + } catch (error) { + holdings.updateBalance( + id, + connection.balance, + connection.currency, + connection.balanceCad, + connection.breakdown, + "error" + ); + throw error; + } + }, + }, +}; + +function requirePlatformAdmin(ctx: OperationContext): void { + requireUser(ctx); + if (!ctx.isAdmin) throw httpError(403, "Platform administrator required"); +} + +export const platformGroupAdapter: RecordAdapter = { + id: "platform_group_service", + list(_db, def, query, ctx) { + const core = ctx.data!.coreDb; + ensurePlatformGroups(core); + const userId = requireUser(ctx); + const rows = ctx.isAdmin + ? (core.prepare("SELECT * FROM platform_groups ORDER BY name").all() as Array< + Record + >) + : (core + .prepare( + `SELECT DISTINCT g.* FROM platform_groups g + JOIN platform_group_members m ON m.group_id=g.id + WHERE m.member_kind='user' AND m.member_id=? + ORDER BY g.name` + ) + .all(userId) as Array>); + return result(def, rows, query); + }, + get(db, def, id, ctx) { + return this.list!(db, def, {}, ctx).records.find((row) => row.id === id) ?? null; + }, +}; + +export const platformGroupMemberAdapter: RecordAdapter = { + id: "platform_group_member_service", + list(_db, def, query, ctx) { + const core = ctx.data!.coreDb; + ensurePlatformGroups(core); + const groupId = + typeof query.filters?.group_id === "string" ? query.filters.group_id : ""; + if (!groupId) throw httpError(400, "group_id filter required"); + if (!ctx.isAdmin) { + const membership = core + .prepare( + `SELECT 1 FROM platform_group_members + WHERE group_id=? AND member_kind='user' AND member_id=?` + ) + .get(groupId, requireUser(ctx)); + if (!membership) throw httpError(403, "Group membership required"); + } + return result( + def, + listGroupMembers(groupId, core).map((member) => ({ + ...member, + id: `${member.group_id}:${member.member_kind}:${member.member_id}:${member.tenant_id ?? ""}`, + })), + query + ); + }, + get(db, def, id, ctx) { + const [groupId] = id.split(":"); + if (!groupId) return null; + return ( + this.list!( + db, + def, + { filters: { group_id: groupId }, limit: 500 }, + ctx + ).records.find((row) => row.id === id) ?? null + ); + }, + actions: { + add(_db, def, _id, input, ctx) { + requirePlatformAdmin(ctx); + return record( + def, + { + ...addGroupMember( + { + groupId: requiredText(input, "group_id"), + memberKind: requiredText(input, "member_kind") as never, + memberId: requiredText(input, "member_id"), + tenantId: + typeof input.tenant_id === "string" ? input.tenant_id : undefined, + }, + ctx.data!.coreDb + ), + id: `${input.group_id}:${input.member_kind}:${input.member_id}:${input.tenant_id ?? ""}`, + } as unknown as Record + ); + }, + remove(_db, _def, _id, input, ctx) { + requirePlatformAdmin(ctx); + return { + ok: removeGroupMember( + { + groupId: requiredText(input, "group_id"), + memberKind: requiredText(input, "member_kind") as never, + memberId: requiredText(input, "member_id"), + tenantId: + typeof input.tenant_id === "string" ? input.tenant_id : undefined, + }, + ctx.data!.coreDb + ), + }; }, }, }; export const platformActionAdapters = [ shareGrantAdapter, + federatedShareInviteAdapter, directConversationAdapter, directMessageAdapter, + dmBlobAdapter, supportTicketAdapter, supportMessageAdapter, catalogSourceAdapter, + catalogInstallAdapter, marketplaceListingAdapter, marketplaceEntitlementAdapter, bridgeConnectionAdapter, peerConnectionAdapter, inferenceEndpointAdapter, financeConnectionAdapter, + platformGroupAdapter, + platformGroupMemberAdapter, ] as const; const OBJECT_TYPE_BY_ADAPTER_ID: Record = { share_grant_read: "ShareGrant", + federated_share_invite_service: "FederatedShareInvite", dm_conversation_read: "DirectConversation", dm_message_read: "DirectMessage", + dm_blob_service: "DmBlob", support_ticket_read: "SupportTicket", support_message_read: "SupportMessage", catalog_source_read: "CatalogSource", + catalog_install_read: "CatalogInstall", marketplace_listing_read: "MarketplaceListing", marketplace_entitlement_read: "MarketplaceEntitlement", bridge_connection_read: "BridgeConnection", peer_connection_read: "PeerConnection", inference_endpoint_read: "InferenceEndpoint", finance_connection_service: "FinanceConnection", + platform_group_service: "PlatformGroup", + platform_group_member_service: "PlatformGroupMember", }; /** Registration metadata for the ObjectType bootstrap layer to consume. */ @@ -870,6 +1558,45 @@ export const PLATFORM_ACTION_METADATA: Record = { effect: "destructive", confirmation: { required: true }, }), + action("share_model", { + target: "collection", + effect: "external", + confirmation: { required: true }, + idempotency: { required: true }, + inputSchema: objectSchema( + { + model_path: { type: "string" }, + grantee_user_id: { type: "string" }, + grantee_email: { type: "string" }, + name: { type: "string" }, + }, + ["model_path"] + ), + }), + action("clone_shared", { + target: "collection", + effect: "write", + confirmation: { required: true }, + idempotency: { required: true }, + inputSchema: objectSchema( + { kind: { type: "string" }, resource_id: { type: "string" } }, + ["kind", "resource_id"] + ), + }), + ], + FederatedShareInvite: [ + action("accept", { + target: "collection", + roles: ["viewer", ...writeRoles], + sensitiveInputPaths: ["invite_token"], + inputSchema: objectSchema( + { + invite_token: { type: "string" }, + grantee_tenant_id: { type: "string" }, + }, + ["invite_token"] + ), + }), ], DirectConversation: [ action("start", { @@ -885,6 +1612,26 @@ export const PLATFORM_ACTION_METADATA: Record = { roles: ["viewer", ...writeRoles], inputSchema: objectSchema({ message_id: { type: "string" } }), }), + action("add_member", { + inputSchema: objectSchema({ user_id: { type: "string" } }, ["user_id"]), + }), + action("remove_member", { + effect: "destructive", + confirmation: { required: true }, + inputSchema: objectSchema({ user_id: { type: "string" } }, ["user_id"]), + }), + action("share", { + confirmation: { required: true }, + idempotency: { required: true }, + inputSchema: objectSchema( + { + resource_kind: { type: "string" }, + resource_id: { type: "string" }, + role: { enum: ["viewer", "editor", "owner"] }, + }, + ["resource_kind", "resource_id"] + ), + }), ], DirectMessage: [ action("send", { @@ -900,6 +1647,20 @@ export const PLATFORM_ACTION_METADATA: Record = { ), }), ], + DmBlob: [ + action("upload", { + target: "collection", + roles: ["viewer", ...writeRoles], + inputSchema: objectSchema( + { + filename: { type: "string" }, + mime: { type: "string" }, + buffer: {}, + }, + ["filename", "mime", "buffer"] + ), + }), + ], SupportTicket: [ action("open", { target: "collection", @@ -957,21 +1718,135 @@ export const PLATFORM_ACTION_METADATA: Record = { idempotency: { required: true }, }), ], + CatalogInstall: [ + action("activate_plugin_path", { + target: "collection", + effect: "external", + confirmation: { required: true }, + idempotency: { required: true }, + inputSchema: objectSchema( + { + path: { type: "string" }, + build_if_needed: { type: "boolean" }, + install_for_tenant: { type: "boolean" }, + reload: { type: "boolean" }, + }, + ["path"] + ), + }), + action("install_entry", { + target: "collection", + effect: "external", + execution: "async", + cancellable: false, + confirmation: { required: true }, + idempotency: { required: true }, + inputSchema: objectSchema( + { + entry_id: { type: "string" }, + source_catalog: { type: "string" }, + }, + ["entry_id"] + ), + }), + action("install_plugin", { + target: "collection", + effect: "external", + execution: "async", + cancellable: false, + confirmation: { required: true }, + inputSchema: objectSchema({ plugin_id: { type: "string" } }, ["plugin_id"]), + }), + action("register_local_plugin", { + target: "collection", + effect: "external", + execution: "async", + cancellable: false, + confirmation: { required: true }, + inputSchema: objectSchema({ path: { type: "string" } }, ["path"]), + }), + action("unregister_local_plugin", { + target: "collection", + effect: "destructive", + confirmation: { required: true }, + inputSchema: objectSchema({ path: { type: "string" } }, ["path"]), + }), + action("uninstall_plugin", { + target: "collection", + effect: "destructive", + confirmation: { required: true }, + idempotency: { required: true }, + inputSchema: objectSchema({ plugin_id: { type: "string" } }, ["plugin_id"]), + }), + action("load_runtime", { + target: "collection", + effect: "external", + roles: ["owner"], + }), + action("reconcile_runtime", { + target: "collection", + roles: ["owner"], + inputSchema: objectSchema( + { operator_tenant_id: { type: "string" } }, + ["operator_tenant_id"] + ), + }), + ], MarketplaceListing: [ + action("acquire", { + effect: "external", + execution: "async", + cancellable: false, + confirmation: { required: true }, + idempotency: { required: true }, + retry: { maxAttempts: 20 }, + }), action("acquire_live", { effect: "external", confirmation: { required: true }, idempotency: { required: true }, }), action("publish", { + target: "collection", effect: "external", confirmation: { required: true }, idempotency: { required: true }, + inputSchema: objectSchema( + { + kind: { type: "string" }, + resource_id: { type: "string" }, + title: { type: "string" }, + description: { type: "string" }, + price_credits: { type: "number" }, + delivery_mode: { enum: ["clone", "live"] }, + pricing_model: { type: "string" }, + price_period: { type: "string" }, + meter_unit: { type: "string" }, + meter_rate: { type: "number" }, + license: { type: "string" }, + inference_endpoint_id: { type: "string" }, + bundle_children: { type: "array" }, + }, + ["kind"] + ), }), action("archive", { effect: "destructive", confirmation: { required: true }, }), + action("export_portable", { + target: "collection", + inputSchema: objectSchema( + { kind: { type: "string" }, resource_id: { type: "string" } }, + ["kind", "resource_id"] + ), + }), + action("import_portable", { + target: "collection", + confirmation: { required: true }, + idempotency: { required: true }, + inputSchema: objectSchema({ bundle: { type: "object" } }, ["bundle"]), + }), ], MarketplaceEntitlement: [ action("cancel", { @@ -1003,14 +1878,39 @@ export const PLATFORM_ACTION_METADATA: Record = { }), ], PeerConnection: [ + action("enable_tailscale", { + target: "collection", + effect: "external", + confirmation: { required: true }, + }), action("invite", { target: "collection", effect: "external", confirmation: { required: true }, + inputSchema: objectSchema( + { + email: { type: "string" }, + remote_bridge_url: { type: "string" }, + }, + ["email"] + ), }), action("accept", { effect: "external", confirmation: { required: true }, + sensitiveInputPaths: ["federation_token"], + inputSchema: objectSchema( + { + remote_bridge_url: { type: "string" }, + federation_token: { type: "string" }, + remote_user_id: { type: "string" }, + remote_display_name: { type: "string" }, + remote_email: { type: "string" }, + tailscale_node_id: { type: "string" }, + tailscale_dns_name: { type: "string" }, + }, + ["remote_bridge_url", "federation_token"] + ), }), action("refresh_health", { effect: "external", @@ -1042,9 +1942,50 @@ export const PLATFORM_ACTION_METADATA: Record = { cancellable: true, confirmation: { required: true }, idempotency: { required: true }, + inputSchema: objectSchema({ + endpoint_id: { type: "string" }, + messages: { type: "array" }, + sampling: { type: "object" }, + priority: { type: "number" }, + }), }), ], FinanceConnection: [ + action("configure_moralis", { + target: "collection", + effect: "external", + execution: "async", + confirmation: { required: true }, + sensitiveInputPaths: ["api_key"], + inputSchema: objectSchema({ api_key: { type: "string" } }, ["api_key"]), + }), + action("configure_paypal", { + target: "collection", + effect: "external", + execution: "async", + confirmation: { required: true }, + sensitiveInputPaths: ["client_secret"], + inputSchema: objectSchema( + { + client_id: { type: "string" }, + client_secret: { type: "string" }, + env: { enum: ["sandbox", "live"] }, + }, + ["client_id", "client_secret"] + ), + }), + action("preview_crypto", { + target: "collection", + effect: "external", + execution: "async", + inputSchema: objectSchema( + { + address: { type: "string" }, + chains: { type: "array", items: { type: "string" } }, + }, + ["address"] + ), + }), action("add_manual", { target: "collection", inputSchema: objectSchema( @@ -1069,6 +2010,21 @@ export const PLATFORM_ACTION_METADATA: Record = { execution: "async", cancellable: false, confirmation: { required: true }, + sensitiveInputPaths: ["api_key", "client_secret"], + inputSchema: objectSchema( + { + provider: { enum: ["paypal", "moralis", "crypto"] }, + address: { type: "string" }, + wallet_provider: { type: "string" }, + label: { type: "string" }, + chains: { type: "array", items: { type: "string" } }, + api_key: { type: "string" }, + client_id: { type: "string" }, + client_secret: { type: "string" }, + env: { enum: ["sandbox", "live"] }, + }, + ["provider"] + ), }), action("refresh_external", { effect: "external", @@ -1076,4 +2032,35 @@ export const PLATFORM_ACTION_METADATA: Record = { cancellable: true, }), ], + PlatformGroupMember: [ + action("add", { + target: "collection", + roles: ["owner"], + confirmation: { required: true }, + inputSchema: objectSchema( + { + group_id: { type: "string" }, + member_kind: { enum: ["user", "agent"] }, + member_id: { type: "string" }, + tenant_id: { type: "string" }, + }, + ["group_id", "member_kind", "member_id"] + ), + }), + action("remove", { + target: "collection", + roles: ["owner"], + effect: "destructive", + confirmation: { required: true }, + inputSchema: objectSchema( + { + group_id: { type: "string" }, + member_kind: { enum: ["user", "agent"] }, + member_id: { type: "string" }, + tenant_id: { type: "string" }, + }, + ["group_id", "member_kind", "member_id"] + ), + }), + ], }; diff --git a/apps/bridge/src/kernel/adapters/platform-config.ts b/apps/bridge/src/kernel/adapters/platform-config.ts new file mode 100644 index 0000000..0acda08 --- /dev/null +++ b/apps/bridge/src/kernel/adapters/platform-config.ts @@ -0,0 +1,240 @@ +import type { + ActionDef, + ObjectTypeDef, + RecordData, + RecordRow, +} from "@godmode/kernel"; +import { + getPlatformBillingConfig, + setPlatformBillingKeys, + testStripeConnection, + type PlatformBillingConfig, +} from "../../services/platform-billing.js"; +import { + markLlmReady, + markOnboardingComplete, +} from "../../services/onboarding.js"; +import { isCursorSubscriptionReady } from "../../services/cursor-subscription.js"; +import type { AppDatabase } from "../../db.js"; +import type { + OperationContext, + RecordAdapter, +} from "../adapter-registry.js"; + +export interface PlatformConfigAdapterServices { + getBillingConfig(): PlatformBillingConfig; + setBillingConfig(input: { + secretKey?: string; + publishableKey?: string; + creditsPerUsd?: number; + }): PlatformBillingConfig; + testBillingConnection(): Promise<{ ok: boolean; detail?: string }>; + getOnboardingStatus?(tenantDb: AppDatabase): { + completed: boolean; + llmReady: boolean; + cursorConnected?: boolean; + llmStatus?: unknown; + }; +} + +const defaultServices: PlatformConfigAdapterServices = { + getBillingConfig: getPlatformBillingConfig, + setBillingConfig: setPlatformBillingKeys, + testBillingConnection: testStripeConnection, +}; + +let services = defaultServices; + +/** + * Parent startup wiring can provide the live LLM-aware onboarding status + * closure without constructing another manager inside the kernel. + */ +export function configurePlatformConfigAdapterServices( + next: Partial +): void { + services = { ...defaultServices, ...next }; +} + +export function resetPlatformConfigAdapterServices(): void { + services = defaultServices; +} + +function httpError(status: number, message: string): Error { + return Object.assign(new Error(message), { status }); +} + +function requireUser(ctx: OperationContext): string { + if (!ctx.userId) throw httpError(401, "Authenticated user required"); + return ctx.userId; +} + +function requireAdmin(ctx: OperationContext): void { + requireUser(ctx); + if (!ctx.isAdmin && ctx.source !== "system") { + throw httpError(403, "Platform administrator required"); + } +} + +function record(def: ObjectTypeDef, id: string, data: RecordData): RecordRow { + return { id, objectType: def.name, data: { id, ...data } }; +} + +function billingRecord(def: ObjectTypeDef): RecordRow { + const config = services.getBillingConfig(); + return record(def, "platform-billing", { + configured: config.configured, + publishable_key: config.publishableKey, + credits_per_usd: config.creditsPerUsd, + has_secret_key: config.hasSecretKey, + }); +} + +export const platformBillingConfigAdapter: RecordAdapter = { + id: "platform_billing_config_service", + list(_core, def, _query, ctx) { + requireAdmin(ctx); + return { + objectType: def.name, + records: [billingRecord(def)], + total: 1, + }; + }, + get(_core, def, id, ctx) { + requireAdmin(ctx); + return id === "platform-billing" ? billingRecord(def) : null; + }, + actions: { + configure(_core, def, _id, input, ctx) { + requireAdmin(ctx); + services.setBillingConfig({ + secretKey: + typeof input.secret_key === "string" ? input.secret_key : undefined, + publishableKey: + typeof input.publishable_key === "string" + ? input.publishable_key + : undefined, + creditsPerUsd: + typeof input.credits_per_usd === "number" + ? input.credits_per_usd + : undefined, + }); + return billingRecord(def); + }, + async test_connection(_core, _def, _id, _input, ctx) { + requireAdmin(ctx); + return services.testBillingConnection(); + }, + }, +}; + +function readFlag(db: AppDatabase, key: string): boolean { + const row = db + .prepare(`SELECT value FROM ai_settings WHERE key=?`) + .get(key) as { value: string } | undefined; + return row?.value === "true"; +} + +function onboardingRecord( + db: AppDatabase, + def: ObjectTypeDef, + ctx: OperationContext +): RecordRow { + const tenantId = ctx.tenantId; + if (!tenantId) throw httpError(403, "Tenant context required"); + const configured = services.getOnboardingStatus?.(db); + const cursorConnected = configured?.cursorConnected ?? isCursorSubscriptionReady(db); + return record(def, tenantId, { + tenant_id: tenantId, + completed: + configured?.completed ?? readFlag(db, "onboarding.completed"), + llm_ready: + configured?.llmReady ?? + (readFlag(db, "onboarding.llm_ready") || cursorConnected), + cursor_connected: cursorConnected, + llm_status: configured?.llmStatus ?? null, + }); +} + +export const tenantOnboardingConfigAdapter: RecordAdapter = { + id: "tenant_onboarding_config_service", + list(db, def, _query, ctx) { + requireUser(ctx); + return { + objectType: def.name, + records: [onboardingRecord(db, def, ctx)], + total: 1, + }; + }, + get(db, def, id, ctx) { + requireUser(ctx); + return id === ctx.tenantId ? onboardingRecord(db, def, ctx) : null; + }, + actions: { + complete(db, def, _id, _input, ctx) { + requireUser(ctx); + markOnboardingComplete(db); + return onboardingRecord(db, def, ctx); + }, + mark_llm_ready(db, def, _id, _input, ctx) { + requireUser(ctx); + markLlmReady(db); + return onboardingRecord(db, def, ctx); + }, + }, +}; + +const schema = ( + properties: Record, + required: string[] = [] +): Record => ({ + type: "object", + additionalProperties: false, + properties, + ...(required.length ? { required } : {}), +}); + +const action = ( + name: string, + options: Partial = {} +): ActionDef => ({ + name, + label: name + .split("_") + .map((part) => part[0]!.toUpperCase() + part.slice(1)) + .join(" "), + target: "record", + effect: "write", + execution: "sync", + roles: ["owner", "intelligence"], + inputSchema: schema({}), + ...options, +}); + +export const PLATFORM_CONFIG_ACTIONS: Record = { + PlatformBillingConfig: [ + action("configure", { + confirmation: { required: true }, + sensitiveInputPaths: ["secret_key"], + inputSchema: schema({ + secret_key: { type: "string" }, + publishable_key: { type: "string" }, + credits_per_usd: { type: "number", minimum: 1 }, + }), + }), + action("test_connection", { + effect: "external", + execution: "async", + cancellable: false, + confirmation: { required: true }, + }), + ], + TenantOnboardingConfig: [ + action("complete"), + action("mark_llm_ready"), + ], +}; + +export const platformConfigAdapters = [ + platformBillingConfigAdapter, + tenantOnboardingConfigAdapter, +] as const; diff --git a/apps/bridge/src/kernel/adapters/productivity.ts b/apps/bridge/src/kernel/adapters/productivity.ts index 048122a..1d74e87 100644 --- a/apps/bridge/src/kernel/adapters/productivity.ts +++ b/apps/bridge/src/kernel/adapters/productivity.ts @@ -6,11 +6,16 @@ import type { RecordRow, } from "@godmode/kernel"; import type { AppDatabase } from "../../db.js"; +import type { ShareGrantRole } from "../../core-db.js"; import { advanceSubtaskOnResultComment, reconcileParentProgress, } from "../../services/card-progress.js"; import { ensureUserProject } from "../../services/user-productivity.js"; +import { + assertShareRole, + resolveShareAccess, +} from "../../services/share-service.js"; import { broadcastCardActivity } from "../../ws-broker.js"; import type { OperationContext, @@ -69,6 +74,54 @@ function requiredText(data: RecordData, name: string): string { return value.trim(); } +interface ProductivityAccess { + db: AppDatabase; + ownerUserId: string; + ownerTenantId: string; + role: ShareGrantRole; +} + +function sharedAccesses( + ctx: OperationContext, + resourceKind: "user_calendar" | "user_tasks" +): ProductivityAccess[] { + if (!ctx.data?.coreDb || !ctx.userId || !ctx.tenantId) return []; + const resources = ctx.data.coreDb + .prepare( + `SELECT DISTINCT resource_id + FROM share_grants + WHERE resource_kind=? + AND (grantee_user_id=? OR grantee_tenant_id=?)` + ) + .all(resourceKind, ctx.userId, ctx.tenantId) as Array<{ + resource_id: string; + }>; + const accesses: ProductivityAccess[] = []; + for (const { resource_id: ownerUserId } of resources) { + const access = resolveShareAccess(ctx.data.coreDb, { + userId: ctx.userId, + tenantId: ctx.tenantId, + resourceKind, + resourceId: ownerUserId, + minRole: "viewer", + }); + if (access) { + accesses.push({ + ...access, + ownerUserId, + }); + } + } + return accesses; +} + +function routedContext( + ctx: OperationContext, + access: ProductivityAccess +): OperationContext { + return { ...ctx, tenantId: access.ownerTenantId }; +} + const WRITE_ACTION_ROLES = ["editor", "owner", "intelligence"] as const; export const CALENDAR_EVENT_ACTIONS: ActionDef[] = [ @@ -222,31 +275,62 @@ function calendarRow( .get(id, userId) as Record | undefined; } +function calendarAccess( + db: AppDatabase, + id: string, + ctx: OperationContext, + write = false +): { access: ProductivityAccess; row: Record } | null { + const userId = requireUser(ctx); + const local = calendarRow(db, id, userId); + if (local) { + return { + access: { + db, + ownerUserId: userId, + ownerTenantId: ctx.tenantId ?? "", + role: "owner", + }, + row: local, + }; + } + for (const access of sharedAccesses(ctx, "user_calendar")) { + const row = calendarRow(access.db, id, access.ownerUserId); + if (!row) continue; + if (write) assertShareRole(access.role, "editor"); + return { access, row }; + } + return null; +} + export const calendarEventServiceAdapter: RecordAdapter = { id: "calendar_event_service", list(db, def, query, ctx) { const userId = requireUser(ctx); const { limit, offset } = paging(query); - const rows = db + const localRows = db .prepare( `SELECT * FROM ai_calendar_events WHERE user_id=? - ORDER BY start_at ASC LIMIT ? OFFSET ?` + ORDER BY start_at ASC` ) - .all(userId, limit, offset) as Record[]; - const total = ( - db - .prepare(`SELECT COUNT(*) AS c FROM ai_calendar_events WHERE user_id=?`) - .get(userId) as { c: number } - ).c; + .all(userId) as Record[]; + const rows = [ + ...localRows, + ...sharedAccesses(ctx, "user_calendar").flatMap((access) => + access.db + .prepare(`SELECT * FROM ai_calendar_events WHERE user_id=?`) + .all(access.ownerUserId) as Record[] + ), + ].sort((a, b) => String(a.start_at).localeCompare(String(b.start_at))); return { objectType: def.name, - records: rows.map((row) => record(def, row)), - total, + records: rows.slice(offset, offset + limit).map((row) => record(def, row)), + total: rows.length, }; }, get(db, def, id, ctx) { - const row = calendarRow(db, id, requireUser(ctx)); - return row ? record(def, row) : null; + const resolved = calendarAccess(db, id, ctx); + return resolved ? record(def, resolved.row) : null; }, create(db, def, data, ctx) { const userId = requireUser(ctx); @@ -273,8 +357,9 @@ export const calendarEventServiceAdapter: RecordAdapter = { return record(def, calendarRow(db, id, userId)!); }, update(db, def, id, data, ctx) { - const userId = requireUser(ctx); - if (!calendarRow(db, id, userId)) notFound("CalendarEvent"); + const resolved = calendarAccess(db, id, ctx, true); + if (!resolved) notFound("CalendarEvent"); + const { access } = resolved; const sets: string[] = []; const values: unknown[] = []; for (const [name, value] of Object.entries(data)) { @@ -284,32 +369,38 @@ export const calendarEventServiceAdapter: RecordAdapter = { } if (sets.length) { sets.push(`updated_at=datetime('now')`); - db.prepare( + access.db.prepare( `UPDATE ai_calendar_events SET ${sets.join(", ")} WHERE id=? AND user_id=?` - ).run(...values, id, userId); + ).run(...values, id, access.ownerUserId); } - return record(def, calendarRow(db, id, userId)!); + return record( + def, + calendarRow(access.db, id, access.ownerUserId)! + ); }, delete(db, _def, id, ctx) { - const result = db + const resolved = calendarAccess(db, id, ctx, true); + if (!resolved) notFound("CalendarEvent"); + const result = resolved.access.db .prepare(`DELETE FROM ai_calendar_events WHERE id=? AND user_id=?`) - .run(id, requireUser(ctx)); + .run(id, resolved.access.ownerUserId); if (!result.changes) notFound("CalendarEvent"); }, actions: { transition(db, def, id, input, ctx) { - const userId = requireUser(ctx); - if (!calendarRow(db, id, userId)) notFound("CalendarEvent"); + const resolved = calendarAccess(db, id, ctx, true); + if (!resolved) notFound("CalendarEvent"); + const { access } = resolved; const status = requiredText(input, "status"); if (!["scheduled", "completed", "cancelled"].includes(status)) { badRequest("invalid calendar status"); } - db.prepare( + access.db.prepare( `UPDATE ai_calendar_events SET status=?, updated_at=datetime('now') WHERE id=? AND user_id=?` - ).run(status, id, userId); - return record(def, calendarRow(db, id, userId)!); + ).run(status, id, access.ownerUserId); + return record(def, calendarRow(access.db, id, access.ownerUserId)!); }, }, }; @@ -339,6 +430,47 @@ function cardRow( .get(id, projectId) as Record | undefined; } +function existingUserProject(db: AppDatabase, userId: string): string | null { + const row = db + .prepare( + `SELECT id FROM ai_projects WHERE user_id=? ORDER BY created_at ASC LIMIT 1` + ) + .get(userId) as { id: string } | undefined; + return row?.id ?? null; +} + +function taskAccess( + db: AppDatabase, + id: string, + ctx: OperationContext, + write = false +): { access: ProductivityAccess; projectId: string; row: Record } | null { + const userId = requireUser(ctx); + const localProjectId = ensureUserProject(userId, db); + const local = cardRow(db, id, localProjectId); + if (local) { + return { + access: { + db, + ownerUserId: userId, + ownerTenantId: ctx.tenantId ?? "", + role: "owner", + }, + projectId: localProjectId, + row: local, + }; + } + for (const access of sharedAccesses(ctx, "user_tasks")) { + const projectId = existingUserProject(access.db, access.ownerUserId); + if (!projectId) continue; + const row = cardRow(access.db, id, projectId); + if (!row) continue; + if (write) assertShareRole(access.role, "editor"); + return { access, projectId, row }; + } + return null; +} + function canonicalColumnExists(db: AppDatabase, columnId: string): boolean { return Boolean( db.prepare(`SELECT id FROM ai_project_columns WHERE id=?`).get(columnId) @@ -384,9 +516,10 @@ function appendScopedComment( input: RecordData, ctx: OperationContext ): RecordRow { - const projectId = ensureUserProject(requireUser(ctx), db); - const card = cardRow(db, cardId, projectId); - if (!card) notFound("TaskCard"); + const resolved = taskAccess(db, cardId, ctx, true); + if (!resolved) notFound("TaskCard"); + const { access, projectId, row: card } = resolved; + const targetCtx = routedContext(ctx, access); const body = requiredText(input, "body"); const kind = input.kind === undefined ? null : requiredText(input, "kind"); @@ -395,18 +528,27 @@ function appendScopedComment( } const author = ctx.source === "agent" || ctx.agentId ? "agent" : "user"; const commentId = uuidv4(); - db.prepare( + access.db.prepare( `INSERT INTO ai_card_comments (id, card_id, author, body, kind) VALUES (?, ?, ?, ?, ?)` ).run(commentId, cardId, author, body, kind); if (author === "agent" && kind === "result") { - advanceSubtaskOnResultComment(db, cardId, ctx.tenantId); + advanceSubtaskOnResultComment(access.db, cardId, targetCtx.tenantId); } else if (author === "agent" && card.parent_card_id) { - reconcileParentProgress(db, String(card.parent_card_id), ctx.tenantId); + reconcileParentProgress( + access.db, + String(card.parent_card_id), + targetCtx.tenantId + ); } - notifyCardMutation(db, cardRow(db, cardId, projectId)!, ctx, "comment"); - const row = db + notifyCardMutation( + access.db, + cardRow(access.db, cardId, projectId)!, + targetCtx, + "comment" + ); + const row = access.db .prepare(`SELECT * FROM ai_card_comments WHERE id=? AND card_id=?`) .get(commentId, cardId) as Record; return record(def, row); @@ -415,28 +557,36 @@ function appendScopedComment( export const taskCardServiceAdapter: RecordAdapter = { id: "task_card_service", list(db, def, query, ctx) { - const projectId = ensureUserProject(requireUser(ctx), db); + const userId = requireUser(ctx); + const projectId = ensureUserProject(userId, db); const { limit, offset } = paging(query); - const rows = db + const localRows = db .prepare( `SELECT * FROM ai_project_cards WHERE project_id=? - ORDER BY sort_order ASC LIMIT ? OFFSET ?` + ORDER BY sort_order ASC` ) - .all(projectId, limit, offset) as Record[]; - const total = ( - db - .prepare(`SELECT COUNT(*) AS c FROM ai_project_cards WHERE project_id=?`) - .get(projectId) as { c: number } - ).c; + .all(projectId) as Record[]; + const rows = [...localRows]; + for (const access of sharedAccesses(ctx, "user_tasks")) { + const sharedProjectId = existingUserProject(access.db, access.ownerUserId); + if (!sharedProjectId) continue; + rows.push( + ...(access.db + .prepare( + `SELECT * FROM ai_project_cards WHERE project_id=? ORDER BY sort_order ASC` + ) + .all(sharedProjectId) as Record[]) + ); + } return { objectType: def.name, - records: rows.map((row) => record(def, row)), - total, + records: rows.slice(offset, offset + limit).map((row) => record(def, row)), + total: rows.length, }; }, get(db, def, id, ctx) { - const row = cardRow(db, id, ensureUserProject(requireUser(ctx), db)); - return row ? record(def, row) : null; + const resolved = taskAccess(db, id, ctx); + return resolved ? record(def, resolved.row) : null; }, create(db, def, data, ctx) { const projectId = ensureUserProject(requireUser(ctx), db); @@ -478,8 +628,9 @@ export const taskCardServiceAdapter: RecordAdapter = { return record(def, cardRow(db, id, projectId)!); }, update(db, def, id, data, ctx) { - const projectId = ensureUserProject(requireUser(ctx), db); - if (!cardRow(db, id, projectId)) notFound("TaskCard"); + const resolved = taskAccess(db, id, ctx, true); + if (!resolved) notFound("TaskCard"); + const { access, projectId } = resolved; const sets: string[] = []; const values: unknown[] = []; for (const [name, raw] of Object.entries(data)) { @@ -495,33 +646,35 @@ export const taskCardServiceAdapter: RecordAdapter = { } if (sets.length) { sets.push(`updated_at=datetime('now')`); - db.prepare( + access.db.prepare( `UPDATE ai_project_cards SET ${sets.join(", ")} WHERE id=? AND project_id=?` ).run(...values, id, projectId); } - return record(def, cardRow(db, id, projectId)!); + return record(def, cardRow(access.db, id, projectId)!); }, delete(db, _def, id, ctx) { - const projectId = ensureUserProject(requireUser(ctx), db); - const result = db + const resolved = taskAccess(db, id, ctx, true); + if (!resolved) notFound("TaskCard"); + const result = resolved.access.db .prepare(`DELETE FROM ai_project_cards WHERE id=? AND project_id=?`) - .run(id, projectId); + .run(id, resolved.projectId); if (!result.changes) notFound("TaskCard"); }, actions: { move(db, def, id, input, ctx) { - const projectId = ensureUserProject(requireUser(ctx), db); - const current = cardRow(db, id, projectId); - if (!current) notFound("TaskCard"); + const resolved = taskAccess(db, id, ctx, true); + if (!resolved) notFound("TaskCard"); + const { access, projectId, row: current } = resolved; + const targetCtx = routedContext(ctx, access); const columnId = requiredText(input, "column_id"); - if (!canonicalColumnExists(db, columnId)) { + if (!canonicalColumnExists(access.db, columnId)) { badRequest("unknown project column"); } let sortOrder: number; if (input.sort_order === undefined) { sortOrder = ( - db + access.db .prepare( `SELECT COALESCE(MAX(sort_order), -1) AS value FROM ai_project_cards WHERE project_id=? AND column_id=?` @@ -538,39 +691,50 @@ export const taskCardServiceAdapter: RecordAdapter = { columnId === "done" && !["accepted", "done", "cancelled"].includes(String(current.status ?? "")) ? "done" : current.status; - db.prepare( + access.db.prepare( `UPDATE ai_project_cards SET column_id=?, sort_order=?, status=?, updated_at=datetime('now') WHERE id=? AND project_id=?` ).run(columnId, sortOrder, impliedStatus ?? null, id, projectId); - const updated = cardRow(db, id, projectId)!; + const updated = cardRow(access.db, id, projectId)!; if (updated.parent_card_id) { - reconcileParentProgress(db, String(updated.parent_card_id), ctx.tenantId); + reconcileParentProgress( + access.db, + String(updated.parent_card_id), + targetCtx.tenantId + ); } - notifyCardMutation(db, updated, ctx, "card_updated"); + notifyCardMutation(access.db, updated, targetCtx, "card_updated"); return record(def, updated); }, assign(db, def, id, input, ctx) { - const projectId = ensureUserProject(requireUser(ctx), db); - if (!cardRow(db, id, projectId)) notFound("TaskCard"); + const resolved = taskAccess(db, id, ctx, true); + if (!resolved) notFound("TaskCard"); + const { access, projectId } = resolved; const raw = input.assigned_agent_id; if (raw !== null && (typeof raw !== "string" || !raw.trim())) { badRequest("assigned_agent_id must be non-empty text or null"); } const assignedAgentId = raw === null ? null : raw.trim(); - db.prepare( + access.db.prepare( `UPDATE ai_project_cards SET assigned_agent_id=?, updated_at=datetime('now') WHERE id=? AND project_id=?` ).run(assignedAgentId, id, projectId); - const updated = cardRow(db, id, projectId)!; - notifyCardMutation(db, updated, ctx, "card_updated"); + const updated = cardRow(access.db, id, projectId)!; + notifyCardMutation( + access.db, + updated, + routedContext(ctx, access), + "card_updated" + ); return record(def, updated); }, transition(db, def, id, input, ctx) { - const projectId = ensureUserProject(requireUser(ctx), db); - const current = cardRow(db, id, projectId); - if (!current) notFound("TaskCard"); + const resolved = taskAccess(db, id, ctx, true); + if (!resolved) notFound("TaskCard"); + const { access, projectId, row: current } = resolved; + const targetCtx = routedContext(ctx, access); const status = requiredText(input, "status"); const columnByStatus: Record = { pending: "backlog", @@ -588,16 +752,20 @@ export const taskCardServiceAdapter: RecordAdapter = { badRequest("invalid card status"); } const columnId = columnByStatus[status] ?? String(current.column_id); - db.prepare( + access.db.prepare( `UPDATE ai_project_cards SET status=?, column_id=?, updated_at=datetime('now') WHERE id=? AND project_id=?` ).run(status, columnId, id, projectId); - const updated = cardRow(db, id, projectId)!; + const updated = cardRow(access.db, id, projectId)!; if (updated.parent_card_id) { - reconcileParentProgress(db, String(updated.parent_card_id), ctx.tenantId); + reconcileParentProgress( + access.db, + String(updated.parent_card_id), + targetCtx.tenantId + ); } - notifyCardMutation(db, updated, ctx, "card_updated"); + notifyCardMutation(access.db, updated, targetCtx, "card_updated"); return record(def, updated); }, add_comment(db, _def, id, input, ctx) { @@ -659,20 +827,6 @@ export const cardCommentServiceAdapter: RecordAdapter = { .get(id, projectId) as Record | undefined; return row ? record(def, row) : null; }, - create(db, def, data, ctx) { - const projectId = ensureUserProject(requireUser(ctx), db); - const cardId = String(data.card_id ?? ""); - if (!cardRow(db, cardId, projectId)) notFound("TaskCard"); - const id = typeof data.id === "string" && data.id ? data.id : uuidv4(); - db.prepare( - `INSERT INTO ai_card_comments (id, card_id, author, body) - VALUES (?, ?, ?, ?)` - ).run(id, cardId, data.author ?? "user", data.body); - const row = db - .prepare(`SELECT * FROM ai_card_comments WHERE id=?`) - .get(id) as Record; - return record(def, row); - }, actions: { add_comment(db, def, _id, input, ctx) { return appendScopedComment( diff --git a/apps/bridge/src/kernel/adapters/runtime.ts b/apps/bridge/src/kernel/adapters/runtime.ts index e751e44..5b82b9d 100644 --- a/apps/bridge/src/kernel/adapters/runtime.ts +++ b/apps/bridge/src/kernel/adapters/runtime.ts @@ -4,6 +4,7 @@ import type { RecordData, RecordRow, } from "@godmode/kernel"; +import { v4 as uuidv4 } from "uuid"; import type { AppDatabase } from "../../db.js"; import { getCoreDb } from "../../core-db.js"; import { @@ -11,8 +12,13 @@ import { type AgentMessage, type AgentSampling, } from "../../services/ai-agent.js"; -import { AiDatasetBuilder, type DatasetSource } from "../../services/ai-dataset-builder.js"; +import { + AiDatasetBuilder, + type DatasetExample, + type DatasetSource, +} from "../../services/ai-dataset-builder.js"; import type { AiQueueWorker, EnqueueInput } from "../../services/ai-queue-worker.js"; +import { AUTONOMOUS_RUNNER_ID } from "../../services/ai-queue-worker.js"; import type { AiTrainingManager, TrainingJobConfig, @@ -23,6 +29,45 @@ import { } from "../../services/model-catalog.js"; import { runRemoteInference } from "../../services/inference-service.js"; import type { LlmManager } from "../../services/llm-manager.js"; +import { + createAdapter, + deleteAdapter, + getAdapter, + listAdapters, + updateAdapter, + type AiAdapter, +} from "../../services/ai-adapters.js"; +import { + createSecret, + deleteSecret, + listSecrets, +} from "../../services/agents/agents-db.js"; +import { + createAgentApiKeyAccount, + getAgentAccount, + listAgentAccounts, + revokeAgentAccount, + type AgentAccount, +} from "../../services/agents/agent-accounts.js"; +import { + countCapabilityIndex, + rebuildAllAgentCapabilityIndexes, +} from "../../services/capability-index.js"; +import type { EmbeddingManager } from "../../services/embeddings/embedding-manager.js"; +import type { MemoryMaintenanceService } from "../../services/memory-maintenance.js"; +import { + assemblePrompt, + loadPromptFlowConfig, + savePromptFlowConfig, + type PromptFlowConfig, +} from "../../services/prompt-assembler.js"; +import { getAgent } from "../../services/agents/agents-db.js"; +import { + CURSOR_API_KEY_SECRET_ID, + getCursorAuthStatus, + removeCursorApiKey, + upsertCursorApiKey, +} from "../../services/cursor-subscription.js"; import type { OperationContext, RecordAdapter, @@ -43,33 +88,60 @@ export interface RuntimeAdapterServices { | "isReady" | "getServerBaseUrl" | "getEnabledAdapterPaths" + | "getSettings" + | "updateSettings" >; - queue: Pick; + queue: Pick; training: Pick; - /** - * Must be the same policy-enforcing chat command used by the HTTP route. - * The kernel deliberately does not duplicate chat streaming/tool policy. - */ - sendMessage(input: { - db: AppDatabase; - chatId: string; - content: string; - agentId?: string; - context: OperationContext; - signal?: AbortSignal; - }): Promise; + embeddings: Pick< + EmbeddingManager, + | "getStatus" + | "start" + | "stop" + | "setEnabled" + | "getEmbeddingClient" + >; + memoryMaintenance: Pick< + MemoryMaintenanceService, + "enqueueDistill" | "enqueueWikiSynthesize" + >; /** Must enqueue through the configured provider connector/scheduler. */ syncIntegration(input: { db: AppDatabase; kind: IntegrationKind; context: OperationContext; }): Promise; + /** + * Promote an owned chat through the same share/access service used by the + * protocol route. + */ + shareChat(input: { + db: AppDatabase; + chatId: string; + agentId: string; + context: OperationContext; + }): Promise | unknown; } let services: RuntimeAdapterServices | undefined; +export const REQUIRED_RUNTIME_ADAPTER_SERVICE_KEYS = [ + "llm", + "queue", + "training", + "embeddings", + "memoryMaintenance", + "syncIntegration", + "shareChat", +] as const satisfies ReadonlyArray; + /** Wire the already-running Bridge singletons; do not construct parallel managers. */ export function configureRuntimeAdapterServices(next: RuntimeAdapterServices): void { + for (const key of REQUIRED_RUNTIME_ADAPTER_SERVICE_KEYS) { + if (!next[key]) { + throw new Error(`Runtime adapter service "${key}" is required`); + } + } services = next; } @@ -78,13 +150,47 @@ export function clearRuntimeAdapterServices(): void { services = undefined; } -function runtime(): RuntimeAdapterServices { +export function assertRuntimeAdapterServicesConfigured(): RuntimeAdapterServices { if (!services) { - throw httpError(503, "Runtime ObjectType services are not configured"); + throw new Error("Runtime ObjectType services are not configured"); + } + for (const key of REQUIRED_RUNTIME_ADAPTER_SERVICE_KEYS) { + if (!services[key]) { + throw new Error(`Runtime adapter service "${key}" is required`); + } } return services; } +function runtime(): RuntimeAdapterServices { + return assertRuntimeAdapterServicesConfigured(); +} + +/** Reuse the boot-injected LLM singleton for non-runtime domain adapters. */ +export function runConfiguredRemoteInference(input: { + core: AppDatabase; + endpointId: string; + buyerUserId: string; + buyerTenantId: string; + messages: AgentMessage[]; + sampling?: Partial; + priority?: number; + signal?: AbortSignal; +}): Promise { + const active = runtime(); + return runRemoteInference(input.core, active.llm as LlmManager, { + endpointId: input.endpointId, + buyerUserId: input.buyerUserId, + buyerTenantId: input.buyerTenantId, + messages: input.messages, + sampling: { + ...active.llm.getSamplingParams(), + ...(input.sampling ?? {}), + } as AgentSampling, + priority: input.priority, + }); +} + function httpError(status: number, message: string): Error { return Object.assign(new Error(message), { status }); } @@ -155,25 +261,23 @@ function requireChat(db: AppDatabase, id: string, ctx: OperationContext) { export const CHAT_SESSION_ACTIONS: ActionDef[] = [ { - name: "send_message", - label: "Send message", - description: "Run a chat turn through the configured policy-enforcing chat runtime.", + name: "share", + label: "Share session", + description: "Promote an owned chat through the configured sharing service.", target: "record", effect: "external", execution: "async", - roles: ["editor", "owner", "intelligence"], - confirmation: { required: false }, + cancellable: false, + roles: ["editor", "owner"], + confirmation: { required: true, ttlSeconds: 300 }, inputSchema: { type: "object", additionalProperties: false, - required: ["content"], properties: { - content: { type: "string", minLength: 1 }, agent_id: { type: "string", minLength: 1 }, }, }, - timeoutMs: 300_000, - cancellable: true, + idempotency: { required: true, ttlSeconds: 300 }, }, { name: "confirm_tool", @@ -212,6 +316,25 @@ export const CHAT_SESSION_ACTIONS: ActionDef[] = [ }, }, }, + { + name: "distill", + label: "Distill memory", + description: "Enqueue episodic memory distillation for this chat.", + target: "record", + effect: "external", + execution: "sync", + roles: ["owner", "intelligence"], + confirmation: { required: true, ttlSeconds: 300 }, + inputSchema: { + type: "object", + additionalProperties: false, + properties: { + agent_id: { type: "string", minLength: 1 }, + force: { type: "boolean" }, + }, + }, + idempotency: { required: true, ttlSeconds: 300 }, + }, ]; export const chatSessionRuntimeAdapter: RecordAdapter = { @@ -254,17 +377,50 @@ export const chatSessionRuntimeAdapter: RecordAdapter = { }) : null; }, + create(db, def, data, ctx) { + const id = uuidv4(); + const title = + typeof data.title === "string" && data.title.trim() + ? data.title.trim().slice(0, 120) + : "New chat"; + db.prepare( + `INSERT INTO ai_chats (id, title, user_id, created_at, updated_at) + VALUES (?, ?, ?, datetime('now'), datetime('now'))` + ).run(id, title, requiredUser(ctx)); + const row = db + .prepare(`SELECT created_at, updated_at FROM ai_chats WHERE id = ?`) + .get(id) as { created_at: string; updated_at: string }; + return record(def, id, { + title, + created_at: row.created_at, + updated_at: row.updated_at, + }); + }, + delete(db, _def, id, ctx) { + requireChat(db, id, ctx); + db.transaction(() => { + db.prepare(`DELETE FROM ai_messages WHERE chat_id = ?`).run(id); + const result = db + .prepare( + `DELETE FROM ai_chats + WHERE id = ? AND (user_id IS NULL OR user_id = ?)` + ) + .run(id, requiredUser(ctx)); + if (!result.changes) throw httpError(404, "Chat session not found"); + })(); + }, actions: { - async send_message(db, _def, id, input, ctx) { + async share(db, _def, id, input, ctx) { requireChat(db, id, ctx); - return runtime().sendMessage({ + const shareChat = runtime().shareChat; + return shareChat({ db, chatId: id, - content: requiredText(input, "content"), agentId: - typeof input.agent_id === "string" ? input.agent_id.trim() || undefined : undefined, + typeof input.agent_id === "string" && input.agent_id.trim() + ? input.agent_id.trim() + : ctx.agentId ?? "intelligence", context: ctx, - signal: ctx.signal, }); }, confirm_tool(db, _def, id, input, ctx) { @@ -290,6 +446,22 @@ export const chatSessionRuntimeAdapter: RecordAdapter = { .run(id, anchor.created_at).changes; return { ok: true, deleted }; }, + distill(db, _def, id, input, ctx) { + requireChat(db, id, ctx); + const maintenance = runtime().memoryMaintenance; + return { + ok: true, + jobId: maintenance.enqueueDistill({ + chatId: id, + agentId: + typeof input.agent_id === "string" + ? input.agent_id + : ctx.agentId ?? "intelligence", + tenantId: ctx.tenantId, + force: input.force === true, + }), + }; + }, }, }; @@ -348,6 +520,500 @@ export const chatMessageRuntimeAdapter: RecordAdapter = { }) : null; }, + create(db, def, data, ctx) { + const chatId = requiredText(data, "chat_id"); + requireChat(db, chatId, ctx); + const role = requiredText(data, "role"); + if (role !== "user" && role !== "assistant") { + throw httpError(400, "Chat message role must be user or assistant"); + } + const id = uuidv4(); + db.transaction(() => { + db.prepare( + `INSERT INTO ai_messages (id, chat_id, role, content_json, user_id) + VALUES (?, ?, ?, ?, ?)` + ).run( + id, + chatId, + role, + JSON.stringify(data.content ?? {}), + role === "user" ? requiredUser(ctx) : null + ); + db.prepare( + `UPDATE ai_chats SET updated_at=datetime('now') WHERE id=?` + ).run(chatId); + })(); + return chatMessageRuntimeAdapter.get!(db, def, id, ctx)!; + }, + delete(db, _def, id, ctx) { + const result = db + .prepare( + `DELETE FROM ai_messages + WHERE id = ? AND chat_id IN ( + SELECT id FROM ai_chats WHERE user_id IS NULL OR user_id = ? + )` + ) + .run(id, requiredUser(ctx)); + if (!result.changes) throw httpError(404, "Chat message not found"); + }, +}; + +function modelAdapterRecord(def: ObjectTypeDef, row: AiAdapter): RecordRow { + return record(def, row.id, { + name: row.name, + path: row.path, + description: row.description, + domain: row.domain, + enabled: Boolean(row.enabled), + default_scale: row.default_scale, + created_at: row.created_at, + updated_at: row.updated_at, + }); +} + +export const modelAdapterRuntimeAdapter: RecordAdapter = { + id: "model_adapter_runtime", + list(db, def, query) { + const result = page(listAdapters(db), query); + return { + objectType: def.name, + records: result.rows.map((row) => modelAdapterRecord(def, row)), + total: result.total, + }; + }, + get(db, def, id) { + const row = getAdapter(db, id); + return row ? modelAdapterRecord(def, row) : null; + }, + create(db, def, data, ctx) { + ownerOnly(ctx); + return modelAdapterRecord( + def, + createAdapter(db, { + name: requiredText(data, "name"), + path: requiredText(data, "path"), + description: + data.description == null ? null : String(data.description), + domain: data.domain == null ? null : String(data.domain), + enabled: data.enabled === undefined ? undefined : Boolean(data.enabled), + defaultScale: + typeof data.default_scale === "number" ? data.default_scale : undefined, + }) + ); + }, + update(db, def, id, data, ctx) { + ownerOnly(ctx); + const row = updateAdapter(db, id, { + name: typeof data.name === "string" ? data.name : undefined, + description: + data.description === undefined ? undefined : (data.description as string | null), + domain: data.domain === undefined ? undefined : (data.domain as string | null), + enabled: data.enabled === undefined ? undefined : Boolean(data.enabled), + defaultScale: + typeof data.default_scale === "number" ? data.default_scale : undefined, + }); + if (!row) throw httpError(404, "Model adapter not found"); + return modelAdapterRecord(def, row); + }, + delete(db, _def, id, ctx) { + ownerOnly(ctx); + if (!deleteAdapter(db, id)) throw httpError(404, "Model adapter not found"); + }, +}; + +export const EMBEDDING_RUNTIME_ACTIONS: ActionDef[] = [ + ...(["start", "stop"] as const).map( + (name): ActionDef => ({ + name, + label: `${name === "start" ? "Start" : "Stop"} embeddings`, + target: "record", + effect: name === "stop" ? "destructive" : "external", + execution: "async", + cancellable: false, + roles: ["owner"], + confirmation: { required: true, ttlSeconds: 300 }, + inputSchema: { type: "object", additionalProperties: false, properties: {} }, + }) + ), + { + name: "set_enabled", + label: "Set embedding engine enabled", + target: "record", + effect: "external", + execution: "async", + cancellable: false, + roles: ["owner"], + confirmation: { required: true, ttlSeconds: 300 }, + inputSchema: { + type: "object", + additionalProperties: false, + required: ["enabled"], + properties: { enabled: { type: "boolean" } }, + }, + }, +]; + +function embeddingStatus(def: ObjectTypeDef): RecordRow { + const embeddings = runtime().embeddings; + const status = embeddings.getStatus(); + return record(def, "runtime", { + enabled: status.enabled, + enabled_override: status.enabledOverride, + state: status.embedder.state, + health_ok: status.embedder.healthOk, + pid: status.embedder.pid, + port: status.embedder.port, + error: status.embedder.error, + }); +} + +export const embeddingRuntimeAdapter: RecordAdapter = { + id: "embedding_runtime", + list(_db, def, query) { + const result = page([embeddingStatus(def)], query); + return { objectType: def.name, records: result.rows, total: result.total }; + }, + get(_db, def, id) { + return id === "runtime" ? embeddingStatus(def) : null; + }, + actions: { + start() { + const embeddings = runtime().embeddings; + return embeddings.start(); + }, + stop() { + const embeddings = runtime().embeddings; + return embeddings.stop(); + }, + set_enabled(_db, _def, _id, input) { + const embeddings = runtime().embeddings; + return embeddings.setEnabled(input.enabled === true); + }, + }, +}; + +export const CAPABILITY_INDEX_ACTIONS: ActionDef[] = [ + { + name: "rebuild", + label: "Rebuild capability index", + target: "record", + effect: "external", + execution: "async", + cancellable: true, + roles: ["owner", "intelligence"], + confirmation: { required: true, ttlSeconds: 300 }, + inputSchema: { type: "object", additionalProperties: false, properties: {} }, + }, +]; + +function capabilityIndexRecord( + db: AppDatabase, + def: ObjectTypeDef +): RecordRow { + return record(def, "default", { index_rows: countCapabilityIndex(db) }); +} + +export const capabilityIndexRuntimeAdapter: RecordAdapter = { + id: "capability_index_runtime", + list(db, def, query) { + const result = page([capabilityIndexRecord(db, def)], query); + return { objectType: def.name, records: result.rows, total: result.total }; + }, + get(db, def, id) { + return id === "default" ? capabilityIndexRecord(db, def) : null; + }, + actions: { + async rebuild(db, _def, _id, _input, ctx) { + runtimeOperator(ctx); + const embeddings = runtime().embeddings; + const count = await rebuildAllAgentCapabilityIndexes( + db, + embeddings?.getEmbeddingClient() + ); + return { ok: true, count, indexRows: countCapabilityIndex(db) }; + }, + }, +}; + +const SETTINGS_FIELD_MAP = { + active_model_path: "activeModelPath", + ctx_size: "ctxSize", + gpu_layers: "gpuLayers", + flash_attn: "flashAttn", + batch_size: "batchSize", + ubatch_size: "ubatchSize", + extra_args: "extraArgs", + auto_start: "autoStart", + top_p: "topP", + top_k: "topK", + min_p: "minP", + repeat_penalty: "repeatPenalty", + presence_penalty: "presencePenalty", + frequency_penalty: "frequencyPenalty", + max_tokens: "maxTokens", + system_prompt: "systemPrompt", + enable_thinking: "enableThinking", + thinking_efficiency: "thinkingEfficiency", + native_tools: "nativeTools", + memory_mode: "memoryMode", +} as const; + +function projectSettings(settings: Record): RecordData { + const projected = { ...settings } as Record; + for (const [external, internal] of Object.entries(SETTINGS_FIELD_MAP)) { + projected[external] = settings[internal]; + delete projected[internal]; + } + return projected; +} + +function internalSettings(data: RecordData): RecordData { + const projected = { ...data }; + for (const [external, internal] of Object.entries(SETTINGS_FIELD_MAP)) { + if (external in data) projected[internal] = data[external]; + delete projected[external]; + } + return projected; +} + +function settingsRecord( + db: AppDatabase, + def: ObjectTypeDef, + settings = runtime().llm.getSettings(db) +): RecordRow { + return record( + def, + "default", + projectSettings(settings as unknown as Record) + ); +} + +export const intelligenceSettingsRuntimeAdapter: RecordAdapter = { + id: "intelligence_settings_runtime", + list(db, def, query) { + const result = page([settingsRecord(db, def)], query); + return { objectType: def.name, records: result.rows, total: result.total }; + }, + get(db, def, id) { + return id === "default" ? settingsRecord(db, def) : null; + }, + update(db, def, id, data, ctx) { + ownerOnly(ctx); + if (id !== "default") throw httpError(404, "Intelligence settings not found"); + return settingsRecord( + db, + def, + runtime().llm.updateSettings(internalSettings(data), db) + ); + }, +}; + +function promptFlowRecord( + db: AppDatabase, + def: ObjectTypeDef, + agentId: string, + flowConfig = loadPromptFlowConfig(db) +): RecordRow { + const settings = runtime().llm.getSettings(db); + const agent = getAgent(db, agentId); + const assembled = assemblePrompt(db, { + basePrompt: agent?.systemPrompt ?? settings.systemPrompt, + flowConfig, + enableThinking: agent?.thinking.enableThinking ?? settings.enableThinking, + thinkingEfficiency: + agent?.thinking.thinkingEfficiency ?? settings.thinkingEfficiency, + nativeTools: agent?.thinking.nativeTools ?? settings.nativeTools, + agentId, + }); + return record(def, "default", { + agent_id: agentId, + config: flowConfig, + assembled, + }); +} + +export const promptFlowRuntimeAdapter: RecordAdapter = { + id: "prompt_flow_runtime", + list(db, def, query, ctx) { + const result = page( + [promptFlowRecord(db, def, ctx.agentId ?? "intelligence")], + query + ); + return { objectType: def.name, records: result.rows, total: result.total }; + }, + get(db, def, id, ctx) { + return id === "default" + ? promptFlowRecord(db, def, ctx.agentId ?? "intelligence") + : null; + }, + update(db, def, id, data, ctx) { + ownerOnly(ctx); + if (id !== "default") throw httpError(404, "Prompt flow not found"); + const flowConfig = (data.config ?? data) as unknown as PromptFlowConfig; + if (!Array.isArray(flowConfig.sections) || flowConfig.sections.length === 0) { + throw httpError(400, "Invalid prompt flow config"); + } + savePromptFlowConfig(db, flowConfig); + return promptFlowRecord( + db, + def, + typeof data.agent_id === "string" + ? data.agent_id + : ctx.agentId ?? "intelligence", + flowConfig + ); + }, +}; + +function vaultSecretRecord( + def: ObjectTypeDef, + row: ReturnType[number] +): RecordRow { + return record(def, row.id, { + name: row.name, + masked: row.masked, + created_at: row.createdAt, + }); +} + +export const vaultSecretRuntimeAdapter: RecordAdapter = { + id: "vault_secret_runtime", + list(db, def, query, ctx) { + ownerOnly(ctx); + const rows = listSecrets(db).filter( + (secret) => + secret.id !== "cursor-api-key" && + secret.name.toLowerCase() !== "cursor_api_key" && + secret.name.toLowerCase() !== "cursor-api-key" + ); + const result = page(rows, query); + return { + objectType: def.name, + records: result.rows.map((row) => vaultSecretRecord(def, row)), + total: result.total, + }; + }, + get(db, def, id, ctx) { + ownerOnly(ctx); + const row = listSecrets(db).find( + (secret) => + secret.id === id && + secret.id !== "cursor-api-key" && + secret.name.toLowerCase() !== "cursor_api_key" && + secret.name.toLowerCase() !== "cursor-api-key" + ); + return row ? vaultSecretRecord(def, row) : null; + }, + create(db, def, data, ctx) { + ownerOnly(ctx); + const name = requiredText(data, "name"); + if ( + name.toLowerCase() === "cursor_api_key" || + name.toLowerCase() === "cursor-api-key" + ) { + throw httpError(400, "Cursor API keys must use the Cursor credential flow"); + } + const created = createSecret(db, name, requiredText(data, "value")); + const row = listSecrets(db).find((secret) => secret.id === created.id); + if (!row) throw httpError(500, "Created Vault secret could not be loaded"); + return vaultSecretRecord(def, row); + }, + delete(db, _def, id, ctx) { + ownerOnly(ctx); + if (!deleteSecret(db, id)) throw httpError(404, "Vault secret not found"); + }, +}; + +function providerCredentialRecord( + def: ObjectTypeDef, + account: AgentAccount +): RecordRow { + return record(def, account.id, { + agent_id: account.agentId, + kind: account.kind, + provider: account.provider, + display_name: account.displayName, + email: account.email, + scopes: account.scopes, + status: account.status, + masked_token: account.maskedToken, + created_at: account.createdAt, + updated_at: account.updatedAt, + }); +} + +export const providerCredentialRuntimeAdapter: RecordAdapter = { + id: "provider_credential_runtime", + list(db, def, query, ctx) { + ownerOnly(ctx); + const agentId = + typeof query.filters?.agent_id === "string" + ? query.filters.agent_id + : ctx.agentId ?? "intelligence"; + const result = page(listAgentAccounts(db, agentId), query); + return { + objectType: def.name, + records: result.rows.map((row) => providerCredentialRecord(def, row)), + total: result.total, + }; + }, + get(db, def, id, ctx) { + ownerOnly(ctx); + if (id === CURSOR_API_KEY_SECRET_ID) { + const status = getCursorAuthStatus(db); + return status.connected + ? record(def, CURSOR_API_KEY_SECRET_ID, { + agent_id: "intelligence", + kind: "api_key", + provider: "cursor", + display_name: "Cursor subscription", + status: "active", + masked_token: status.masked ?? "****", + }) + : null; + } + const account = getAgentAccount(db, id); + return account ? providerCredentialRecord(def, account) : null; + }, + create(db, def, data, ctx) { + ownerOnly(ctx); + if (requiredText(data, "provider").toLowerCase() === "cursor") { + upsertCursorApiKey(db, requiredText(data, "api_key")); + const status = getCursorAuthStatus(db); + return record(def, CURSOR_API_KEY_SECRET_ID, { + agent_id: "intelligence", + kind: "api_key", + provider: "cursor", + display_name: "Cursor subscription", + status: "active", + masked_token: status.masked ?? "****", + }); + } + return providerCredentialRecord( + def, + createAgentApiKeyAccount(db, { + agentId: + typeof data.agent_id === "string" + ? data.agent_id + : ctx.agentId ?? "intelligence", + provider: requiredText(data, "provider"), + label: typeof data.label === "string" ? data.label : undefined, + apiKey: requiredText(data, "api_key"), + }) + ); + }, + delete(db, _def, id, ctx) { + ownerOnly(ctx); + if (id === CURSOR_API_KEY_SECRET_ID) { + removeCursorApiKey(db); + return; + } + const account = getAgentAccount(db, id); + if (!account) throw httpError(404, "Provider credential not found"); + if (!revokeAgentAccount(db, id, account.agentId)) { + throw httpError(404, "Provider credential not found"); + } + }, }; function safeModelStatus(status: ReturnType): RecordData { @@ -610,6 +1276,50 @@ export const DATASET_ACTIONS: ActionDef[] = [ }, idempotency: { required: true, ttlSeconds: 86_400 }, }, + { + name: "import_dataset", + label: "Import dataset", + description: "Validate examples and import them into managed JSONL storage.", + target: "collection", + effect: "write", + execution: "sync", + roles: ["owner"], + confirmation: { required: true, ttlSeconds: 300 }, + inputSchema: { + type: "object", + additionalProperties: false, + required: ["name", "examples"], + properties: { + name: { type: "string", minLength: 1, maxLength: 120 }, + domain: { type: "string", maxLength: 120 }, + examples: { + type: "array", + minItems: 1, + items: { + type: "object", + required: ["messages"], + properties: { + messages: { + type: "array", + minItems: 1, + items: { + type: "object", + required: ["role", "content"], + properties: { + role: { type: "string", minLength: 1 }, + content: { type: "string", minLength: 1 }, + }, + additionalProperties: false, + }, + }, + }, + additionalProperties: false, + }, + }, + }, + }, + idempotency: { required: true, ttlSeconds: 86_400 }, + }, ]; function datasetRecord(def: ObjectTypeDef, row: Record): RecordRow { @@ -669,6 +1379,96 @@ export const datasetRuntimeAdapter: RecordAdapter = { }); return datasetRecord(def, row as unknown as Record); }, + import_dataset(db, def, _id, input, ctx) { + ownerOnly(ctx); + const row = new AiDatasetBuilder(db).importDataset({ + name: requiredText(input, "name"), + domain: typeof input.domain === "string" ? input.domain : undefined, + examples: input.examples as unknown as DatasetExample[], + }); + return datasetRecord(def, row as unknown as Record); + }, + }, +}; + +export const MEMORY_MAINTENANCE_ACTIONS: ActionDef[] = [ + { + name: "wiki_synthesize", + label: "Synthesize wiki memory", + target: "collection", + effect: "external", + execution: "sync", + roles: ["owner", "intelligence"], + confirmation: { required: true, ttlSeconds: 300 }, + inputSchema: { + type: "object", + additionalProperties: false, + properties: { agent_id: { type: "string", minLength: 1 } }, + }, + idempotency: { required: true, ttlSeconds: 300 }, + }, +]; + +export const memoryMaintenanceRuntimeAdapter: RecordAdapter = { + id: "memory_maintenance_runtime", + policy: { + authorize(_operation, _def, ctx) { + runtimeOperator(ctx); + }, + }, + actions: { + wiki_synthesize(_db, _def, _id, input, ctx) { + const maintenance = runtime().memoryMaintenance; + return { + ok: true, + jobId: maintenance.enqueueWikiSynthesize( + ctx.tenantId ?? "", + typeof input.agent_id === "string" + ? input.agent_id + : ctx.agentId ?? "intelligence" + ), + }; + }, + }, +}; + +export const AUTONOMOUS_RUNTIME_ACTIONS: ActionDef[] = [ + { + name: "kick", + label: "Kick autonomous runner", + target: "collection", + effect: "external", + execution: "sync", + roles: ["owner", "intelligence"], + confirmation: { required: true, ttlSeconds: 300 }, + inputSchema: { type: "object", additionalProperties: false, properties: {} }, + idempotency: { required: true, ttlSeconds: 60 }, + }, +]; + +export const autonomousRuntimeAdapter: RecordAdapter = { + id: "autonomous_runtime", + policy: { + authorize(_operation, _def, ctx) { + runtimeOperator(ctx); + }, + }, + actions: { + kick(_db, _def, _id, _input, ctx) { + const queue = runtime().queue; + if (queue.hasPendingOrRunningWorkflow(AUTONOMOUS_RUNNER_ID)) { + return { ok: true, alreadyRunning: true }; + } + return { + ok: true, + jobId: queue.enqueue({ + workflowId: AUTONOMOUS_RUNNER_ID, + context: { autonomousTick: true, autoChainTick: 0 }, + priority: 1, + tenantId: ctx.tenantId, + }), + }; + }, }, }; @@ -958,9 +1758,18 @@ export const integrationRuntimeAdapter: RecordAdapter = { export const runtimeAdapters = [ chatSessionRuntimeAdapter, chatMessageRuntimeAdapter, + modelAdapterRuntimeAdapter, + embeddingRuntimeAdapter, + capabilityIndexRuntimeAdapter, + intelligenceSettingsRuntimeAdapter, + promptFlowRuntimeAdapter, + vaultSecretRuntimeAdapter, + providerCredentialRuntimeAdapter, modelRuntimeAdapter, promptQueueRuntimeAdapter, datasetRuntimeAdapter, + memoryMaintenanceRuntimeAdapter, + autonomousRuntimeAdapter, trainingJobRuntimeAdapter, inferenceRuntimeAdapter, integrationRuntimeAdapter, @@ -971,22 +1780,137 @@ export const runtimeAdapterRegistrations = [ objectType: "ChatSession", adapterId: "chat_session_runtime", database: "tenant", - operations: ["list", "get"], + operations: ["list", "get", "create", "delete"], fields: ["id", "title", "created_at", "updated_at"], - // Chat turn streaming remains a protocol exception. Session lifecycle - // actions are kernel-dispatched, while send_message stays on /api/ai/chat. - actions: CHAT_SESSION_ACTIONS.filter( - (action) => action.name !== "send_message" - ), + // Chat turn streaming remains on the authorized SSE protocol endpoint and + // is not declared as a Record action. + actions: CHAT_SESSION_ACTIONS, }, { objectType: "ChatMessage", adapterId: "chat_message_runtime", database: "tenant", - operations: ["list", "get"], + operations: ["list", "get", "create", "delete"], fields: ["id", "chat_id", "role", "content", "created_at"], actions: [], }, + { + objectType: "ModelAdapter", + adapterId: "model_adapter_runtime", + database: "tenant", + operations: ["list", "get", "create", "update", "delete"], + fields: [ + "id", + "name", + "path", + "description", + "domain", + "enabled", + "default_scale", + "created_at", + "updated_at", + ], + actions: [], + }, + { + objectType: "EmbeddingRuntime", + adapterId: "embedding_runtime", + database: "tenant", + operations: ["list", "get"], + fields: [ + "id", + "enabled", + "enabled_override", + "state", + "health_ok", + "pid", + "port", + "error", + ], + actions: EMBEDDING_RUNTIME_ACTIONS, + }, + { + objectType: "CapabilityIndex", + adapterId: "capability_index_runtime", + database: "tenant", + operations: ["list", "get"], + fields: ["id", "index_rows"], + actions: CAPABILITY_INDEX_ACTIONS, + }, + { + objectType: "IntelligenceSettings", + adapterId: "intelligence_settings_runtime", + database: "tenant", + operations: ["list", "get", "update"], + fields: [ + "id", + "active_model_path", + "ctx_size", + "gpu_layers", + "port", + "flash_attn", + "threads", + "batch_size", + "ubatch_size", + "parallel", + "jinja", + "extra_args", + "auto_start", + "temperature", + "top_p", + "top_k", + "min_p", + "repeat_penalty", + "presence_penalty", + "frequency_penalty", + "max_tokens", + "seed", + "system_prompt", + "enable_thinking", + "thinking_efficiency", + "native_tools", + "memory_mode", + ], + actions: [], + }, + { + objectType: "PromptFlow", + adapterId: "prompt_flow_runtime", + database: "tenant", + operations: ["list", "get", "update"], + fields: ["id", "agent_id", "config", "assembled"], + actions: [], + }, + { + objectType: "VaultSecret", + adapterId: "vault_secret_runtime", + database: "tenant", + operations: ["list", "get", "create", "delete"], + fields: ["id", "name", "value", "masked", "created_at"], + actions: [], + }, + { + objectType: "ProviderCredential", + adapterId: "provider_credential_runtime", + database: "tenant", + operations: ["list", "get", "create", "delete"], + fields: [ + "id", + "agent_id", + "kind", + "provider", + "label", + "api_key", + "display_name", + "email", + "scopes", + "status", + "masked_token", + "created_at", + "updated_at", + ], + actions: [], + }, { objectType: "ModelRuntime", adapterId: "model_runtime", @@ -1021,6 +1945,22 @@ export const runtimeAdapterRegistrations = [ fields: ["id", "name", "domain", "row_count", "created_at", "updated_at"], actions: DATASET_ACTIONS, }, + { + objectType: "MemoryMaintenance", + adapterId: "memory_maintenance_runtime", + database: "tenant", + operations: [], + fields: ["id"], + actions: MEMORY_MAINTENANCE_ACTIONS, + }, + { + objectType: "AutonomousRuntime", + adapterId: "autonomous_runtime", + database: "tenant", + operations: [], + fields: ["id"], + actions: AUTONOMOUS_RUNTIME_ACTIONS, + }, { objectType: "TrainingJob", adapterId: "training_job_runtime", diff --git a/apps/bridge/src/kernel/adapters/sql-read.ts b/apps/bridge/src/kernel/adapters/sql-read.ts index d2d6be3..860bba1 100644 --- a/apps/bridge/src/kernel/adapters/sql-read.ts +++ b/apps/bridge/src/kernel/adapters/sql-read.ts @@ -1,6 +1,5 @@ import type { AppDatabase } from "../../db.js"; import type { ObjectTypeDef, RecordData, RecordRow } from "@godmode/kernel"; -import { getCoreDb } from "../../core-db.js"; import type { OperationContext, RecordAdapter, @@ -24,10 +23,13 @@ function ident(value: string): string { } function sourceDb( - tenantDb: AppDatabase, - options: SqlReadAdapterOptions + declaredDb: AppDatabase, + _options: SqlReadAdapterOptions ): AppDatabase { - return options.database === "core" ? getCoreDb() : tenantDb; + // record-api resolves ObjectType.database before dispatch. Always use that + // authoritative handle so injected core databases and tenant isolation are + // preserved instead of re-opening a process-global singleton here. + return declaredDb; } function scopeClause( diff --git a/apps/bridge/src/kernel/adapters/structure-node.ts b/apps/bridge/src/kernel/adapters/structure-node.ts index 9df02bd..d5ded00 100644 --- a/apps/bridge/src/kernel/adapters/structure-node.ts +++ b/apps/bridge/src/kernel/adapters/structure-node.ts @@ -11,6 +11,8 @@ import { updateNode, } from "../../services/structure.js"; import type { RecordAdapter } from "../adapter-registry.js"; +import { writeStructureGraphLayout } from "../../services/structure-graph-service.js"; +import type { StructureGraphLayout } from "@godmode/flow-core"; function nodeToRecord(n: { id: string; @@ -231,5 +233,10 @@ export const structureNodeAdapter: RecordAdapter = { }); return { ok: true }; }, + save_layout(db, _def, _id, input, ctx) { + writeStructureGraphLayout(db, input.layout as unknown as StructureGraphLayout); + ctx.bus?.emit("structure.layout.updated", {}); + return { ok: true }; + }, }, }; diff --git a/apps/bridge/src/kernel/core-object-types.ts b/apps/bridge/src/kernel/core-object-types.ts index 1bdceae..df8182e 100644 --- a/apps/bridge/src/kernel/core-object-types.ts +++ b/apps/bridge/src/kernel/core-object-types.ts @@ -1,5 +1,10 @@ import type { ObjectTypeDef } from "@godmode/kernel"; -import { registerRecordAdapter } from "./adapter-registry.js"; +import { + getRecordAdapter, + hasRecordAdapter, + registerRecordAdapter, + type RecordAdapter, +} from "./adapter-registry.js"; import { createSqlReadAdapter } from "./adapters/sql-read.js"; import { agentServiceAdapter, @@ -9,6 +14,7 @@ import { scheduleServiceAdapter, wikiPageServiceAdapter, wikiRevisionServiceAdapter, + workflowCommentServiceAdapter, workflowServiceAdapter, workflowRunServiceAdapter, } from "./adapters/core-services.js"; @@ -28,6 +34,8 @@ import { wikiProposalServiceAdapter, } from "./adapters/content.js"; import { platformActionAdapters } from "./adapters/platform-actions.js"; +import { identityAdminAdapters } from "./adapters/identity-admin.js"; +import { platformConfigAdapters } from "./adapters/platform-config.js"; import { AUTOMATION_SPECS } from "./domains/automation.js"; import { COLLABORATION_SPECS } from "./domains/collaboration.js"; import { CONNECTIVITY_SUPPORT_SPECS } from "./domains/connectivity-support.js"; @@ -45,7 +53,8 @@ import { type BuiltinSpec, } from "./domains/shared.js"; import { runtimeAdapters } from "./adapters/runtime.js"; -import { registerObjectType } from "./registry.js"; +import { structureNodeAdapter } from "./adapters/structure-node.js"; +import { listObjectTypes, registerObjectType } from "./registry.js"; const DOMAIN_SPECS: BuiltinSpec[] = [ ...PLATFORM_SPECS, @@ -60,10 +69,11 @@ const DOMAIN_SPECS: BuiltinSpec[] = [ ...RUNTIME_SPECS, ]; -const SPEC_REGISTRATION_ORDER = [ +export const CORE_OBJECT_TYPE_NAMES = [ "Notification", "Artifact", "WorkflowRun", + "WorkflowComment", "HookRun", "PlatformEvent", "ActionLog", @@ -92,8 +102,12 @@ const SPEC_REGISTRATION_ORDER = [ "Hook", "User", "UserProfile", + "UserCredential", "Tenant", "TenantMembership", + "TenantProvisioningRun", + "PlatformBillingConfig", + "TenantOnboardingConfig", "ShareGrant", "MarketplaceListing", "MarketplaceEntitlement", @@ -106,18 +120,40 @@ const SPEC_REGISTRATION_ORDER = [ "SupportMessage", "DirectConversation", "DirectMessage", + "DmBlob", + "FederatedShareInvite", + "PlatformGroup", + "PlatformGroupMember", "ChatSession", "ChatMessage", + "ModelAdapter", + "EmbeddingRuntime", + "CapabilityIndex", + "IntelligenceSettings", + "PromptFlow", + "VaultSecret", + "ProviderCredential", "ModelRuntime", "PromptQueueJob", "Dataset", + "MemoryMaintenance", + "AutonomousRuntime", "TrainingJob", "InferenceRuntime", "IntegrationRuntime", ] as const; const SPECS_BY_NAME = new Map(DOMAIN_SPECS.map((spec) => [spec.name, spec])); -const SPECS = SPEC_REGISTRATION_ORDER.map((name) => { +if ( + SPECS_BY_NAME.size !== DOMAIN_SPECS.length || + SPECS_BY_NAME.size !== CORE_OBJECT_TYPE_NAMES.length || + CORE_OBJECT_TYPE_NAMES.some((name) => !SPECS_BY_NAME.has(name)) +) { + throw new Error( + "Core ObjectType declaration set does not exactly match the production registration set" + ); +} +const SPECS = CORE_OBJECT_TYPE_NAMES.map((name) => { const spec = SPECS_BY_NAME.get(name); if (!spec) throw new Error(`Missing core object type spec: ${name}`); return spec; @@ -130,6 +166,7 @@ const SERVICE_ADAPTERS = new Map( agentServiceAdapter, assignmentServiceAdapter, workflowServiceAdapter, + workflowCommentServiceAdapter, workflowRunServiceAdapter, scheduleServiceAdapter, hookServiceAdapter, @@ -147,13 +184,86 @@ const SERVICE_ADAPTERS = new Map( ruleServiceAdapter, skillServiceAdapter, wikiProposalServiceAdapter, + ...identityAdminAdapters, + ...platformConfigAdapters, ...platformActionAdapters, ...runtimeAdapters, ].map((adapter) => [adapter.id, adapter]) ); +const RECORD_OPERATIONS = [ + "list", + "get", + "create", + "update", + "delete", +] as const; + +function assertAdapterParity(def: ObjectTypeDef, adapter: RecordAdapter): void { + const declaredOperations = [...(def.operations ?? [])].sort(); + const implementedOperations = RECORD_OPERATIONS.filter( + (operation) => typeof adapter[operation] === "function" + ).sort(); + if ( + declaredOperations.length !== implementedOperations.length || + declaredOperations.some( + (operation, index) => operation !== implementedOperations[index] + ) + ) { + throw new Error( + `ObjectType ${def.name} operation parity mismatch for adapter ${adapter.id}: declared [${declaredOperations.join(", ")}], implemented [${implementedOperations.join(", ")}]` + ); + } + const declaredActions = (def.actions ?? []) + .map((action) => action.name) + .sort(); + const implementedActions = Object.keys(adapter.actions ?? {}).sort(); + if ( + declaredActions.length !== implementedActions.length || + declaredActions.some( + (action, index) => action !== implementedActions[index] + ) + ) { + throw new Error( + `ObjectType ${def.name} action parity mismatch for adapter ${adapter.id}: declared [${declaredActions.join(", ")}], implemented [${implementedActions.join(", ")}]` + ); + } +} + +export function assertCoreObjectTypeBootstrapComplete(): void { + const definitions = listObjectTypes().filter((def) => !def.pluginId); + const expectedNames = ["StructureNode", ...CORE_OBJECT_TYPE_NAMES].sort(); + const actualNames = definitions.map((def) => def.name).sort(); + if ( + expectedNames.length !== actualNames.length || + expectedNames.some((name, index) => name !== actualNames[index]) + ) { + throw new Error( + `Core ObjectType bootstrap mismatch: expected [${expectedNames.join(", ")}], registered [${actualNames.join(", ")}]` + ); + } + for (const def of definitions) { + if (def.storage.kind !== "adapter") { + throw new Error(`Core ObjectType ${def.name} is not adapter-backed`); + } + const adapter = getRecordAdapter(def.storage.adapterId); + if (!adapter) { + throw new Error( + `Core ObjectType ${def.name} has no registered adapter ${def.storage.adapterId}` + ); + } + assertAdapterParity(def, adapter); + } +} + export function registerCoreObjectTypes(): void { - if (registered) return; + if (registered) { + assertCoreObjectTypeBootstrapComplete(); + return; + } + if (!hasRecordAdapter(structureNodeAdapter.id)) { + registerRecordAdapter(structureNodeAdapter); + } for (const spec of SPECS) { let objectFields = buildFields( spec.fields, @@ -223,22 +333,10 @@ export function registerCoreObjectTypes(): void { }; const adapter = SERVICE_ADAPTERS.get(spec.id) ?? createSqlReadAdapter(spec); - for (const operation of def.operations ?? []) { - if (typeof adapter[operation] !== "function") { - throw new Error( - `ObjectType ${def.name} declares ${operation} but adapter ${adapter.id} does not implement it` - ); - } - } - for (const action of def.actions ?? []) { - if (typeof adapter.actions?.[action.name] !== "function") { - throw new Error( - `ObjectType ${def.name} declares action ${action.name} but adapter ${adapter.id} does not implement it` - ); - } - } + assertAdapterParity(def, adapter); registerRecordAdapter(adapter); registerObjectType(def); } registered = true; + assertCoreObjectTypeBootstrapComplete(); } diff --git a/apps/bridge/src/kernel/domains/automation.ts b/apps/bridge/src/kernel/domains/automation.ts index cfa1a55..3daf57a 100644 --- a/apps/bridge/src/kernel/domains/automation.ts +++ b/apps/bridge/src/kernel/domains/automation.ts @@ -5,10 +5,10 @@ const ownerRoles = ["owner", "intelligence"] as const; const emptyInput = { type: "object", additionalProperties: false }; const WORKFLOW_ACTIONS: ActionDef[] = [ - { name: "run", label: "Run", target: "record", effect: "external", execution: "sync", roles: [...ownerRoles], confirmation: { required: true }, idempotency: { required: true }, inputSchema: { type: "object", properties: { input: {} }, additionalProperties: false } }, + { name: "run", label: "Run", target: "record", effect: "external", execution: "sync", roles: [...ownerRoles], confirmation: { required: true }, idempotency: { required: true }, inputSchema: { type: "object", properties: { trigger_input: { type: "string" }, card_id: { type: "string" } }, additionalProperties: false } }, ]; const WORKFLOW_RUN_ACTIONS: ActionDef[] = [ - { name: "resume", label: "Resume", target: "record", effect: "external", execution: "sync", roles: [...ownerRoles], confirmation: { required: true }, idempotency: { required: true }, inputSchema: { type: "object", properties: { input: {} }, additionalProperties: false } }, + { name: "resume", label: "Resume", target: "record", effect: "external", execution: "sync", roles: [...ownerRoles], confirmation: { required: true }, idempotency: { required: true }, inputSchema: { type: "object", properties: { decision: { enum: ["approve", "request_changes"] }, comments: { type: "string" } }, additionalProperties: false } }, { name: "cancel", label: "Cancel", target: "record", effect: "destructive", execution: "sync", roles: [...ownerRoles], confirmation: { required: true }, inputSchema: emptyInput }, ]; const TOGGLE_ACTIONS: ActionDef[] = [ @@ -22,6 +22,7 @@ const HOOK_RUN_ACTIONS: ActionDef[] = [ export const AUTOMATION_SPECS: BuiltinSpec[] = [ { name: "WorkflowRun", label: "Workflow Run", module: "automation", id: "workflow_run_read", table: "ai_workflow_runs", defaultSort: "updated_at", actions: WORKFLOW_RUN_ACTIONS, fields: ["id", "workflow_id", "status", "trigger_input", ["state_json", "JSON"], "awaiting_node_id", "card_id", ["result_json", "JSON"], "error", "created_at", "updated_at"] }, + { name: "WorkflowComment", label: "Workflow Comment", module: "automation", id: "workflow_comment_service", table: "ai_workflow_comments", defaultSort: "created_at", writable: ["workflow_id", "author", "body"], required: ["workflow_id", "body"], operations: ["list", "get", "create", "delete"], fields: ["id", "workflow_id", "author", "body", "created_at"] }, { name: "HookRun", label: "Hook Run", module: "automation", id: "hook_run_read", table: "hook_runs", database: "core", defaultSort: "created_at", actions: HOOK_RUN_ACTIONS, fields: ["id", "hook_id", "event_id", "status", "detail", ["result_json", "JSON"], "created_at"] }, { name: "PlatformEvent", label: "Platform Event", module: "automation", id: "platform_event_read", table: "events", database: "core", scope: "tenant", defaultSort: "created_at", fields: ["id", "type", "actor_kind", "actor_id", "tenant_id", ["payload_json", "JSON"], "created_at"] }, { name: "Workflow", label: "Workflow", module: "automation", id: "workflow_service", table: "ai_workflows", defaultSort: "updated_at", writable: ["agent_id", "name", "config_json", "enabled"], required: ["name"], operations: ["list", "get", "create", "update", "delete"], actions: WORKFLOW_ACTIONS, fields: ["id", "agent_id", "name", ["config_json", "JSON"], ["enabled", "Check"], "created_at", "updated_at"] }, diff --git a/apps/bridge/src/kernel/domains/collaboration.ts b/apps/bridge/src/kernel/domains/collaboration.ts index d715a06..f30cb76 100644 --- a/apps/bridge/src/kernel/domains/collaboration.ts +++ b/apps/bridge/src/kernel/domains/collaboration.ts @@ -4,4 +4,8 @@ import { PLATFORM_ACTION_METADATA } from "../adapters/platform-actions.js"; export const COLLABORATION_SPECS: BuiltinSpec[] = [ { name: "DirectConversation", label: "Direct Conversation", module: "messages", id: "dm_conversation_read", table: "dm_conversations", database: "core", defaultSort: "updated_at", accessPolicy: "relationship-scoped", writable: ["kind", "title", "member_user_ids"], operations: ["list", "get", "create"], actions: PLATFORM_ACTION_METADATA.DirectConversation, fields: ["id", "kind", "title", ["member_user_ids", "JSON"], "created_by_user_id", "created_at", "updated_at", "last_message_at", "last_message_preview"] }, { name: "DirectMessage", label: "Direct Message", module: "messages", id: "dm_message_read", table: "dm_messages", database: "core", defaultSort: "created_at", accessPolicy: "relationship-scoped", writable: ["conversation_id", "body_text", "attachments"], required: ["conversation_id"], operations: ["list", "get", "create"], actions: PLATFORM_ACTION_METADATA.DirectMessage, fields: ["id", "conversation_id", "sender_user_id", "body_text", ["attachments", "JSON"], "created_at", "edited_at", "deleted_at"] }, + { name: "DmBlob", label: "DM Blob", module: "messages", id: "dm_blob_service", table: "dm_blobs", database: "core", defaultSort: "created_at", accessPolicy: "relationship-scoped", operations: ["list", "get"], actions: PLATFORM_ACTION_METADATA.DmBlob, fields: ["id", "owner_user_id", "filename", "mime", ["size", "Int"], "created_at"] }, + { name: "FederatedShareInvite", label: "Federated Share Invite", module: "messages", id: "federated_share_invite_service", table: "federated_share_invites", database: "core", accessPolicy: "relationship-scoped", operations: [], actions: PLATFORM_ACTION_METADATA.FederatedShareInvite, fields: ["id", "owner_tenant_id", "owner_user_id", "resource_kind", "resource_id", "role", "invitee_email", "status", "expires_at", "accepted_peer_connection_id", "created_at"] }, + { name: "PlatformGroup", label: "Platform Group", module: "messages", id: "platform_group_service", table: "platform_groups", database: "core", accessPolicy: "relationship-scoped", fields: ["id", "slug", "name", "description", "created_at"] }, + { name: "PlatformGroupMember", label: "Platform Group Member", module: "messages", id: "platform_group_member_service", table: "platform_group_members", database: "core", accessPolicy: "relationship-scoped", actions: PLATFORM_ACTION_METADATA.PlatformGroupMember, fields: ["id", "group_id", "member_kind", "member_id", "tenant_id", "created_at"] }, ]; diff --git a/apps/bridge/src/kernel/domains/intelligence.ts b/apps/bridge/src/kernel/domains/intelligence.ts index 8586118..c152a17 100644 --- a/apps/bridge/src/kernel/domains/intelligence.ts +++ b/apps/bridge/src/kernel/domains/intelligence.ts @@ -4,6 +4,8 @@ import { CONTENT_LIFECYCLE_ACTIONS } from "../adapters/content.js"; import { PLATFORM_ACTION_METADATA } from "../adapters/platform-actions.js"; const AGENT_ACTIONS: ActionDef[] = [ + { name: "create_configured", label: "Create Configured Agent", target: "collection", effect: "write", execution: "sync", roles: ["owner"], inputSchema: { type: "object", required: ["name"], properties: { name: { type: "string" }, description: { type: "string" }, icon: { type: "string" }, backend: { type: "string" }, system_prompt: { type: "string" }, sampling: { type: "object" }, thinking: { type: "object" }, tool_allow: { type: ["array", "null"] }, auto_approve: { type: "array" }, model_path: { type: ["string", "null"] }, adapter_ids: { type: "array" }, config: { type: "object" }, parent_id: { type: ["string", "null"] }, team: { type: ["string", "null"] } }, additionalProperties: false } }, + { name: "update_config", label: "Update Agent Configuration", target: "record", effect: "write", execution: "sync", roles: ["owner"], inputSchema: { type: "object", properties: { name: { type: "string" }, description: { type: ["string", "null"] }, icon: { type: ["string", "null"] }, backend: { type: "string" }, enabled: { type: "boolean" }, system_prompt: { type: "string" }, sampling: { type: "object" }, thinking: { type: "object" }, tool_allow: { type: ["array", "null"] }, auto_approve: { type: "array" }, model_path: { type: ["string", "null"] }, adapter_ids: { type: "array" }, config: { type: "object" }, parent_id: { type: ["string", "null"] }, team: { type: ["string", "null"] } }, additionalProperties: false } }, { name: "clone", label: "Clone", target: "record", effect: "write", execution: "sync", roles: ["owner", "intelligence"], idempotency: { required: true }, inputSchema: { type: "object", required: ["name"], properties: { id: { type: "string" }, name: { type: "string" }, description: { type: "string" }, parent_id: { type: ["string", "null"] }, team: { type: ["string", "null"] } }, additionalProperties: false } }, { name: "assign", label: "Assign", target: "record", effect: "write", execution: "sync", roles: ["owner", "intelligence"], confirmation: { required: true }, inputSchema: { type: "object", required: ["scope_type", "scope_id"], properties: { scope_type: { enum: ["department", "division", "page"] }, scope_id: { type: "string" }, role: { enum: ["viewer", "editor", "owner"] } }, additionalProperties: false } }, { name: "configure_reflection", label: "Configure Reflection", target: "record", effect: "write", execution: "sync", roles: ["owner", "intelligence"], confirmation: { required: true }, inputSchema: { type: "object", properties: { enabled: { type: "boolean" }, mode: { enum: ["approval", "auto"] }, schedule: { type: "object" }, idle: { type: "object" } }, additionalProperties: false } }, diff --git a/apps/bridge/src/kernel/domains/marketplace.ts b/apps/bridge/src/kernel/domains/marketplace.ts index a7d6fb3..8138ee4 100644 --- a/apps/bridge/src/kernel/domains/marketplace.ts +++ b/apps/bridge/src/kernel/domains/marketplace.ts @@ -2,8 +2,8 @@ import type { BuiltinSpec } from "./shared.js"; import { PLATFORM_ACTION_METADATA } from "../adapters/platform-actions.js"; export const MARKETPLACE_SPECS: BuiltinSpec[] = [ - { name: "MarketplaceListing", label: "Marketplace Listing", module: "marketplace", id: "marketplace_listing_read", table: "marketplace_listings", database: "core", defaultSort: "updated_at", accessPolicy: "relationship-scoped", actions: PLATFORM_ACTION_METADATA.MarketplaceListing, fields: ["id", "seller_user_id", "seller_tenant_id", "kind", "resource_id", "title", "description", ["price_credits", "Int"], "visibility", "status", "created_at", "updated_at"] }, + { name: "MarketplaceListing", label: "Marketplace Listing", module: "marketplace", id: "marketplace_listing_read", table: "marketplace_listings", database: "core", defaultSort: "updated_at", accessPolicy: "relationship-scoped", actions: PLATFORM_ACTION_METADATA.MarketplaceListing, fields: ["id", "seller_user_id", "seller_tenant_id", "kind", "resource_id", "title", "description", ["price_credits", "Int"], "visibility", "status", "delivery_mode", "pricing_model", "price_period", "meter_unit", ["meter_rate", "Float"], "license", "inference_endpoint_id", "created_at", "updated_at"] }, { name: "MarketplaceEntitlement", label: "Marketplace Entitlement", module: "marketplace", id: "marketplace_entitlement_read", table: "marketplace_entitlements", database: "core", scope: "tenant", scopeColumn: "buyer_tenant_id", defaultSort: "updated_at", actions: PLATFORM_ACTION_METADATA.MarketplaceEntitlement, fields: ["id", "listing_id", "buyer_user_id", "buyer_tenant_id", "kind", "owner_tenant_id", "resource_kind", "resource_id", "status", "expires_at", "created_at", "updated_at"] }, { name: "CatalogSource", label: "Catalog Source", module: "marketplace", id: "catalog_source_read", table: "catalog_sources", database: "core", scope: "user", writable: ["name", "url"], required: ["name", "url"], operations: ["list", "get", "create", "delete"], actions: PLATFORM_ACTION_METADATA.CatalogSource, fields: ["id", "user_id", "name", "url", "created_at"] }, - { name: "CatalogInstall", label: "Catalog Install", module: "marketplace", id: "catalog_install_read", table: "catalog_installs", database: "core", scope: "tenant", defaultSort: "installed_at", fields: ["id", "tenant_id", "user_id", "entry_id", "entry_title", "install_type", "source_catalog", "installed_at"] }, + { name: "CatalogInstall", label: "Catalog Install", module: "marketplace", id: "catalog_install_read", table: "catalog_installs", database: "core", scope: "tenant", defaultSort: "installed_at", actions: PLATFORM_ACTION_METADATA.CatalogInstall, fields: ["id", "tenant_id", "user_id", "entry_id", "entry_title", "install_type", "source_catalog", "installed_at"] }, ]; diff --git a/apps/bridge/src/kernel/domains/platform.ts b/apps/bridge/src/kernel/domains/platform.ts index ae090e1..1f5b5c2 100644 --- a/apps/bridge/src/kernel/domains/platform.ts +++ b/apps/bridge/src/kernel/domains/platform.ts @@ -1,6 +1,8 @@ import type { BuiltinSpec } from "./shared.js"; import { NOTIFICATION_ACTIONS } from "../adapters/content.js"; import { PLATFORM_ACTION_METADATA } from "../adapters/platform-actions.js"; +import { IDENTITY_ADMIN_ACTIONS } from "../adapters/identity-admin.js"; +import { PLATFORM_CONFIG_ACTIONS } from "../adapters/platform-config.js"; export const PLATFORM_SPECS: BuiltinSpec[] = [ { name: "Notification", label: "Notification", module: "platform", id: "notification_service", table: "notifications", database: "core", scope: "tenant", scopeColumn: "recipient_tenant_id", defaultSort: "created_at", writable: ["recipient_kind", "recipient_id", "recipient_tenant_id", "category", "title", "body", "link", "resource_kind", "resource_id"], required: ["recipient_kind", "recipient_id", "title"], operations: ["list", "get", "create", "delete"], actions: NOTIFICATION_ACTIONS, fields: ["id", "recipient_kind", "recipient_id", "recipient_tenant_id", "category", "title", "body", "link", "resource_kind", "resource_id", "read_at", "created_at"] }, @@ -27,9 +29,106 @@ export const PLATFORM_SPECS: BuiltinSpec[] = [ }, ], }, - { name: "User", label: "User", module: "platform", id: "user_read", table: "users", database: "core", scope: "admin", fields: ["id", "email", "display_name", "avatar_url", ["is_admin", "Check"], "created_at", "updated_at"] }, - { name: "UserProfile", label: "User Profile", module: "platform", id: "user_profile_read", table: "user_profiles", database: "core", idColumn: "user_id", scope: "user", scopeColumn: "user_id", fields: ["id", "user_id", "headline", "bio", "pronouns", "location", "timezone", "company", "job_title", "website", "github", "linkedin", "created_at", "updated_at"] }, - { name: "Tenant", label: "Tenant", module: "platform", id: "tenant_read", table: "tenants", database: "core", scope: "tenant", scopeColumn: "id", fields: ["id", "name", "slug", ["is_operator", "Check"], "owner_user_id", "created_at", "updated_at"] }, - { name: "TenantMembership", label: "Tenant Membership", module: "platform", id: "tenant_membership_read", table: "tenant_memberships", database: "core", idColumn: "user_id", scope: "tenant", fields: ["id", "user_id", "tenant_id", "role", "created_at"] }, - { name: "ShareGrant", label: "Share Grant", module: "platform", id: "share_grant_read", table: "share_grants", database: "core", scope: "tenant", scopeColumn: "owner_tenant_id", defaultSort: "updated_at", writable: ["resource_kind", "resource_id", "grantee_user_id", "grantee_tenant_id", "role"], required: ["resource_kind", "resource_id"], operations: ["list", "get", "create", "delete"], actions: PLATFORM_ACTION_METADATA.ShareGrant, fields: ["id", "owner_tenant_id", "owner_user_id", "resource_kind", "resource_id", "grantee_user_id", "grantee_tenant_id", "role", "created_at", "updated_at"] }, + { + name: "User", + label: "User", + module: "platform", + id: "user_admin_service", + table: "users", + database: "core", + scope: "admin", + writable: ["email", "display_name", "is_admin"], + required: ["email", "display_name"], + operations: ["list", "get", "update", "delete"], + actions: IDENTITY_ADMIN_ACTIONS.User, + fields: ["id", "email", "display_name", "avatar_url", ["is_admin", "Check"], ["tenant_count", "Int"], "created_at"], + }, + { + name: "UserProfile", + label: "User Profile", + module: "platform", + id: "user_profile_service", + table: "user_profiles", + database: "core", + scope: "user", + writable: ["display_name", "avatar_url", "headline", "bio", "pronouns", "location", "timezone", "phone", "company", "job_title", "website", "twitter", "github", "linkedin", "emoji", "birthday", "languages", "interests", "values", "goals", "personality_notes", "decision_style", "risk_tolerance"], + operations: ["list", "get", "update"], + actions: IDENTITY_ADMIN_ACTIONS.UserProfile, + fields: ["id", "user_id", "email", "display_name", "avatar_url", "headline", "bio", "pronouns", "location", "timezone", "phone", "company", "job_title", "website", "twitter", "github", "linkedin", "emoji", "birthday", "languages", "interests", "values", "goals", "personality_notes", "decision_style", "risk_tolerance", "created_at", "updated_at"], + }, + { + name: "UserCredential", + label: "User Credential", + module: "platform", + id: "user_credential_service", + table: "users", + database: "core", + scope: "user", + operations: ["list", "get"], + actions: IDENTITY_ADMIN_ACTIONS.UserCredential, + fields: ["id", "user_id", ["has_password", "Check"], ["has_oauth", "Check"], "updated_at"], + }, + { + name: "Tenant", + label: "Tenant", + module: "platform", + id: "tenant_admin_service", + table: "tenants", + database: "core", + scope: "tenant", + writable: ["name", "slug"], + required: ["name"], + operations: ["list", "get", "create", "update", "delete"], + actions: IDENTITY_ADMIN_ACTIONS.Tenant, + fields: ["id", "name", "slug", ["is_operator", "Check"], "owner_user_id", "created_at", "updated_at"], + }, + { + name: "TenantMembership", + label: "Tenant Membership", + module: "platform", + id: "tenant_membership_service", + table: "tenant_memberships", + database: "core", + scope: "tenant", + writable: ["user_id", "tenant_id", "role"], + required: ["user_id", "role"], + operations: ["list", "get", "create", "update", "delete"], + actions: IDENTITY_ADMIN_ACTIONS.TenantMembership, + fields: ["id", "user_id", "tenant_id", "role", "created_at"], + }, + { + name: "TenantProvisioningRun", + label: "Tenant Provisioning Run", + module: "platform", + id: "tenant_provisioning_run_service", + table: "platform_meta", + database: "core", + scope: "user", + operations: ["list", "get"], + fields: ["id", "operation", "status", "actor_user_id", "owner_user_id", "tenant_id", "tenant_name", "tenant_slug", "error", "created_at", "updated_at"], + }, + { + name: "PlatformBillingConfig", + label: "Platform Billing Config", + module: "platform", + id: "platform_billing_config_service", + table: "platform_meta", + database: "core", + scope: "admin", + operations: ["list", "get"], + actions: PLATFORM_CONFIG_ACTIONS.PlatformBillingConfig, + fields: ["id", ["configured", "Check"], "publishable_key", ["credits_per_usd", "Float"], ["has_secret_key", "Check"]], + }, + { + name: "TenantOnboardingConfig", + label: "Tenant Onboarding Config", + module: "platform", + id: "tenant_onboarding_config_service", + table: "ai_settings", + scope: "tenant", + operations: ["list", "get"], + actions: PLATFORM_CONFIG_ACTIONS.TenantOnboardingConfig, + fields: ["id", "tenant_id", ["completed", "Check"], ["llm_ready", "Check"], ["cursor_connected", "Check"], ["llm_status", "JSON"]], + }, + { name: "ShareGrant", label: "Share Grant", module: "platform", id: "share_grant_read", table: "share_grants", database: "core", scope: "tenant", scopeColumn: "owner_tenant_id", defaultSort: "updated_at", writable: ["resource_kind", "resource_id", "grantee_user_id", "grantee_tenant_id", "role", "expires_at"], required: ["resource_kind", "resource_id"], operations: ["list", "get", "create", "delete"], actions: PLATFORM_ACTION_METADATA.ShareGrant, fields: ["id", "owner_tenant_id", "owner_user_id", "resource_kind", "resource_id", "grantee_user_id", "grantee_tenant_id", "role", "expires_at", "created_at", "updated_at"] }, ]; diff --git a/apps/bridge/src/kernel/domains/productivity.ts b/apps/bridge/src/kernel/domains/productivity.ts index 933efb8..9099615 100644 --- a/apps/bridge/src/kernel/domains/productivity.ts +++ b/apps/bridge/src/kernel/domains/productivity.ts @@ -8,7 +8,7 @@ import { export const PRODUCTIVITY_SPECS: BuiltinSpec[] = [ { name: "Project", label: "Project", module: "productivity", id: "project_read", table: "ai_projects", defaultSort: "updated_at", fields: ["id", "name", "agent_id", "user_id", "created_at", "updated_at"] }, { name: "ProjectColumn", label: "Project Column", module: "productivity", id: "project_column_read", table: "ai_project_columns", fields: ["id", "project_id", "name", ["sort_order", "Int"]] }, - { name: "TaskCard", label: "Task Card", module: "productivity", id: "task_card_service", table: "ai_project_cards", defaultSort: "updated_at", writable: ["id", "title", "description", "prompt", "context_json", "tags_json", "due_at", "linked_chat_id", "linked_workflow_id", "priority", "assigned_agent_id"], required: ["title"], operations: ["list", "get", "create", "update"], actions: TASK_CARD_ACTIONS, fields: ["id", "project_id", "column_id", "title", "description", "prompt", ["context_json", "JSON"], ["tags_json", "JSON"], "due_at", "linked_chat_id", "linked_workflow_id", ["priority", "Int"], "parent_card_id", "status", "assigned_agent_id", ["sort_order", "Int"], "created_at", "updated_at"] }, + { name: "TaskCard", label: "Task Card", module: "productivity", id: "task_card_service", table: "ai_project_cards", defaultSort: "updated_at", writable: ["id", "title", "description", "prompt", "context_json", "tags_json", "due_at", "linked_chat_id", "linked_workflow_id", "priority", "assigned_agent_id"], required: ["title"], operations: ["list", "get", "create", "update", "delete"], actions: TASK_CARD_ACTIONS, fields: ["id", "project_id", "column_id", "title", "description", "prompt", ["context_json", "JSON"], ["tags_json", "JSON"], "due_at", "linked_chat_id", "linked_workflow_id", ["priority", "Int"], "parent_card_id", "status", "assigned_agent_id", ["sort_order", "Int"], "created_at", "updated_at"] }, { name: "CardComment", label: "Card Comment", module: "productivity", id: "card_comment_service", table: "ai_card_comments", defaultSort: "created_at", actions: CARD_COMMENT_ACTIONS, fields: ["id", "card_id", "author", "body", "created_at"] }, { name: "CalendarEvent", label: "Calendar Event", module: "productivity", id: "calendar_event_service", table: "ai_calendar_events", defaultSort: "start_at", writable: ["id", "kind", "title", "description", "start_at", "end_at", "all_day", "location", "linked_card_id", "linked_run_id", "status"], required: ["title", "start_at"], actions: CALENDAR_EVENT_ACTIONS, fields: ["id", "agent_id", "kind", "title", "description", "start_at", "end_at", ["all_day", "Check"], "location", "linked_card_id", "linked_run_id", "status", "created_at", "updated_at"] }, ]; diff --git a/apps/bridge/src/kernel/domains/runtime.ts b/apps/bridge/src/kernel/domains/runtime.ts index e2f94ee..d02dfed 100644 --- a/apps/bridge/src/kernel/domains/runtime.ts +++ b/apps/bridge/src/kernel/domains/runtime.ts @@ -1,4 +1,8 @@ -import type { BuiltinSpec, FieldSpec } from "./shared.js"; +import { + WRITE_PERMISSIONS, + type BuiltinSpec, + type FieldSpec, +} from "./shared.js"; import { runtimeAdapterRegistrations, } from "../adapters/runtime.js"; @@ -6,9 +10,18 @@ import { const LABELS: Record = { ChatSession: "Chat Session", ChatMessage: "Chat Message", + ModelAdapter: "Model Adapter", + EmbeddingRuntime: "Embedding Runtime", + CapabilityIndex: "Capability Index", + IntelligenceSettings: "Intelligence Settings", + PromptFlow: "Prompt Flow", + VaultSecret: "Vault Secret", + ProviderCredential: "Provider Credential", ModelRuntime: "Model Runtime", PromptQueueJob: "Prompt Queue Job", Dataset: "Dataset", + MemoryMaintenance: "Memory Maintenance", + AutonomousRuntime: "Autonomous Runtime", TrainingJob: "Training Job", InferenceRuntime: "Inference Runtime", IntegrationRuntime: "Integration Runtime", @@ -23,6 +36,64 @@ const FIELD_TYPES: Record = { progress: ["progress", "Float"], health_ok: ["health_ok", "Check"], connected: ["connected", "Check"], + enabled: ["enabled", "Check"], + enabled_override: ["enabled_override", "Check"], + index_rows: ["index_rows", "Int"], + default_scale: ["default_scale", "Float"], + config: ["config", "JSON"], + assembled: ["assembled", "JSON"], + scopes: ["scopes", "JSON"], +}; + +const WRITABLE: Record = { + ChatSession: ["title"], + ChatMessage: ["chat_id", "role", "content"], + ModelAdapter: [ + "name", + "path", + "description", + "domain", + "enabled", + "default_scale", + ], + IntelligenceSettings: [ + "active_model_path", + "ctx_size", + "gpu_layers", + "port", + "flash_attn", + "threads", + "batch_size", + "ubatch_size", + "parallel", + "jinja", + "extra_args", + "auto_start", + "temperature", + "top_p", + "top_k", + "min_p", + "repeat_penalty", + "presence_penalty", + "frequency_penalty", + "max_tokens", + "seed", + "system_prompt", + "enable_thinking", + "thinking_efficiency", + "native_tools", + "memory_mode", + ], + PromptFlow: ["agent_id", "config"], + VaultSecret: ["name", "value"], + ProviderCredential: ["agent_id", "provider", "label", "api_key"], +}; + +const REQUIRED: Record = { + ChatMessage: ["chat_id", "role", "content"], + ModelAdapter: ["name", "path"], + VaultSecret: ["name", "value"], + ProviderCredential: ["provider", "api_key"], }; export const RUNTIME_SPECS: BuiltinSpec[] = @@ -35,6 +106,16 @@ export const RUNTIME_SPECS: BuiltinSpec[] = database: registration.database, operations: [...registration.operations], actions: [...registration.actions], + writable: WRITABLE[registration.objectType], + required: REQUIRED[registration.objectType], + permissions: registration.operations.some( + (operation) => + operation === "create" || + operation === "update" || + operation === "delete" + ) + ? WRITE_PERMISSIONS + : undefined, accessPolicy: registration.database === "core" ? "platform-admin" : "tenant-local", fields: registration.fields.map( diff --git a/apps/bridge/src/kernel/index.ts b/apps/bridge/src/kernel/index.ts index c46de8e..5956891 100644 --- a/apps/bridge/src/kernel/index.ts +++ b/apps/bridge/src/kernel/index.ts @@ -29,11 +29,19 @@ export { createSystemOperationContext, cancelOperationRun, recoverInterruptedOperationRuns, + processClaimedOperationRun, seedRecords, materializeAllNativeTypes, ensureObjectTypeStorage, } from "./record-api.js"; +export { + OperationRunWorker, + claimOperationRun, + ensureOperationRunTables, + type OperationRunRow, +} from "./operation-run-worker.js"; + export { genericObjectTypeToolDefs, objectTypeAutoToolDefs, @@ -58,4 +66,7 @@ export { applyPluginObjectTypeSeeds, } from "./plugin-object-types.js"; -export { registerCoreObjectTypes } from "./core-object-types.js"; +export { + assertCoreObjectTypeBootstrapComplete, + registerCoreObjectTypes, +} from "./core-object-types.js"; diff --git a/apps/bridge/src/kernel/native-storage.ts b/apps/bridge/src/kernel/native-storage.ts index 6c76b20..320317d 100644 --- a/apps/bridge/src/kernel/native-storage.ts +++ b/apps/bridge/src/kernel/native-storage.ts @@ -30,6 +30,7 @@ export function ensureNativeTable(db: AppDatabase, def: ObjectTypeDef): string { return `${quoteIdent(f.name)} ${sqlType(f)}${required}`; }); cols.unshift(`id TEXT PRIMARY KEY`); + cols.push(`_kernel_version INTEGER NOT NULL DEFAULT 1`); cols.push(`created_at TEXT NOT NULL DEFAULT (datetime('now'))`); cols.push(`updated_at TEXT NOT NULL DEFAULT (datetime('now'))`); db.exec( @@ -42,6 +43,12 @@ export function ensureNativeTable(db: AppDatabase, def: ObjectTypeDef): string { }> ).map((r) => r.name) ); + if (!existing.has("_kernel_version")) { + db.exec( + `ALTER TABLE ${quoteIdent(table)} ADD COLUMN _kernel_version INTEGER NOT NULL DEFAULT 1` + ); + existing.add("_kernel_version"); + } for (const field of def.fields) { if ( field.name === "id" || @@ -101,7 +108,13 @@ function rowToRecord( def: ObjectTypeDef, row: Record ): RecordRow { - const { id, created_at: _c, updated_at: _u, ...rest } = row; + const { + id, + created_at: _c, + updated_at: _u, + _kernel_version, + ...rest + } = row; const data: RecordData = { id: String(id) }; for (const field of def.fields) { if (field.name === "id" || field.secret || !(field.name in rest)) continue; @@ -111,6 +124,7 @@ function rowToRecord( id: String(id), objectType: def.name, data, + version: String(_kernel_version ?? 1), }; } @@ -219,7 +233,8 @@ export function updateNativeRecord( db: AppDatabase, def: ObjectTypeDef, id: string, - data: RecordData + data: RecordData, + expectedVersion?: string ): RecordRow { const table = ensureNativeTable(db, def); const existing = getNativeRecord(db, def, id); @@ -240,9 +255,22 @@ export function updateNativeRecord( if (sets.length === 0) return existing; sets.push(`updated_at=datetime('now')`); vals.push(id); - db.prepare( - `UPDATE ${quoteIdent(table)} SET ${sets.join(", ")} WHERE id=?` + sets.push(`_kernel_version=_kernel_version+1`); + const normalizedExpected = expectedVersion + ?.replace(/^W\//, "") + .replace(/^"|"$/g, ""); + if (normalizedExpected) vals.push(Number(normalizedExpected)); + const info = db.prepare( + `UPDATE ${quoteIdent(table)} SET ${sets.join(", ")} WHERE id=?${ + normalizedExpected ? " AND _kernel_version=?" : "" + }` ).run(...vals); + if (info.changes === 0) { + throw Object.assign(new Error("resource version conflict"), { + status: 412, + code: "KERNEL_VERSION_CONFLICT", + }); + } const row = getNativeRecord(db, def, id); if (!row) throw new Error("failed to read updated record"); return row; @@ -251,11 +279,30 @@ export function updateNativeRecord( export function deleteNativeRecord( db: AppDatabase, def: ObjectTypeDef, - id: string + id: string, + expectedVersion?: string ): void { const table = ensureNativeTable(db, def); - const info = db.prepare(`DELETE FROM ${quoteIdent(table)} WHERE id=?`).run(id); + const normalizedExpected = expectedVersion + ?.replace(/^W\//, "") + .replace(/^"|"$/g, ""); + const info = db + .prepare( + `DELETE FROM ${quoteIdent(table)} WHERE id=?${ + normalizedExpected ? " AND _kernel_version=?" : "" + }` + ) + .run( + id, + ...(normalizedExpected ? [Number(normalizedExpected)] : []) + ); if (info.changes === 0) { + if (getNativeRecord(db, def, id)) { + throw Object.assign(new Error("resource version conflict"), { + status: 412, + code: "KERNEL_VERSION_CONFLICT", + }); + } throw Object.assign(new Error("record not found"), { status: 404 }); } } diff --git a/apps/bridge/src/kernel/operation-run-worker.ts b/apps/bridge/src/kernel/operation-run-worker.ts new file mode 100644 index 0000000..031195e --- /dev/null +++ b/apps/bridge/src/kernel/operation-run-worker.ts @@ -0,0 +1,286 @@ +import { randomUUID } from "node:crypto"; +import type { AppDatabase } from "../db.js"; + +export interface OperationRunRow { + id: string; + tenant_id: string | null; + actor_id: string; + object_type: string; + record_id: string | null; + action_name: string; + input_json: string; + context_json: string; + idempotency_key: string | null; + idempotency_ttl_seconds: number | null; + status: string; + attempt: number; + max_attempts: number; + timeout_ms: number | null; + cancellable: number; + recovery_safe: number; + lease_owner: string | null; +} + +function addColumn( + db: AppDatabase, + table: string, + name: string, + definition: string +): void { + const columns = db.prepare(`PRAGMA table_info("${table}")`).all() as Array<{ + name: string; + }>; + if (!columns.some((column) => column.name === name)) { + db.exec(`ALTER TABLE "${table}" ADD COLUMN "${name}" ${definition}`); + } +} + +export function ensureOperationRunTables(db: AppDatabase): void { + db.exec(` + CREATE TABLE IF NOT EXISTS kernel_action_idempotency ( + tenant_id TEXT NOT NULL DEFAULT '', + key TEXT NOT NULL, + actor_id TEXT NOT NULL, + object_type TEXT NOT NULL, + record_id TEXT NOT NULL, + action_name TEXT NOT NULL, + input_hash TEXT NOT NULL, + status TEXT NOT NULL, + result_json TEXT, + error_json TEXT, + expires_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (key, actor_id, object_type, record_id, action_name) + ); + CREATE TABLE IF NOT EXISTS kernel_operation_runs ( + id TEXT PRIMARY KEY, + tenant_id TEXT, + actor_id TEXT NOT NULL, + object_type TEXT NOT NULL, + record_id TEXT, + action_name TEXT NOT NULL, + input_json TEXT NOT NULL DEFAULT '{}', + context_json TEXT NOT NULL DEFAULT '{}', + idempotency_key TEXT, + idempotency_ttl_seconds INTEGER, + status TEXT NOT NULL, + attempt INTEGER NOT NULL DEFAULT 0, + max_attempts INTEGER NOT NULL DEFAULT 1, + timeout_ms INTEGER, + cancellable INTEGER NOT NULL DEFAULT 0, + recovery_safe INTEGER NOT NULL DEFAULT 0, + next_attempt_at TEXT, + lease_owner TEXT, + lease_expires_at TEXT, + progress REAL, + result_json TEXT, + error_code TEXT, + error_message TEXT, + error_json TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + finished_at TEXT + ); + CREATE INDEX IF NOT EXISTS kernel_operation_runs_claim + ON kernel_operation_runs(status, next_attempt_at, lease_expires_at, created_at); + `); + addColumn(db, "kernel_action_idempotency", "tenant_id", "TEXT NOT NULL DEFAULT ''"); + addColumn(db, "kernel_action_idempotency", "error_json", "TEXT"); + addColumn(db, "kernel_operation_runs", "input_json", "TEXT NOT NULL DEFAULT '{}'"); + addColumn(db, "kernel_operation_runs", "context_json", "TEXT NOT NULL DEFAULT '{}'"); + addColumn(db, "kernel_operation_runs", "idempotency_key", "TEXT"); + addColumn(db, "kernel_operation_runs", "idempotency_ttl_seconds", "INTEGER"); + addColumn(db, "kernel_operation_runs", "attempt", "INTEGER NOT NULL DEFAULT 0"); + addColumn(db, "kernel_operation_runs", "max_attempts", "INTEGER NOT NULL DEFAULT 1"); + addColumn(db, "kernel_operation_runs", "timeout_ms", "INTEGER"); + addColumn(db, "kernel_operation_runs", "cancellable", "INTEGER NOT NULL DEFAULT 0"); + addColumn(db, "kernel_operation_runs", "recovery_safe", "INTEGER NOT NULL DEFAULT 0"); + addColumn(db, "kernel_operation_runs", "next_attempt_at", "TEXT"); + addColumn(db, "kernel_operation_runs", "lease_owner", "TEXT"); + addColumn(db, "kernel_operation_runs", "lease_expires_at", "TEXT"); + addColumn(db, "kernel_operation_runs", "error_json", "TEXT"); +} + +export function recoverLeasedOperationRuns( + db: AppDatabase, + forceAllRunning = false +): { requeued: number; failed: number } { + ensureOperationRunTables(db); + return db.transaction(() => { + const rows = db + .prepare( + `SELECT id, tenant_id, actor_id, object_type, record_id, action_name, + idempotency_key, idempotency_ttl_seconds, recovery_safe + FROM kernel_operation_runs + WHERE status='running' + AND (? = 1 OR (lease_expires_at IS NOT NULL + AND lease_expires_at <= datetime('now')))` + ) + .all(forceAllRunning ? 1 : 0) as Array<{ + id: string; + tenant_id: string | null; + actor_id: string; + object_type: string; + record_id: string | null; + action_name: string; + idempotency_key: string | null; + idempotency_ttl_seconds: number | null; + recovery_safe: number; + }>; + let requeued = 0; + let failed = 0; + const unsafeError = JSON.stringify({ + code: "KERNEL_REPLAY_UNSAFE", + message: + "Interrupted action was not replayed without a declared retry or idempotency guarantee", + retryable: false, + }); + for (const row of rows) { + if (row.recovery_safe) { + requeued += db + .prepare( + `UPDATE kernel_operation_runs + SET status='retrying', lease_owner=NULL, lease_expires_at=NULL, + next_attempt_at=NULL, updated_at=datetime('now'), + finished_at=NULL + WHERE id=? AND status='running'` + ) + .run(row.id).changes; + continue; + } + const changed = db + .prepare( + `UPDATE kernel_operation_runs + SET status='failed', error_code='KERNEL_REPLAY_UNSAFE', + error_message='Interrupted action cannot be replayed safely', + error_json=?, lease_owner=NULL, lease_expires_at=NULL, + updated_at=datetime('now'), finished_at=datetime('now') + WHERE id=? AND status='running'` + ) + .run(unsafeError, row.id).changes; + if (changed !== 1) continue; + failed += 1; + if (row.idempotency_key) { + db.prepare( + `UPDATE kernel_action_idempotency + SET status='failed', result_json=NULL, error_json=?, + expires_at=datetime('now', ?), updated_at=datetime('now') + WHERE tenant_id=? AND key=? AND actor_id=? AND object_type=? + AND record_id=? AND action_name=? AND status='pending'` + ).run( + unsafeError, + `+${row.idempotency_ttl_seconds ?? 86400} seconds`, + row.tenant_id ?? "", + row.idempotency_key, + row.actor_id, + row.object_type, + row.record_id ?? "", + row.action_name + ); + } + } + return { requeued, failed }; + })(); +} + +export function claimOperationRun( + db: AppDatabase, + leaseOwner: string, + leaseSeconds = 60 +): OperationRunRow | null { + ensureOperationRunTables(db); + recoverLeasedOperationRuns(db); + return db.transaction(() => { + const candidate = db + .prepare( + `SELECT id FROM kernel_operation_runs + WHERE status IN ('pending', 'retrying') + AND (next_attempt_at IS NULL OR next_attempt_at <= datetime('now')) + AND (lease_expires_at IS NULL OR lease_expires_at <= datetime('now')) + ORDER BY created_at ASC LIMIT 1` + ) + .get() as { id: string } | undefined; + if (!candidate) return null; + const claimed = db + .prepare( + `UPDATE kernel_operation_runs + SET status='running', attempt=attempt+1, lease_owner=?, + lease_expires_at=datetime('now', ?), updated_at=datetime('now') + WHERE id=? AND status IN ('pending', 'retrying') + AND (lease_expires_at IS NULL OR lease_expires_at <= datetime('now'))` + ) + .run(leaseOwner, `+${leaseSeconds} seconds`, candidate.id); + if (claimed.changes !== 1) return null; + return db + .prepare(`SELECT * FROM kernel_operation_runs WHERE id=?`) + .get(candidate.id) as OperationRunRow; + })(); +} + +export type TenantDatabaseProvider = () => Array<{ + tenantId: string; + db: AppDatabase; +}>; + +export class OperationRunWorker { + private readonly workerId = randomUUID(); + private timer: ReturnType | null = null; + private running = false; + + constructor( + private readonly databases: TenantDatabaseProvider, + private readonly execute: ( + db: AppDatabase, + run: OperationRunRow, + leaseOwner: string + ) => Promise, + private readonly intervalMs = 250 + ) {} + + start(): void { + if (this.timer) return; + void this.tick(); + this.timer = setInterval(() => void this.tick(), this.intervalMs); + this.timer.unref?.(); + } + + stop(): void { + if (this.timer) clearInterval(this.timer); + this.timer = null; + } + + async drainOnce(): Promise { + let processed = 0; + for (const database of this.databases()) { + try { + const db = database.db; + const run = claimOperationRun(db, this.workerId); + if (!run) continue; + await this.execute(db, run, this.workerId); + processed += 1; + } catch (error) { + console.warn( + `[kernel-worker] tenant ${database.tenantId} failed:`, + error instanceof Error ? error.message : error + ); + } + } + return processed; + } + + private async tick(): Promise { + if (this.running) return; + this.running = true; + try { + await this.drainOnce(); + } catch (error) { + console.warn( + "[kernel-worker] tick failed:", + error instanceof Error ? error.message : error + ); + } finally { + this.running = false; + } + } +} diff --git a/apps/bridge/src/kernel/protocol-exceptions.ts b/apps/bridge/src/kernel/protocol-exceptions.ts index fbdca2b..dd9a885 100644 --- a/apps/bridge/src/kernel/protocol-exceptions.ts +++ b/apps/bridge/src/kernel/protocol-exceptions.ts @@ -15,11 +15,32 @@ export const PROTOCOL_EXCEPTIONS: readonly ProtocolException[] = [ authenticatedDomainMutations: "none", }, { - id: "authentication", - methods: ["POST", "GET"], - pathPattern: "/api/auth/*", - rationale: "Session cookies, signup/login bootstrap, and OAuth callbacks.", - authenticatedDomainMutations: "kernel-delegated", + id: "authentication-login", + methods: ["POST"], + pathPattern: "/api/auth/login", + rationale: "Credential verification and session-cookie establishment are authentication transport.", + authenticatedDomainMutations: "none", + }, + { + id: "authentication-logout", + methods: ["POST"], + pathPattern: "/api/auth/logout", + rationale: "Session-cookie invalidation is authentication transport.", + authenticatedDomainMutations: "none", + }, + { + id: "analytics-read-query", + methods: ["POST"], + pathPattern: "/api/analytics/timeseries/query", + rationale: "Read-only analytics query uses POST for a structured query body and performs no mutation.", + authenticatedDomainMutations: "none", + }, + { + id: "federation-command-transport", + methods: ["POST"], + pathPattern: "/api/federation/sc/:", + rationale: "Authenticated external Sierra Chart command transport performs no local durable mutation.", + authenticatedDomainMutations: "none", }, { id: "websocket", @@ -29,23 +50,23 @@ export const PROTOCOL_EXCEPTIONS: readonly ProtocolException[] = [ authenticatedDomainMutations: "none", }, { - id: "streams", - methods: ["GET", "POST"], - pathPattern: "/api/*/stream", - rationale: "Streaming transport for an authorized OperationRun.", + id: "dm-binary-upload", + methods: ["POST"], + pathPattern: "/api/dm/uploads", + rationale: "Multipart and binary transfer cannot be represented as JSON Records.", authenticatedDomainMutations: "kernel-delegated", }, { - id: "binary-transfer", - methods: ["GET", "POST"], - pathPattern: "/api/*/(upload|download)", - rationale: "Multipart and binary transfer cannot be represented as JSON Records.", - authenticatedDomainMutations: "kernel-delegated", + id: "dm-binary-download", + methods: ["GET"], + pathPattern: "/api/dm/blobs/:", + rationale: "Authorized binary response streams bytes from the DM blob store.", + authenticatedDomainMutations: "none", }, { id: "ephemeral-presence", methods: ["POST"], - pathPattern: "/api/dm/*/typing", + pathPattern: "/api/dm/conversations/:/typing", rationale: "Ephemeral typing signal with no durable domain mutation.", authenticatedDomainMutations: "none", }, diff --git a/apps/bridge/src/kernel/record-api.ts b/apps/bridge/src/kernel/record-api.ts index 58a81d4..4bf611e 100644 --- a/apps/bridge/src/kernel/record-api.ts +++ b/apps/bridge/src/kernel/record-api.ts @@ -33,6 +33,11 @@ import { listNativeRecords, updateNativeRecord, } from "./native-storage.js"; +import { + ensureOperationRunTables, + recoverLeasedOperationRuns, + type OperationRunRow, +} from "./operation-run-worker.js"; export class KernelError extends Error { status: number; @@ -98,13 +103,34 @@ function withDataContext( return { ...ctx, data: { - tenantDb: db, - coreDb: getCoreDb(), + tenantDb: ctx.data?.tenantDb ?? db, + coreDb: ctx.data?.coreDb ?? getCoreDb(), declaredDatabase: def.database ?? "tenant", }, }; } +function declaredDatabase( + db: AppDatabase, + def: ObjectTypeDef, + ctx?: OperationContext +): AppDatabase { + if (def.database === "core") return ctx?.data?.coreDb ?? getCoreDb(); + return ctx?.data?.tenantDb ?? db; +} + +function auditDatabaseForObject( + db: AppDatabase, + objectType: string, + ctx: OperationContext +): AppDatabase { + try { + return declaredDatabase(db, requireOt(objectType, ctx), ctx); + } catch { + return db; + } +} + function auditMutation( db: AppDatabase, ctx: OperationContext, @@ -142,18 +168,38 @@ function auditMutation( `INSERT INTO platform_action_log (agent_id, action, scope, payload_hash, result) VALUES (?, ?, ?, ?, ?)` ).run(agentId, action, ctx.tenantId ?? null, payloadHash, result); - insertEvent(db, { - id: randomUUID(), - type: "platform.action", - actorAgentId: agentId, - subject: ctx.tenantId ?? null, - payload: { - action, - result, - payloadHash, - requestId: ctx.requestId, - }, - }); + const eventPayload = { + action, + result, + payloadHash, + requestId: ctx.requestId, + }; + const eventColumns = new Set( + (db.prepare(`PRAGMA table_info(events)`).all() as Array<{ name: string }>).map( + (column) => column.name + ) + ); + if (eventColumns.has("seq")) { + insertEvent(db, { + id: randomUUID(), + type: "platform.action", + actorAgentId: agentId, + subject: ctx.tenantId ?? null, + payload: eventPayload, + }); + } else { + db.prepare( + `INSERT INTO events + (id, type, actor_kind, actor_id, tenant_id, payload_json) + VALUES (?, 'platform.action', ?, ?, ?, ?)` + ).run( + randomUUID(), + ctx.agentId ? "agent" : ctx.userId ? "user" : "system", + ctx.agentId ?? ctx.userId ?? null, + ctx.tenantId ?? null, + JSON.stringify(eventPayload) + ); + } } function stableJson(value: unknown): string { @@ -231,25 +277,44 @@ function projectRow( const field = fields.get(name); if (field && !field.secret) data[name] = value; } - return { id: String(policyRow.id), objectType: def.name, data }; + return { + id: String(policyRow.id), + objectType: def.name, + data, + version: resourceVersion(policyRow, def.versionField), + }; } -function resourceVersion(row: RecordRow | null | undefined): string | undefined { +function resourceVersion( + row: RecordRow | null | undefined, + versionField?: string +): string | undefined { const value = + row?.version ?? + (versionField ? row?.data[versionField] : undefined) ?? row?.data.version ?? row?.data.updated_at ?? row?.data.revision ?? undefined; - return value == null ? undefined : String(value); + if (value == null) { + return row + ? inputHash({ id: row.id, data: row.data }).slice(0, 32) + : undefined; + } + const opaque = String(value); + return /^[\x21\x23-\x7e]+$/.test(opaque) + ? opaque + : inputHash(opaque).slice(0, 32); } function requireExpectedVersion( row: RecordRow | null | undefined, - ctx: OperationContext + ctx: OperationContext, + versionField?: string ): void { if (!ctx.expectedVersion) return; const expected = ctx.expectedVersion.replace(/^W\//, "").replace(/^"|"$/g, ""); - const actual = resourceVersion(row); + const actual = resourceVersion(row, versionField); if (!actual || actual !== expected) { throw new KernelError(412, "Resource version does not match If-Match", { code: "KERNEL_VERSION_CONFLICT", @@ -465,7 +530,7 @@ export function listRecords( const adapter = getRecordAdapter(def.storage.adapterId); if (!adapter?.list) throw new KernelError(405, `list is not supported for ${objectType}`); authorize(adapter, "list", def, ctx); - const result = adapter.list(db, def, query, ctx); + const result = adapter.list(declaredDatabase(db, def, ctx), def, query, ctx); return { ...result, records: result.records.map((row) => projectRow(def, row, ctx, adapter)), @@ -496,7 +561,7 @@ export function getRecord( if (def.storage.kind === "adapter") { const adapter = getRecordAdapter(def.storage.adapterId); if (!adapter?.get) throw new KernelError(405, `get is not supported for ${objectType}`); - row = adapter.get(db, def, id, ctx); + row = adapter.get(declaredDatabase(db, def, ctx), def, id, ctx); authorize(adapter, "get", def, ctx, row); if (row) row = projectRow(def, row, ctx, adapter); } else if (def.storage.kind === "native") { @@ -525,9 +590,15 @@ function createRecordImpl( const adapter = getRecordAdapter(def.storage.adapterId); if (!adapter?.create) throw new KernelError(405, `create is not supported for ${objectType}`); authorize(adapter, "create", def, ctx); - const row = db.transaction(() => { - const created = adapter.create!(db, def, validated, ctx); - auditMutation(db, ctx, `object.create.${objectType}`, { + const ownershipDb = declaredDatabase(db, def, ctx); + const row = ownershipDb.transaction(() => { + const created = adapter.create!( + ownershipDb, + def, + validated, + ctx + ); + auditMutation(ownershipDb, ctx, `object.create.${objectType}`, { recordId: created.id, }); return created; @@ -560,10 +631,7 @@ function createRecordImpl( } } catch (err) { if (err && typeof err === "object" && "status" in err) { - throw new KernelError( - Number((err as { status: number }).status), - err instanceof Error ? err.message : "error" - ); + throw normalizeKernelError(err); } throw err; } @@ -580,7 +648,7 @@ export function createRecord( return createRecordImpl(db, objectType, data, ctx); } catch (error) { throw auditKernelFailure( - db, + auditDatabaseForObject(db, objectType, ctx), ctx, `object.create.${objectType}`, { data }, @@ -605,12 +673,20 @@ function updateRecordImpl( if (def.storage.kind === "adapter") { const adapter = getRecordAdapter(def.storage.adapterId); if (!adapter?.update) throw new KernelError(405, `update is not supported for ${objectType}`); - const current = adapter.get?.(db, def, id, ctx) ?? null; - authorize(adapter, "update", def, ctx, current); - requireExpectedVersion(current, ctx); - const row = db.transaction(() => { - const updated = adapter.update!(db, def, id, validated, ctx); - auditMutation(db, ctx, `object.update.${objectType}`, { recordId: id }); + const ownershipDb = declaredDatabase(db, def, ctx); + const row = ownershipDb.transaction(() => { + const current = + adapter.get?.(ownershipDb, def, id, ctx) ?? null; + authorize(adapter, "update", def, ctx, current); + requireExpectedVersion(current, ctx, def.versionField); + const updated = adapter.update!( + ownershipDb, + def, + id, + validated, + ctx + ); + auditMutation(ownershipDb, ctx, `object.update.${objectType}`, { recordId: id }); return updated; })(); ctx.bus?.emit("object.record.updated", { @@ -623,9 +699,14 @@ function updateRecordImpl( return projectRow(def, row, ctx, adapter); } if (def.storage.kind === "native") { - requireExpectedVersion(getNativeRecord(db, def, id), ctx); const row = db.transaction(() => { - const updated = updateNativeRecord(db, def, id, validated); + const updated = updateNativeRecord( + db, + def, + id, + validated, + ctx.expectedVersion + ); auditMutation(db, ctx, `object.update.${objectType}`, { recordId: id }); return updated; })(); @@ -640,10 +721,7 @@ function updateRecordImpl( } } catch (err) { if (err && typeof err === "object" && "status" in err) { - throw new KernelError( - Number((err as { status: number }).status), - err instanceof Error ? err.message : "error" - ); + throw normalizeKernelError(err); } throw err; } @@ -661,7 +739,7 @@ export function updateRecord( return updateRecordImpl(db, objectType, id, data, ctx); } catch (error) { throw auditKernelFailure( - db, + auditDatabaseForObject(db, objectType, ctx), ctx, `object.update.${objectType}`, { recordId: id, data }, @@ -684,12 +762,14 @@ function deleteRecordImpl( if (def.storage.kind === "adapter") { const adapter = getRecordAdapter(def.storage.adapterId); if (!adapter?.delete) throw new KernelError(405, `delete is not supported for ${objectType}`); - const current = adapter.get?.(db, def, id, ctx) ?? null; - authorize(adapter, "delete", def, ctx, current); - requireExpectedVersion(current, ctx); - db.transaction(() => { - adapter.delete!(db, def, id, ctx); - auditMutation(db, ctx, `object.delete.${objectType}`, { recordId: id }); + const ownershipDb = declaredDatabase(db, def, ctx); + ownershipDb.transaction(() => { + const current = + adapter.get?.(ownershipDb, def, id, ctx) ?? null; + authorize(adapter, "delete", def, ctx, current); + requireExpectedVersion(current, ctx, def.versionField); + adapter.delete!(ownershipDb, def, id, ctx); + auditMutation(ownershipDb, ctx, `object.delete.${objectType}`, { recordId: id }); })(); ctx.bus?.emit("object.record.deleted", { objectType, @@ -701,9 +781,8 @@ function deleteRecordImpl( return; } if (def.storage.kind === "native") { - requireExpectedVersion(getNativeRecord(db, def, id), ctx); db.transaction(() => { - deleteNativeRecord(db, def, id); + deleteNativeRecord(db, def, id, ctx.expectedVersion); auditMutation(db, ctx, `object.delete.${objectType}`, { recordId: id }); })(); ctx.bus?.emit("object.record.deleted", { @@ -717,10 +796,7 @@ function deleteRecordImpl( } } catch (err) { if (err && typeof err === "object" && "status" in err) { - throw new KernelError( - Number((err as { status: number }).status), - err instanceof Error ? err.message : "error" - ); + throw normalizeKernelError(err); } throw err; } @@ -737,7 +813,7 @@ export function deleteRecord( deleteRecordImpl(db, objectType, id, ctx); } catch (error) { throw auditKernelFailure( - db, + auditDatabaseForObject(db, objectType, ctx), ctx, `object.delete.${objectType}`, { recordId: id }, @@ -749,6 +825,7 @@ export function deleteRecord( const confirmationGrants = new Map< string, { + tenantId: string; actorId: string; objectType: string; recordId: string; @@ -765,38 +842,7 @@ function actorId(ctx: OperationContext): string { } function ensureActionTables(db: AppDatabase): void { - db.exec(` - CREATE TABLE IF NOT EXISTS kernel_action_idempotency ( - key TEXT NOT NULL, - actor_id TEXT NOT NULL, - object_type TEXT NOT NULL, - record_id TEXT NOT NULL, - action_name TEXT NOT NULL, - input_hash TEXT NOT NULL, - status TEXT NOT NULL, - result_json TEXT, - expires_at TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')), - PRIMARY KEY (key, actor_id, object_type, record_id, action_name) - ); - CREATE TABLE IF NOT EXISTS kernel_operation_runs ( - id TEXT PRIMARY KEY, - tenant_id TEXT, - actor_id TEXT NOT NULL, - object_type TEXT NOT NULL, - record_id TEXT, - action_name TEXT NOT NULL, - status TEXT NOT NULL, - progress REAL, - result_json TEXT, - error_code TEXT, - error_message TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')), - finished_at TEXT - ); - `); + ensureOperationRunTables(db); } function requireActionPermission( @@ -815,6 +861,28 @@ function requireActionPermission( code: "KERNEL_ACTION_FORBIDDEN", }); } + if ( + def.accessPolicy === "platform-admin" && + !ctx.isAdmin && + ctx.role !== "intelligence" + ) { + throw new KernelError(403, "Platform administrator access required", { + code: "KERNEL_ADMIN_REQUIRED", + }); + } + if ( + ["tenant-member", "tenant-local"].includes(def.accessPolicy ?? "") && + !ctx.tenantId + ) { + throw new KernelError(403, "Tenant membership context required", { + code: "KERNEL_TENANT_REQUIRED", + }); + } + if (def.accessPolicy === "user-private" && !ctx.userId) { + throw new KernelError(403, "User principal required", { + code: "KERNEL_USER_REQUIRED", + }); + } } function requireConfirmation( @@ -837,10 +905,14 @@ function requireConfirmation( return; } const hash = inputHash(input); + for (const [grantId, candidate] of confirmationGrants) { + if (candidate.expiresAt <= Date.now()) confirmationGrants.delete(grantId); + } const id = ctx.confirmationId; const grant = id ? confirmationGrants.get(id) : undefined; if ( grant && + grant.tenantId === (ctx.tenantId ?? "") && grant.actorId === actorId(ctx) && grant.objectType === objectType && grant.recordId === recordId && @@ -854,6 +926,7 @@ function requireConfirmation( } const confirmationId = randomUUID(); confirmationGrants.set(confirmationId, { + tenantId: ctx.tenantId ?? "", actorId: actorId(ctx), objectType, recordId, @@ -874,7 +947,8 @@ async function executeRecordActionImpl( id: string, actionName: string, input: RecordData, - ctx: OperationContext + ctx: OperationContext, + expectedTarget: "record" | "collection" ): Promise { ctx = withKernelEventBus(requireContext(ctx)); const def = requireOt(objectType, ctx); @@ -883,6 +957,22 @@ async function executeRecordActionImpl( if (!action) { throw new KernelError(404, `Unknown ${objectType} action: ${actionName}`); } + if (action.target !== expectedTarget) { + throw new KernelError( + 405, + `${objectType}.${actionName} is a ${action.target ?? "misconfigured"} action`, + { code: "KERNEL_ACTION_TARGET_MISMATCH" } + ); + } + if (action.deprecated) { + throw new KernelError(410, action.deprecated.message, { + code: "KERNEL_ACTION_DEPRECATED", + details: { + since: action.deprecated.since, + replacement: action.deprecated.replacement, + }, + }); + } if (def.storage.kind !== "adapter") { throw new KernelError(405, `${objectType} actions require a service adapter`); } @@ -892,8 +982,15 @@ async function executeRecordActionImpl( throw new KernelError(501, `${objectType} action is not implemented: ${actionName}`); } requireActionPermission(action, def, ctx); - const current = action.target === "collection" ? null : adapter?.get?.(db, def, id, ctx) ?? null; - if (action.target !== "collection" && !current) { + // All kernel receipts, audit rows, and outbox events live with the object + // type's authoritative database. A caller tenant transaction cannot cover a + // core adapter write. + const ownershipDb = declaredDatabase(db, def, ctx); + const current = + expectedTarget === "collection" + ? null + : adapter?.get?.(ownershipDb, def, id, ctx) ?? null; + if (expectedTarget === "record" && !current) { throw new KernelError(404, `${objectType} record not found: ${id}`); } authorize(adapter, "action", def, ctx, current, action); @@ -902,9 +999,15 @@ async function executeRecordActionImpl( code: "KERNEL_IF_MATCH_REQUIRED", }); } - if (action.concurrency?.required) requireExpectedVersion(current, ctx); + if (action.concurrency?.required) { + requireExpectedVersion( + current, + ctx, + action.concurrency.versionField ?? def.versionField + ); + } validateSchema(action.inputSchema, input, `${objectType}.${actionName} input`); - ensureActionTables(db); + ensureActionTables(ownershipDb); const hash = inputHash(input); const idem = action.idempotency; @@ -914,13 +1017,41 @@ async function executeRecordActionImpl( }); } if (ctx.idempotencyKey) { - const existing = db + ownershipDb.prepare( + `DELETE FROM kernel_action_idempotency + WHERE tenant_id=? AND key=? AND actor_id=? AND object_type=? + AND record_id=? AND action_name=? + AND status IN ('succeeded', 'failed', 'cancelled') + AND expires_at IS NOT NULL AND expires_at <= datetime('now')` + ).run( + ctx.tenantId ?? "", + ctx.idempotencyKey, + actorId(ctx), + objectType, + id, + actionName + ); + const existing = ownershipDb .prepare( - `SELECT input_hash, status, result_json FROM kernel_action_idempotency - WHERE key=? AND actor_id=? AND object_type=? AND record_id=? AND action_name=?` + `SELECT input_hash, status, result_json, error_json + FROM kernel_action_idempotency + WHERE tenant_id=? AND key=? AND actor_id=? AND object_type=? + AND record_id=? AND action_name=?` ) - .get(ctx.idempotencyKey, actorId(ctx), objectType, id, actionName) as - | { input_hash: string; status: string; result_json: string | null } + .get( + ctx.tenantId ?? "", + ctx.idempotencyKey, + actorId(ctx), + objectType, + id, + actionName + ) as + | { + input_hash: string; + status: string; + result_json: string | null; + error_json: string | null; + } | undefined; if (existing) { if (existing.input_hash !== hash) { @@ -931,6 +1062,27 @@ async function executeRecordActionImpl( if (existing.status === "succeeded") { return existing.result_json ? JSON.parse(existing.result_json) : null; } + if (existing.status === "pending") { + if (existing.result_json) return JSON.parse(existing.result_json); + throw new KernelError(409, "Action is being durably queued", { + code: "KERNEL_ACTION_IN_PROGRESS", + retryable: true, + }); + } + if (existing.status === "failed") { + throw new KernelError(409, "Previous idempotent action failed", { + code: "KERNEL_IDEMPOTENT_ACTION_FAILED", + details: existing.error_json ? JSON.parse(existing.error_json) : undefined, + }); + } + if (existing.status === "cancelled") { + throw new KernelError(409, "Previous idempotent action was cancelled", { + code: "KERNEL_IDEMPOTENT_ACTION_CANCELLED", + details: existing.error_json + ? JSON.parse(existing.error_json) + : undefined, + }); + } throw new KernelError(409, "Action already in progress", { code: "KERNEL_ACTION_IN_PROGRESS", retryable: true, @@ -942,21 +1094,28 @@ async function executeRecordActionImpl( id, input, ctx, - resourceVersion(current) - ); - db.prepare( - `INSERT INTO kernel_action_idempotency - (key, actor_id, object_type, record_id, action_name, input_hash, status, expires_at) - VALUES (?, ?, ?, ?, ?, ?, 'running', datetime('now', ?))` - ).run( - ctx.idempotencyKey, - actorId(ctx), - objectType, - id, - actionName, - hash, - `+${idem?.ttlSeconds ?? 86400} seconds` + resourceVersion( + current, + action.concurrency?.versionField ?? def.versionField + ) ); + if (action.execution !== "async") { + ownershipDb.prepare( + `INSERT INTO kernel_action_idempotency + (tenant_id, key, actor_id, object_type, record_id, action_name, + input_hash, status, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 'running', datetime('now', ?))` + ).run( + ctx.tenantId ?? "", + ctx.idempotencyKey, + actorId(ctx), + objectType, + id, + actionName, + hash, + `+${idem?.ttlSeconds ?? 86400} seconds` + ); + } } else { requireConfirmation( action, @@ -964,120 +1123,92 @@ async function executeRecordActionImpl( id, input, ctx, - resourceVersion(current) + resourceVersion( + current, + action.concurrency?.versionField ?? def.versionField + ) ); } if (action.execution === "async") { const operationRunId = randomUUID(); - const controller = new AbortController(); - operationControllers.set(operationRunId, controller); - db.prepare( - `INSERT INTO kernel_operation_runs - (id, tenant_id, actor_id, object_type, record_id, action_name, status) - VALUES (?, ?, ?, ?, ?, ?, 'pending')` - ).run(operationRunId, ctx.tenantId ?? null, actorId(ctx), objectType, id || null, actionName); - queueMicrotask(async () => { - try { - db.prepare( - `UPDATE kernel_operation_runs SET status='running', updated_at=datetime('now') WHERE id=?` - ).run(operationRunId); - const raw = await handler(db, def, id, input, { - ...ctx, - signal: controller.signal, - }); - if (controller.signal.aborted) return; - validateSchema(action.outputSchema, raw, `${objectType}.${actionName} output`); - const result = redactActionOutput(raw, action); - db.transaction(() => { - db.prepare( - `UPDATE kernel_operation_runs - SET status='succeeded', result_json=?, updated_at=datetime('now'), finished_at=datetime('now') - WHERE id=?` - ).run(JSON.stringify(result ?? null), operationRunId); - if (ctx.idempotencyKey) { - db.prepare( - `UPDATE kernel_action_idempotency - SET status='succeeded', result_json=?, updated_at=datetime('now') - WHERE key=? AND actor_id=? AND object_type=? AND record_id=? AND action_name=?` - ).run( - JSON.stringify({ status: "accepted", operationRunId }), - ctx.idempotencyKey, - actorId(ctx), - objectType, - id, - actionName - ); - } - auditMutation(db, ctx, `object.action.${objectType}.${actionName}`, { - recordId: id, - operationRunId, - input: redactActionInput(input, action), - }); - appendDeclaredActionEvents( - db, - ctx, - action, - objectType, - id, - result - ); - })(); - emitAction(ctx, objectType, id, actionName, operationRunId); - } catch (error) { - if (controller.signal.aborted) return; - const normalized = normalizeKernelError(error); - db.transaction(() => { - db.prepare( - `UPDATE kernel_operation_runs - SET status='failed', error_code=?, error_message=?, updated_at=datetime('now'), finished_at=datetime('now') - WHERE id=?` - ).run(normalized.code, normalized.message, operationRunId); - if (ctx.idempotencyKey) { - db.prepare( - `UPDATE kernel_action_idempotency - SET status='failed', updated_at=datetime('now') - WHERE key=? AND actor_id=? AND object_type=? AND record_id=? AND action_name=?` - ).run( - ctx.idempotencyKey, - actorId(ctx), - objectType, - id, - actionName - ); - } - auditMutation( - db, - ctx, - `object.action.${objectType}.${actionName}`, - { recordId: id, operationRunId, errorCode: normalized.code }, - "error" - ); - })(); - } finally { - operationControllers.delete(operationRunId); + const accepted = { status: "accepted", operationRunId } as const; + ownershipDb.transaction(() => { + if (ctx.idempotencyKey) { + ownershipDb.prepare( + `INSERT INTO kernel_action_idempotency + (tenant_id, key, actor_id, object_type, record_id, action_name, + input_hash, status, result_json, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, NULL)` + ).run( + ctx.tenantId ?? "", + ctx.idempotencyKey, + actorId(ctx), + objectType, + id, + actionName, + hash, + JSON.stringify(accepted) + ); } - }); - return { status: "accepted", operationRunId }; + ownershipDb.prepare( + `INSERT INTO kernel_operation_runs + (id, tenant_id, actor_id, object_type, record_id, action_name, + input_json, context_json, idempotency_key, idempotency_ttl_seconds, + status, max_attempts, timeout_ms, cancellable, recovery_safe) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?)` + ).run( + operationRunId, + ctx.tenantId ?? null, + actorId(ctx), + objectType, + id || null, + actionName, + JSON.stringify(input), + JSON.stringify({ + tenantId: ctx.tenantId, + userId: ctx.userId, + isAdmin: ctx.isAdmin, + role: ctx.role, + agentId: ctx.agentId, + source: ctx.source, + requestId: ctx.requestId, + idempotencyKey: ctx.idempotencyKey, + installedPluginIds: [...(ctx.installedPluginIds ?? [])], + }), + ctx.idempotencyKey ?? null, + ctx.idempotencyKey ? (idem?.ttlSeconds ?? 86400) : null, + action.retry?.maxAttempts ?? 1, + action.timeoutMs ?? null, + action.cancellable ? 1 : 0, + (ctx.idempotencyKey && action.idempotency) || + (action.retry?.maxAttempts ?? 1) > 1 + ? 1 + : 0 + ); + })(); + return accepted; } try { - const raw = await handler(db, def, id, input, ctx); + const raw = await handler(ownershipDb, def, id, input, ctx); validateSchema(action.outputSchema, raw, `${objectType}.${actionName} output`); const result = redactActionOutput(raw, action); - db.transaction(() => { - auditMutation(db, ctx, `object.action.${objectType}.${actionName}`, { + ownershipDb.transaction(() => { + auditMutation(ownershipDb, ctx, `object.action.${objectType}.${actionName}`, { recordId: id, input: redactActionInput(input, action), }); - appendDeclaredActionEvents(db, ctx, action, objectType, id, result); + appendDeclaredActionEvents(ownershipDb, ctx, action, objectType, id, result); if (ctx.idempotencyKey) { - db.prepare( + ownershipDb.prepare( `UPDATE kernel_action_idempotency SET status='succeeded', result_json=?, updated_at=datetime('now') - WHERE key=? AND actor_id=? AND object_type=? AND record_id=? AND action_name=?` + WHERE tenant_id=? AND key=? AND actor_id=? AND object_type=? + AND record_id=? AND action_name=?` ).run( JSON.stringify(result ?? null), + ctx.tenantId ?? "", ctx.idempotencyKey, actorId(ctx), objectType, @@ -1089,13 +1220,44 @@ async function executeRecordActionImpl( emitAction(ctx, objectType, id, actionName); return result; } catch (error) { + let normalized = normalizeKernelError(error); + const errorPayload = { + code: normalized.code, + message: normalized.message, + retryable: normalized.retryable, + details: normalized.details, + }; + if (action.errorSchema) { + try { + validateSchema( + action.errorSchema, + errorPayload, + `${objectType}.${actionName} error` + ); + } catch (schemaError) { + normalized = new KernelError(500, "Action returned an invalid error", { + code: "KERNEL_ERROR_SCHEMA_INVALID", + details: normalizeKernelError(schemaError).details, + }); + } + } if (ctx.idempotencyKey) { - db.prepare( - `UPDATE kernel_action_idempotency SET status='failed', updated_at=datetime('now') - WHERE key=? AND actor_id=? AND object_type=? AND record_id=? AND action_name=?` - ).run(ctx.idempotencyKey, actorId(ctx), objectType, id, actionName); + ownershipDb.prepare( + `UPDATE kernel_action_idempotency + SET status='failed', error_json=?, updated_at=datetime('now') + WHERE tenant_id=? AND key=? AND actor_id=? AND object_type=? + AND record_id=? AND action_name=?` + ).run( + JSON.stringify(errorPayload), + ctx.tenantId ?? "", + ctx.idempotencyKey, + actorId(ctx), + objectType, + id, + actionName + ); } - throw normalizeKernelError(error); + throw normalized; } } @@ -1114,14 +1276,16 @@ export async function executeRecordAction( id, actionName, input, - ctx + ctx, + "record" ); } catch (error) { const normalized = normalizeKernelError(error); try { - db.transaction(() => { + const auditDb = declaredDatabase(db, requireOt(objectType, ctx), ctx); + auditDb.transaction(() => { auditMutation( - db, + auditDb, requireContext(ctx), `object.action.${objectType}.${actionName}`, { @@ -1141,6 +1305,314 @@ export async function executeRecordAction( } } +export async function processClaimedOperationRun( + db: AppDatabase, + run: OperationRunRow, + leaseOwner: string +): Promise { + ensureActionTables(db); + let stored: Partial & { installedPluginIds?: string[] }; + try { + stored = JSON.parse(run.context_json || "{}") as Partial & { + installedPluginIds?: string[]; + }; + } catch (error) { + finishOperationFailure( + db, + run, + leaseOwner, + undefined, + createSystemOperationContext({ tenantId: run.tenant_id ?? undefined }), + new KernelError(500, "Queued action context is corrupt", { + code: "KERNEL_OPERATION_CONTEXT_CORRUPT", + details: error instanceof Error ? error.message : error, + }) + ); + return; + } + let ctx: OperationContext = + !stored.source || stored.source === "system" + ? createSystemOperationContext(stored) + : { + role: stored.role ?? "viewer", + source: stored.source ?? "system", + tenantId: stored.tenantId ?? run.tenant_id ?? undefined, + userId: stored.userId, + isAdmin: stored.isAdmin, + agentId: stored.agentId, + requestId: stored.requestId, + idempotencyKey: stored.idempotencyKey ?? run.idempotency_key ?? undefined, + installedPluginIds: new Set(stored.installedPluginIds ?? []), + }; + ctx = withKernelEventBus(ctx); + let def: ObjectTypeDef; + try { + def = requireOt(run.object_type, ctx); + ctx = withDataContext(db, def, ctx); + } catch (error) { + finishOperationFailure(db, run, leaseOwner, undefined, ctx, error); + return; + } + const action = def.actions?.find((item) => item.name === run.action_name); + const adapter = + def.storage.kind === "adapter" + ? getRecordAdapter(def.storage.adapterId) + : undefined; + const handler = adapter?.actions?.[run.action_name]; + if (!action || action.target === "bulk" || !handler) { + const error = new KernelError(501, "Queued action is no longer implemented", { + code: "KERNEL_ACTION_UNAVAILABLE", + }); + finishOperationFailure(db, run, leaseOwner, action, ctx, error); + return; + } + let input: RecordData; + try { + input = JSON.parse(run.input_json || "{}") as RecordData; + } catch (error) { + finishOperationFailure( + db, + run, + leaseOwner, + action, + ctx, + new KernelError(500, "Queued action input is corrupt", { + code: "KERNEL_OPERATION_INPUT_CORRUPT", + details: error instanceof Error ? error.message : error, + }) + ); + return; + } + const controller = new AbortController(); + operationControllers.set(run.id, controller); + let timeout: ReturnType | undefined; + const heartbeat = setInterval(() => { + const renewed = db.prepare( + `UPDATE kernel_operation_runs + SET lease_expires_at=datetime('now', '+60 seconds'), + updated_at=datetime('now') + WHERE id=? AND status='running' AND lease_owner=?` + ).run(run.id, leaseOwner); + if (renewed.changes !== 1) controller.abort(); + }, 20_000); + heartbeat.unref?.(); + try { + const work = Promise.resolve( + handler(declaredDatabase(db, def, ctx), def, run.record_id ?? "", input, { + ...ctx, + signal: controller.signal, + }) + ); + const raw = + run.timeout_ms && run.timeout_ms > 0 + ? await Promise.race([ + work, + new Promise((_, reject) => { + timeout = setTimeout(() => { + controller.abort(); + reject( + new KernelError(504, "Action timed out", { + code: "KERNEL_ACTION_TIMEOUT", + retryable: true, + }) + ); + }, run.timeout_ms!); + timeout.unref?.(); + }), + ]) + : await work; + if (controller.signal.aborted) return; + validateSchema( + action.outputSchema, + raw, + `${run.object_type}.${run.action_name} output` + ); + const result = redactActionOutput(raw, action); + const committed = db.transaction(() => { + const completed = db.prepare( + `UPDATE kernel_operation_runs + SET status='succeeded', result_json=?, error_code=NULL, + error_message=NULL, error_json=NULL, lease_owner=NULL, + lease_expires_at=NULL, updated_at=datetime('now'), + finished_at=datetime('now') + WHERE id=? AND status='running' AND lease_owner=?` + ).run(JSON.stringify(result ?? null), run.id, leaseOwner); + if (completed.changes !== 1) return false; + if (run.idempotency_key) { + const finalized = db.prepare( + `UPDATE kernel_action_idempotency + SET status='succeeded', result_json=?, error_json=NULL, + expires_at=datetime('now', ?), updated_at=datetime('now') + WHERE tenant_id=? AND key=? AND actor_id=? AND object_type=? + AND record_id=? AND action_name=? AND status='pending'` + ).run( + JSON.stringify(result ?? null), + `+${run.idempotency_ttl_seconds ?? 86400} seconds`, + run.tenant_id ?? "", + run.idempotency_key, + run.actor_id, + run.object_type, + run.record_id ?? "", + run.action_name + ); + if (finalized.changes !== 1) { + throw new KernelError( + 500, + "OperationRun idempotency record could not be finalized", + { code: "KERNEL_IDEMPOTENCY_FINALIZE_FAILED" } + ); + } + } + auditMutation( + db, + ctx, + `object.action.${run.object_type}.${run.action_name}`, + { + recordId: run.record_id, + operationRunId: run.id, + input: redactActionInput(input, action), + } + ); + appendDeclaredActionEvents( + db, + ctx, + action, + run.object_type, + run.record_id ?? "", + result + ); + return true; + })(); + if (!committed) return; + emitAction( + ctx, + run.object_type, + run.record_id ?? "", + run.action_name, + run.id + ); + } catch (error) { + if (!controller.signal.aborted || error instanceof KernelError) { + finishOperationFailure(db, run, leaseOwner, action, ctx, error); + } + } finally { + if (timeout) clearTimeout(timeout); + clearInterval(heartbeat); + operationControllers.delete(run.id); + } +} + +function finishOperationFailure( + db: AppDatabase, + run: OperationRunRow, + leaseOwner: string, + action: ActionDef | undefined, + ctx: OperationContext, + error: unknown +): void { + let normalized = normalizeKernelError(error); + let payload = { + code: normalized.code, + message: normalized.message, + retryable: normalized.retryable, + details: normalized.details, + }; + if (action?.errorSchema) { + try { + validateSchema( + action.errorSchema, + payload, + `${run.object_type}.${run.action_name} error` + ); + } catch (schemaError) { + normalized = new KernelError(500, "Action returned an invalid error", { + code: "KERNEL_ERROR_SCHEMA_INVALID", + details: normalizeKernelError(schemaError).details, + }); + payload = { + code: normalized.code, + message: normalized.message, + retryable: false, + details: normalized.details, + }; + } + } + const retryCodes = action?.retry?.retryableErrorCodes ?? []; + const retryable = + run.attempt < run.max_attempts && + (normalized.retryable || retryCodes.includes(normalized.code)); + const backoffMs = action?.retry?.backoffMs ?? 0; + db.transaction(() => { + const updated = db.prepare( + `UPDATE kernel_operation_runs + SET status=?, error_code=?, error_message=?, error_json=?, + next_attempt_at=${ + retryable ? "datetime('now', ?)" : "NULL" + }, + lease_owner=NULL, lease_expires_at=NULL, updated_at=datetime('now'), + finished_at=${retryable ? "NULL" : "datetime('now')"} + WHERE id=? AND status='running' AND lease_owner=?` + ); + const info = retryable + ? updated.run( + "retrying", + normalized.code, + normalized.message, + JSON.stringify(payload), + `+${Math.max(0, backoffMs) / 1000} seconds`, + run.id, + leaseOwner + ) + : updated.run( + "failed", + normalized.code, + normalized.message, + JSON.stringify(payload), + run.id, + leaseOwner + ); + if (info.changes !== 1) return; + if (!retryable) { + if (run.idempotency_key) { + const finalized = db.prepare( + `UPDATE kernel_action_idempotency + SET status='failed', result_json=NULL, error_json=?, + expires_at=datetime('now', ?), updated_at=datetime('now') + WHERE tenant_id=? AND key=? AND actor_id=? AND object_type=? + AND record_id=? AND action_name=? AND status='pending'` + ).run( + JSON.stringify(payload), + `+${run.idempotency_ttl_seconds ?? 86400} seconds`, + run.tenant_id ?? "", + run.idempotency_key, + run.actor_id, + run.object_type, + run.record_id ?? "", + run.action_name + ); + if (finalized.changes !== 1) { + throw new KernelError( + 500, + "OperationRun idempotency failure could not be finalized", + { code: "KERNEL_IDEMPOTENCY_FINALIZE_FAILED" } + ); + } + } + auditMutation( + db, + ctx, + `object.action.${run.object_type}.${run.action_name}`, + { + recordId: run.record_id, + operationRunId: run.id, + errorCode: normalized.code, + }, + "error" + ); + } + })(); +} + export function cancelOperationRun( db: AppDatabase, operationRunId: string, @@ -1150,10 +1622,22 @@ export function cancelOperationRun( ensureActionTables(db); const row = db .prepare( - `SELECT tenant_id, actor_id, status FROM kernel_operation_runs WHERE id=?` + `SELECT tenant_id, actor_id, object_type, record_id, action_name, + idempotency_key, idempotency_ttl_seconds, status, cancellable + FROM kernel_operation_runs WHERE id=?` ) .get(operationRunId) as - | { tenant_id: string | null; actor_id: string; status: string } + | { + tenant_id: string | null; + actor_id: string; + object_type: string; + record_id: string | null; + action_name: string; + idempotency_key: string | null; + idempotency_ttl_seconds: number | null; + status: string; + cancellable: number; + } | undefined; if (!row) throw new KernelError(404, `OperationRun not found: ${operationRunId}`); if ( @@ -1169,44 +1653,103 @@ export function cancelOperationRun( if (ctx.tenantId && row.tenant_id && ctx.tenantId !== row.tenant_id) { throw new KernelError(404, `OperationRun not found: ${operationRunId}`); } - if (!["pending", "running"].includes(row.status)) return false; + if (!row.cancellable) { + throw new KernelError(409, "This operation is not cancellable", { + code: "KERNEL_OPERATION_NOT_CANCELLABLE", + }); + } + if (!["pending", "running", "retrying"].includes(row.status)) return false; operationControllers.get(operationRunId)?.abort(); - db.transaction(() => { - db.prepare( + return db.transaction(() => { + const errorJson = JSON.stringify({ + code: "KERNEL_OPERATION_CANCELLED", + message: "Operation cancelled", + retryable: false, + }); + const cancelled = db.prepare( `UPDATE kernel_operation_runs - SET status='cancelled', updated_at=datetime('now'), finished_at=datetime('now') - WHERE id=?` - ).run(operationRunId); + SET status='cancelled', error_code='KERNEL_OPERATION_CANCELLED', + error_message='Operation cancelled', error_json=?, + lease_owner=NULL, lease_expires_at=NULL, + updated_at=datetime('now'), finished_at=datetime('now') + WHERE id=? AND status IN ('pending', 'running', 'retrying')` + ).run(errorJson, operationRunId); + if (cancelled.changes !== 1) return false; + if (row.idempotency_key) { + const finalized = db.prepare( + `UPDATE kernel_action_idempotency + SET status='cancelled', result_json=NULL, error_json=?, + expires_at=datetime('now', ?), updated_at=datetime('now') + WHERE tenant_id=? AND key=? AND actor_id=? AND object_type=? + AND record_id=? AND action_name=? AND status='pending'` + ).run( + errorJson, + `+${row.idempotency_ttl_seconds ?? 86400} seconds`, + row.tenant_id ?? "", + row.idempotency_key, + row.actor_id, + row.object_type, + row.record_id ?? "", + row.action_name + ); + if (finalized.changes !== 1) { + throw new KernelError( + 500, + "OperationRun cancellation idempotency could not be finalized", + { code: "KERNEL_IDEMPOTENCY_FINALIZE_FAILED" } + ); + } + } auditMutation(db, ctx, "object.action.OperationRun.cancel", { operationRunId, }); + return true; })(); - return true; } export function recoverInterruptedOperationRuns(db: AppDatabase): number { ensureActionTables(db); - return db - .prepare( - `UPDATE kernel_operation_runs - SET status='failed', - error_code='KERNEL_RESTART_INTERRUPTED', - error_message='Bridge restarted before this operation completed', - updated_at=datetime('now'), - finished_at=datetime('now') - WHERE status IN ('pending', 'running')` - ) - .run().changes; + const recovered = recoverLeasedOperationRuns(db, true); + return recovered.requeued + recovered.failed; } -export function executeCollectionAction( +export async function executeCollectionAction( db: AppDatabase, objectType: string, actionName: string, input: RecordData, ctx: OperationContext ): Promise { - return executeRecordAction(db, objectType, "", actionName, input, ctx); + try { + return await executeRecordActionImpl( + db, + objectType, + "", + actionName, + input, + ctx, + "collection" + ); + } catch (error) { + const normalized = normalizeKernelError(error); + try { + const auditDb = declaredDatabase(db, requireOt(objectType, ctx), ctx); + auditDb.transaction(() => { + auditMutation( + auditDb, + requireContext(ctx), + `object.action.${objectType}.${actionName}`, + { input, errorCode: normalized.code }, + normalized.status === 401 || normalized.status === 403 + ? "denied" + : "error" + ); + })(); + } catch { + // Preserve the operation error if the audit store is unavailable. + } + throw normalized; + } } function emitAction( @@ -1283,10 +1826,20 @@ function redactActionOutput(output: unknown, action: ActionDef): unknown { function normalizeKernelError(error: unknown): KernelError { if (error instanceof KernelError) return error; if (error && typeof error === "object" && "status" in error) { + const value = error as { + status: number; + code?: string; + details?: unknown; + retryable?: boolean; + }; return new KernelError( - Number((error as { status: number }).status), + Number(value.status), error instanceof Error ? error.message : "Kernel operation failed", - { code: "KERNEL_ADAPTER_ERROR" } + { + code: value.code ?? "KERNEL_ADAPTER_ERROR", + details: value.details, + retryable: value.retryable, + } ); } return new KernelError( diff --git a/apps/bridge/src/kernel/registry.ts b/apps/bridge/src/kernel/registry.ts index d72c87b..b0f0c7a 100644 --- a/apps/bridge/src/kernel/registry.ts +++ b/apps/bridge/src/kernel/registry.ts @@ -15,6 +15,11 @@ function resolveRuntimeMetadata(def: ObjectTypeDef): ObjectTypeDef { ? { ...f, fieldType: "Select" as const, options: listPageKinds() } : f ), + actions: def.actions?.map((action) => + action.execution === "async" && action.cancellable == null + ? { ...action, cancellable: false } + : action + ), }; } @@ -30,12 +35,13 @@ export function registerObjectType(def: ObjectTypeDef): void { `ObjectType ${def.name} is owned by ${existing.pluginId ?? "core"}` ); } - byName.set(def.name, def); + byName.set(def.name, resolved); } export function registerObjectTypes(defs: ObjectTypeDef[]): void { - for (const def of defs) { - const errors = validateObjectTypeDef(resolveRuntimeMetadata(def)); + const resolvedDefs = defs.map(resolveRuntimeMetadata); + for (const [index, def] of defs.entries()) { + const errors = validateObjectTypeDef(resolvedDefs[index]!); if (errors.length) { throw new Error(`Invalid ObjectType ${def.name}: ${errors.join("; ")}`); } @@ -46,7 +52,7 @@ export function registerObjectTypes(defs: ObjectTypeDef[]): void { ); } } - for (const def of defs) byName.set(def.name, def); + for (const def of resolvedDefs) byName.set(def.name, def); } export function replaceObjectTypesByPlugin( @@ -57,8 +63,9 @@ export function replaceObjectTypesByPlugin( throw new Error(`All replacement ObjectTypes must be owned by ${pluginId}`); } // Validate the complete replacement before changing the live registry. - for (const def of defs) { - const errors = validateObjectTypeDef(resolveRuntimeMetadata(def)); + const resolvedDefs = defs.map(resolveRuntimeMetadata); + for (const [index, def] of defs.entries()) { + const errors = validateObjectTypeDef(resolvedDefs[index]!); if (errors.length) { throw new Error(`Invalid ObjectType ${def.name}: ${errors.join("; ")}`); } @@ -72,7 +79,7 @@ export function replaceObjectTypesByPlugin( for (const [name, def] of byName) { if (def.pluginId === pluginId) byName.delete(name); } - for (const def of defs) byName.set(def.name, def); + for (const def of resolvedDefs) byName.set(def.name, def); } export function unregisterObjectType(name: string): void { diff --git a/apps/bridge/src/kernel/routes.ts b/apps/bridge/src/kernel/routes.ts index f3ec72c..b7f92dc 100644 --- a/apps/bridge/src/kernel/routes.ts +++ b/apps/bridge/src/kernel/routes.ts @@ -1,7 +1,6 @@ -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { Router, type Request } from "express"; import type { AppDatabase } from "../db.js"; -import { requireTenantRole } from "../services/auth/middleware.js"; import { getCoreDb } from "../core-db.js"; import { installedPluginIdsForTenant } from "../plugins/plugin-install.js"; import { listPageKinds, isRegisteredPageKind } from "./kind-registry.js"; @@ -27,7 +26,6 @@ export function createKernelRouter( ): Router { const router = Router(); const tdb = (req: Request): AppDatabase => req.tenantDb ?? operatorDb; - const requireEditor = requireTenantRole("editor"); const context = (req: Request): OperationContext => ({ tenantId: req.tenantId, userId: req.user?.id, @@ -44,13 +42,11 @@ export function createKernelRouter( ), }); - router.use((req, res, next) => { - if (req.method === "GET" || req.method === "HEAD" || req.method === "OPTIONS") { - next(); - return; - } - requireEditor(req, res, next); - }); + const etag = (value: unknown): string => + `"${createHash("sha256") + .update(JSON.stringify(value)) + .digest("base64url") + .slice(0, 32)}"`; const handleErr = (err: unknown, res: import("express").Response): void => { if (err instanceof KernelError || err instanceof StructureError) { @@ -69,15 +65,17 @@ export function createKernelRouter( }; router.get("/object-types", (req, res) => { - res.json({ objectTypes: listVisibleObjectTypes(context(req)) }); + const payload = { objectTypes: listVisibleObjectTypes(context(req)) }; + res.set("ETag", etag(payload)).json(payload); }); router.get("/kernel/capabilities", (req, res) => { - res.json({ + const payload = { contractVersion: 1, objectTypes: listVisibleObjectTypes(context(req)), protocolExceptions: PROTOCOL_EXCEPTIONS, - }); + }; + res.set("ETag", etag(payload)).json(payload); }); router.get("/object-types/:name", (req, res) => { @@ -88,7 +86,7 @@ export function createKernelRouter( res.status(404).json({ error: "ObjectType not found" }); return; } - res.json(def); + res.set("ETag", etag(def)).json(def); }); router.get("/page-kinds", (_req, res) => { @@ -114,16 +112,15 @@ export function createKernelRouter( req.query.direction === "asc" || req.query.direction === "desc" ? req.query.direction : undefined; - res.json( - listRecords(tdb(req), req.params.objectType, { + const result = listRecords(tdb(req), req.params.objectType, { parentId, limit: Number.isFinite(limit) ? limit : undefined, offset: Number.isFinite(offset) ? offset : undefined, filters, sort: typeof req.query.sort === "string" ? req.query.sort : undefined, direction, - }, context(req)) - ); + }, context(req)); + res.set("ETag", etag(result)).json(result); } catch (err) { handleErr(err, res); } @@ -131,7 +128,13 @@ export function createKernelRouter( router.get("/records/:objectType/:id", (req, res) => { try { - res.json(getRecord(tdb(req), req.params.objectType, req.params.id, context(req))); + const row = getRecord( + tdb(req), + req.params.objectType, + req.params.id, + context(req) + ); + res.set("ETag", `"${row.version}"`).json(row); } catch (err) { handleErr(err, res); } @@ -152,7 +155,7 @@ export function createKernelRouter( throw new KernelError(400, `Unknown page kind: ${String(data.kind)}`); } const row = createRecord(tdb(req), req.params.objectType, data, context(req)); - res.status(201).json(row); + res.set("ETag", `"${row.version}"`).status(201).json(row); } catch (err) { handleErr(err, res); } @@ -172,9 +175,14 @@ export function createKernelRouter( ) { throw new KernelError(400, `Unknown page kind: ${String(data.kind)}`); } - res.json( - updateRecord(tdb(req), req.params.objectType, req.params.id, data, context(req)) + const row = updateRecord( + tdb(req), + req.params.objectType, + req.params.id, + data, + context(req) ); + res.set("ETag", `"${row.version}"`).json(row); } catch (err) { handleErr(err, res); } diff --git a/apps/bridge/src/plugins/activate-plugin.ts b/apps/bridge/src/plugins/activate-plugin.ts deleted file mode 100644 index 519ff9d..0000000 --- a/apps/bridge/src/plugins/activate-plugin.ts +++ /dev/null @@ -1,67 +0,0 @@ -import path from "node:path"; -import type { CoreDatabase } from "../core-db.js"; -import { loadPluginFromRoot } from "./loader.js"; -import { installPluginForTenant } from "./plugin-install.js"; -import { ensurePluginBuilt } from "../services/plugin-build.js"; - -/** - * Persist a plugin root for boot rediscovery (Marketplace Unofficial paths). - */ -export function appendPluginPath(core: CoreDatabase, pluginRoot: string): void { - const key = "marketplace.plugin_paths"; - const existing = core.prepare(`SELECT value FROM platform_meta WHERE key=?`).get(key) as - | { value: string } - | undefined; - const paths: string[] = existing?.value ? JSON.parse(existing.value) : []; - const resolved = path.resolve(pluginRoot); - if (!paths.includes(resolved)) paths.push(resolved); - core - .prepare( - `INSERT INTO platform_meta (key, value) VALUES (?, ?) - ON CONFLICT(key) DO UPDATE SET value=excluded.value` - ) - .run(key, JSON.stringify(paths)); -} - -export interface ActivatePluginResult { - pluginId: string; - pluginRoot: string; - installed: boolean; - built: boolean; - reloaded: boolean; -} - -/** - * Same pipeline as Marketplace Unofficial: optional build → persist path → - * runtime load/reload → tenant install. No Bridge restart required for tools - * and tenant:install hooks. - */ -export async function activatePluginForTenant( - core: CoreDatabase, - tenantId: string, - pluginRoot: string, - opts?: { buildIfNeeded?: boolean; installForTenant?: boolean; reload?: boolean } -): Promise { - const resolved = path.resolve(pluginRoot); - let built = false; - if (opts?.buildIfNeeded !== false) { - built = await ensurePluginBuilt(resolved); - } - - appendPluginPath(core, resolved); - const load = await loadPluginFromRoot(resolved, { reload: opts?.reload }); - - let installed = false; - if (opts?.installForTenant !== false) { - await installPluginForTenant(core, tenantId, load.pluginId, resolved); - installed = true; - } - - return { - pluginId: load.pluginId, - pluginRoot: resolved, - installed, - built, - reloaded: load.reloaded, - }; -} diff --git a/apps/bridge/src/plugins/loader.ts b/apps/bridge/src/plugins/loader.ts index a7e5c1c..517ec22 100644 --- a/apps/bridge/src/plugins/loader.ts +++ b/apps/bridge/src/plugins/loader.ts @@ -1,6 +1,5 @@ import fs from "node:fs"; import path from "node:path"; -import { createRequire } from "node:module"; import { pathToFileURL } from "node:url"; import { readGodmodePluginManifest, @@ -17,70 +16,6 @@ import { replaceObjectTypesByPlugin, } from "../kernel/registry.js"; -const hostRequire = createRequire(import.meta.url); - -/** - * Packages that plugin bridge bundles typically `external`ize and expect to - * resolve from node_modules. In Docker hubs the plugin's `file:../GodMode` - * links often break (symlink outside the bind mount / missing dist). Point - * them at the Bridge image's built copies instead. - */ -const HOST_LINKED_PACKAGES = ["plugin-api", "plugin-host"] as const; - -/** - * Ensure `@godmode/plugin-api` and `@godmode/plugin-host` under the plugin's - * node_modules resolve to the same builds Bridge itself uses. - */ -export function ensureHostGodmodePackageLinks(pluginRoot: string): void { - const nmGodmode = path.join(pluginRoot, "node_modules", "@godmode"); - fs.mkdirSync(nmGodmode, { recursive: true }); - - for (const name of HOST_LINKED_PACKAGES) { - let resolvedPkg: string; - try { - resolvedPkg = path.dirname(hostRequire.resolve(`@godmode/${name}/package.json`)); - } catch { - console.warn( - `[plugins] host package @godmode/${name} not resolvable; skip link for ${pluginRoot}` - ); - continue; - } - - const distEntry = path.join(resolvedPkg, "dist", "index.js"); - if (!fs.existsSync(distEntry)) { - console.warn( - `[plugins] host package @godmode/${name} has no dist/index.js at ${distEntry}` - ); - continue; - } - - const linkPath = path.join(nmGodmode, name); - try { - if (fs.existsSync(linkPath)) { - const real = fs.realpathSync(linkPath); - if (real === fs.realpathSync(resolvedPkg)) continue; - fs.rmSync(linkPath, { recursive: true, force: true }); - } - } catch { - try { - fs.rmSync(linkPath, { recursive: true, force: true }); - } catch { - /* ignore */ - } - } - - try { - // 'junction' works for directories on Windows without admin; 'dir' symlink on POSIX. - const type = process.platform === "win32" ? "junction" : "dir"; - fs.symlinkSync(resolvedPkg, linkPath, type); - console.log(`[plugins] linked @godmode/${name} -> ${resolvedPkg} for ${pluginRoot}`); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - console.warn(`[plugins] failed to link @godmode/${name} for ${pluginRoot}: ${msg}`); - } - } -} - function extraMarketplacePluginPaths(): string[] { try { const row = getCoreDb() @@ -146,44 +81,6 @@ export interface LoadPluginsResult { errors: Array<{ path: string; error: string }>; } -export async function loadPluginsFromEnv(): Promise { - const roots = discoverPluginRoots(); - const loaded: string[] = []; - const errors: Array<{ path: string; error: string }> = []; - - for (const pluginRoot of roots) { - try { - const manifest = readGodmodePluginManifest(pluginRoot); - assertEngineCompatible(manifest); - registerPluginObjectTypes(manifest); - if (!manifest.bridge?.entry) { - if ((manifest.objectTypes?.length ?? 0) > 0 || (manifest.records?.length ?? 0) > 0) { - pluginRuntime.registerManifestOnly(manifest, pluginRoot); - loaded.push(manifest.id); - console.log( - `[plugins] registered ObjectTypes for ${manifest.name} (${manifest.id}) from ${pluginRoot}` - ); - continue; - } - errors.push({ path: pluginRoot, error: "manifest missing bridge.entry" }); - continue; - } - ensureHostGodmodePackageLinks(pluginRoot); - const entryPath = resolveBridgeEntry(pluginRoot, manifest.bridge.entry); - const registerFn = await importBridgeRegister(entryPath); - await Promise.resolve(pluginRuntime.register(manifest, pluginRoot, registerFn)); - loaded.push(manifest.id); - console.log(`[plugins] loaded ${manifest.name} (${manifest.id}) from ${pluginRoot}`); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - errors.push({ path: pluginRoot, error: msg }); - console.error(`[plugins] failed to load ${pluginRoot}:`, msg); - } - } - - return { loaded, errors }; -} - /** * Load a single plugin at runtime (Marketplace / Intelligence). * If already loaded, unregister and re-import with a cache-bust so rebuilds apply. @@ -215,7 +112,6 @@ export async function loadPluginFromRoot( } try { - ensureHostGodmodePackageLinks(pluginRoot); const entryPath = resolveBridgeEntry(pluginRoot, manifest.bridge.entry); const registerFn = await importBridgeRegister(entryPath, already); if (already) { diff --git a/apps/bridge/src/plugins/plugin-host-bridge.ts b/apps/bridge/src/plugins/plugin-host-bridge.ts index db64b6c..916ab9b 100644 --- a/apps/bridge/src/plugins/plugin-host-bridge.ts +++ b/apps/bridge/src/plugins/plugin-host-bridge.ts @@ -11,6 +11,11 @@ import { } from "../services/card-awaiting.js"; import { setPluginHost, type PluginHostServices, type SierraPb1SchedulerHost, type TenantDb } from "@godmode/plugin-host"; import { config } from "../config.js"; +import { + createRecord, + createSystemOperationContext, + KernelError, +} from "../kernel/index.js"; let pingScHealthImpl: (() => Promise<{ ok: boolean; detail?: string }>) | null = null; let enqueueScLineImpl: ((line: string, chartbookKey?: string) => string) | null = null; @@ -25,11 +30,26 @@ export function setScHealthPing( } function upsertTradingDepartment(db: AppDatabase): void { - db.prepare( - `INSERT OR IGNORE INTO structure_nodes - (id, parent_id, label, icon, segment, kind, right_sidebar, agent_id, built_in, sort_order, tabs_json) - VALUES ('trading', NULL, 'Trading', 'trending-up', 'trading', 'placeholder', NULL, NULL, 1, 0, NULL)` - ).run(); + try { + createRecord( + db, + "StructureNode", + { + id: "trading", + parent_id: null, + label: "Trading", + icon: "trending-up", + segment: "trading", + kind: "placeholder", + built_in: true, + sort_order: 0, + }, + createSystemOperationContext() + ); + } catch (error) { + if (error instanceof KernelError && error.status === 409) return; + throw error; + } } export function initPluginHost(): PluginHostServices { diff --git a/apps/bridge/src/plugins/plugin-install.ts b/apps/bridge/src/plugins/plugin-install.ts index 65e0b19..87c15a1 100644 --- a/apps/bridge/src/plugins/plugin-install.ts +++ b/apps/bridge/src/plugins/plugin-install.ts @@ -1,15 +1,7 @@ import type { CoreDatabase } from "../core-db.js"; -import type { AppDatabase } from "../db.js"; -import { readGodmodePluginManifest } from "@godmode/plugin-api"; import { discoverPluginRoots } from "./loader.js"; import { pluginRuntime } from "./runtime.js"; -import { - importPluginKnowledgeFromRoot, - removePluginKnowledge, -} from "../services/knowledge-store.js"; -import { getTenantDb } from "../tenant-registry.js"; -import { applyPluginObjectTypeSeeds, registerPluginObjectTypes } from "../kernel/plugin-object-types.js"; -import { unregisterObjectTypesByPlugin } from "../kernel/registry.js"; +import { readGodmodePluginManifest } from "@godmode/plugin-api"; export interface TenantPluginRow { tenant_id: string; @@ -22,41 +14,7 @@ export interface TenantPluginRow { updated_at: string; } -export function ensureTenantPluginsTable(core: CoreDatabase): void { - core.exec(` - CREATE TABLE IF NOT EXISTS tenant_plugins ( - tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, - plugin_id TEXT NOT NULL, - version TEXT NOT NULL, - installed_at TEXT NOT NULL DEFAULT (datetime('now')), - plugin_root TEXT, - PRIMARY KEY (tenant_id, plugin_id) - ); - CREATE INDEX IF NOT EXISTS tenant_plugins_tenant_idx ON tenant_plugins(tenant_id); - `); - const columns = new Set( - ( - core.prepare(`PRAGMA table_info(tenant_plugins)`).all() as Array<{ - name: string; - }> - ).map((column) => column.name) - ); - if (!columns.has("state")) { - core.exec( - `ALTER TABLE tenant_plugins ADD COLUMN state TEXT NOT NULL DEFAULT 'active'` - ); - } - if (!columns.has("last_error")) { - core.exec(`ALTER TABLE tenant_plugins ADD COLUMN last_error TEXT`); - } - if (!columns.has("updated_at")) { - core.exec(`ALTER TABLE tenant_plugins ADD COLUMN updated_at TEXT`); - core.exec( - `UPDATE tenant_plugins SET updated_at=COALESCE(updated_at, installed_at)` - ); - } -} - +/** Read model for tenant-visible, fully activated plugins. */ export function listInstalledPlugins( core: CoreDatabase, tenantId: string @@ -79,7 +37,6 @@ export function listAvailablePlugins(): Array<{ pluginRoot: string; loaded: boolean; }> { - const roots = discoverPluginRoots(); const out: Array<{ id: string; version: string; @@ -87,214 +44,42 @@ export function listAvailablePlugins(): Array<{ pluginRoot: string; loaded: boolean; }> = []; - for (const pluginRoot of roots) { + for (const pluginRoot of discoverPluginRoots()) { try { - const m = readGodmodePluginManifest(pluginRoot); + const manifest = readGodmodePluginManifest(pluginRoot); out.push({ - id: m.id, - version: m.version, - name: m.name, + id: manifest.id, + version: manifest.version, + name: manifest.name, pluginRoot, - loaded: pluginRuntime.hasPlugin(m.id), + loaded: pluginRuntime.hasPlugin(manifest.id), }); } catch { - /* skip */ + // Invalid plugin roots are reported by lifecycle reconciliation. } } return out; } -export async function installPluginForTenant( - core: CoreDatabase, - tenantId: string, - pluginId: string, - pluginRoot?: string -): Promise { - const loaded = pluginRuntime.getPlugin(pluginId); - if (!loaded) { - throw new Error( - `Plugin not loaded: ${pluginId}. Call install_plugin (or Marketplace → Unofficial) so Bridge can load it at runtime — no restart required for tools/tenant:install.` - ); - } - const root = pluginRoot ?? loaded.pluginRoot; - registerPluginObjectTypes(loaded.manifest); - ensureTenantPluginsTable(core); - core.prepare( - `INSERT INTO tenant_plugins - (tenant_id, plugin_id, version, plugin_root, state, last_error, updated_at) - VALUES (?, ?, ?, ?, 'installing', NULL, datetime('now')) - ON CONFLICT(tenant_id, plugin_id) DO UPDATE SET - version=excluded.version, - plugin_root=excluded.plugin_root, - state='installing', - last_error=NULL, - installed_at=datetime('now'), - updated_at=datetime('now')` - ).run(tenantId, pluginId, loaded.manifest.version, root); - try { - const tenantDb = getTenantDb(tenantId); - applyPluginObjectTypeSeeds(tenantDb, loaded.manifest); - await pluginRuntime.installPluginForTenant(pluginId, tenantId); - syncPluginKnowledgeForTenant(core, tenantId, pluginId, root); - core.prepare( - `UPDATE tenant_plugins - SET state='active', last_error=NULL, updated_at=datetime('now') - WHERE tenant_id=? AND plugin_id=?` - ).run(tenantId, pluginId); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - try { - removePluginKnowledge(getTenantDb(tenantId), pluginId); - await pluginRuntime.uninstallPluginForTenant(pluginId, tenantId); - } catch { - // Reconciliation will retry compensation from the durable failed state. - } - core.prepare( - `UPDATE tenant_plugins - SET state='failed', last_error=?, updated_at=datetime('now') - WHERE tenant_id=? AND plugin_id=?` - ).run(message, tenantId, pluginId); - throw error; - } -} - -function syncPluginKnowledgeForTenant( - core: CoreDatabase, - tenantId: string, - pluginId: string, - pluginRoot: string -): void { - const db = getTenantDb(tenantId); - const { rules, skills } = importPluginKnowledgeFromRoot(db, pluginRoot, pluginId); - if (rules > 0 || skills > 0) { - console.log( - `[plugins] imported knowledge for ${pluginId} on tenant ${tenantId}: ${rules} rules, ${skills} skills` - ); - } -} - -export function syncInstalledPluginKnowledge( - core: CoreDatabase, - tenantId: string -): void { - for (const row of listInstalledPlugins(core, tenantId)) { - const root = - row.plugin_root ?? - pluginRuntime.getPlugin(row.plugin_id)?.pluginRoot ?? - null; - if (!root) continue; - syncPluginKnowledgeForTenant(core, tenantId, row.plugin_id, root); - } -} - -/** Reconcile declarative ObjectType storage/seeds for every installed tenant. */ -export function reconcileInstalledPluginObjectTypes(core: CoreDatabase): void { - ensureTenantPluginsTable(core); - const rows = core - .prepare( - `SELECT tenant_id, plugin_id FROM tenant_plugins - WHERE state='active' ORDER BY tenant_id, plugin_id` - ) - .all() as Array<{ tenant_id: string; plugin_id: string }>; - for (const row of rows) { - const loaded = pluginRuntime.getPlugin(row.plugin_id); - if (!loaded) continue; - registerPluginObjectTypes(loaded.manifest); - applyPluginObjectTypeSeeds(getTenantDb(row.tenant_id), loaded.manifest); - } -} - export function isPluginEnabledForTenant( core: CoreDatabase, tenantId: string, pluginId: string ): boolean { if (!pluginRuntime.hasPlugin(pluginId)) return false; - const row = core - .prepare( - `SELECT 1 FROM tenant_plugins - WHERE tenant_id=? AND plugin_id=? AND state='active'` - ) - .get(tenantId, pluginId); - return Boolean(row); -} - -export function installedPluginIdsForTenant(core: CoreDatabase, tenantId: string): string[] { - return listInstalledPlugins(core, tenantId).map((r) => r.plugin_id); -} - -export async function uninstallPluginForTenant( - core: CoreDatabase, - tenantId: string, - pluginId: string -): Promise { - const db = getTenantDb(tenantId); - core.prepare( - `UPDATE tenant_plugins - SET state='uninstalling', last_error=NULL, updated_at=datetime('now') - WHERE tenant_id=? AND plugin_id=?` - ).run(tenantId, pluginId); - try { - removePluginKnowledge(db, pluginId); - await pluginRuntime.uninstallPluginForTenant(pluginId, tenantId); - core.prepare( - `DELETE FROM tenant_plugins WHERE tenant_id=? AND plugin_id=?` - ).run(tenantId, pluginId); - } catch (error) { - core.prepare( - `UPDATE tenant_plugins - SET state='failed', last_error=?, updated_at=datetime('now') - WHERE tenant_id=? AND plugin_id=?` - ).run(error instanceof Error ? error.message : String(error), tenantId, pluginId); - throw error; - } - const remaining = core - .prepare(`SELECT 1 FROM tenant_plugins WHERE plugin_id=? LIMIT 1`) - .get(pluginId); - if (!remaining) unregisterObjectTypesByPlugin(pluginId); -} - -export async function ensureOperatorPluginsInstalled( - core: CoreDatabase, - operatorTenantId: string, - operatorDb: AppDatabase -): Promise { - ensureTenantPluginsTable(core); - for (const plugin of pluginRuntime.loaded) { - const row = core + return Boolean( + core .prepare( `SELECT 1 FROM tenant_plugins WHERE tenant_id=? AND plugin_id=? AND state='active'` ) - .get(operatorTenantId, plugin.manifest.id); - if (row) continue; - const departmentIds = plugin.manifest.departments ?? []; - const hasExistingStructure = - departmentIds.length > 0 && - departmentIds.some((deptId) => - Boolean( - operatorDb - .prepare(`SELECT 1 FROM structure_nodes WHERE id=?`) - .get(deptId) - ) - ); - if (hasExistingStructure) { - core.prepare( - `INSERT OR IGNORE INTO tenant_plugins (tenant_id, plugin_id, version, plugin_root) - VALUES (?, ?, ?, ?)` - ).run( - operatorTenantId, - plugin.manifest.id, - plugin.manifest.version, - plugin.pluginRoot - ); - continue; - } - await installPluginForTenant( - core, - operatorTenantId, - plugin.manifest.id, - plugin.pluginRoot - ); - } + .get(tenantId, pluginId) + ); +} + +export function installedPluginIdsForTenant( + core: CoreDatabase, + tenantId: string +): string[] { + return listInstalledPlugins(core, tenantId).map((row) => row.plugin_id); } diff --git a/apps/bridge/src/plugins/runtime.ts b/apps/bridge/src/plugins/runtime.ts index bc521e1..0eb7673 100644 --- a/apps/bridge/src/plugins/runtime.ts +++ b/apps/bridge/src/plugins/runtime.ts @@ -13,6 +13,7 @@ import type { PluginRecordAdapter, PluginRecordContext, } from "@godmode/plugin-api"; +import { KERNEL_CLIENT_API_VERSION } from "@godmode/plugin-api"; import { getPluginHost } from "@godmode/plugin-host"; import { registerPageKinds } from "../kernel/kind-registry.js"; import { @@ -25,6 +26,16 @@ import { registerObjectType, unregisterObjectType, } from "../kernel/registry.js"; +import { + createRecord, + deleteRecord, + executeCollectionAction, + executeRecordAction, + getRecord, + listRecords, + updateRecord, +} from "../kernel/record-api.js"; +import { getTenantDb } from "../tenant-registry.js"; type HookHandler = (ctx: PluginBootContext & PluginTenantContext) => void | Promise; @@ -188,10 +199,78 @@ export class PluginRuntime { const self = this; const pluginId = manifest.id; const host = getPluginHost(); + const kernelContext = (ctx: PluginRecordContext): OperationContext => ({ + tenantId: ctx.tenantId, + userId: ctx.userId, + agentId: ctx.activeAgentId, + role: ctx.role, + source: "plugin", + requestId: ctx.requestId, + idempotencyKey: ctx.idempotencyKey, + expectedVersion: ctx.expectedVersion, + confirmationId: ctx.confirmationId, + installedPluginIds: new Set([pluginId]), + signal: ctx.signal, + bus: self.config?.bus, + }); + const kernelDb = (ctx: PluginRecordContext) => + host.getTenantDb(ctx.tenantId) as AppDatabase; const api: GodModePluginApi = { manifest: { id: manifest.id, version: manifest.version, name: manifest.name }, pluginRoot, host, + kernel: { + apiVersion: KERNEL_CLIENT_API_VERSION, + list(objectType, query, ctx) { + return listRecords( + kernelDb(ctx), + objectType, + query, + kernelContext(ctx) + ); + }, + get(objectType, id, ctx) { + return getRecord(kernelDb(ctx), objectType, id, kernelContext(ctx)); + }, + create(objectType, data, ctx) { + return createRecord( + kernelDb(ctx), + objectType, + data, + kernelContext(ctx) + ); + }, + update(objectType, id, data, ctx) { + return updateRecord( + kernelDb(ctx), + objectType, + id, + data, + kernelContext(ctx) + ); + }, + delete(objectType, id, ctx) { + deleteRecord(kernelDb(ctx), objectType, id, kernelContext(ctx)); + }, + async runAction(objectType, action, input, ctx, id) { + return id + ? executeRecordAction( + kernelDb(ctx), + objectType, + id, + action, + input, + kernelContext(ctx) + ) + : executeCollectionAction( + kernelDb(ctx), + objectType, + action, + input, + kernelContext(ctx) + ); + }, + }, routes: { mount(path: string, router: IRouter) { self.routers.push({ path, router }); @@ -248,6 +327,9 @@ export class PluginRuntime { role: ctx.role, source: ctx.source, requestId: ctx.requestId, + idempotencyKey: ctx.idempotencyKey, + expectedVersion: ctx.expectedVersion, + confirmationId: ctx.confirmationId, signal: ctx.signal, }); const adapter: RecordAdapter = { @@ -346,7 +428,21 @@ export class PluginRuntime { }, }, async installTenant(tenantId: string, userId?: string) { - await self.installPluginForTenant(pluginId, tenantId, userId); + await executeCollectionAction( + getTenantDb(tenantId), + "CatalogInstall", + "install_plugin", + { plugin_id: pluginId }, + { + tenantId, + userId, + agentId: userId ? undefined : `plugin:${pluginId}`, + role: "owner", + source: "plugin", + installedPluginIds: new Set([pluginId]), + bus: self.config?.bus, + } + ); }, }; return api; diff --git a/apps/bridge/src/routes/admin-billing.ts b/apps/bridge/src/routes/admin-billing.ts index 165a833..ec9773c 100644 --- a/apps/bridge/src/routes/admin-billing.ts +++ b/apps/bridge/src/routes/admin-billing.ts @@ -18,23 +18,5 @@ export function createAdminBillingRouter(): Router { res.json(getPlatformBillingConfig()); }); - router.put("/", (req, res) => { - const { secretKey, publishableKey, creditsPerUsd } = req.body ?? {}; - const cfg = setPlatformBillingKeys({ - secretKey: typeof secretKey === "string" ? secretKey : undefined, - publishableKey: typeof publishableKey === "string" ? publishableKey : undefined, - creditsPerUsd: - creditsPerUsd != null && Number.isFinite(Number(creditsPerUsd)) - ? Number(creditsPerUsd) - : undefined, - }); - res.json(cfg); - }); - - router.post("/test", async (_req, res) => { - const result = await testStripeConnection(); - res.status(result.ok ? 200 : 400).json(result); - }); - return router; } diff --git a/apps/bridge/src/routes/admin-users.ts b/apps/bridge/src/routes/admin-users.ts index c2feeb1..9f1bd8a 100644 --- a/apps/bridge/src/routes/admin-users.ts +++ b/apps/bridge/src/routes/admin-users.ts @@ -36,99 +36,6 @@ export function createAdminUsersRouter(): Router { res.json({ user }); }); - router.post("/users", (req, res) => { - const { email, password, displayName, isAdmin, provisionDefaultTenant } = - req.body ?? {}; - if (typeof email !== "string" || typeof password !== "string") { - res.status(400).json({ error: "email and password required" }); - return; - } - try { - const core = getCoreDb(); - const user = createAdminUser(core, { - email, - password, - displayName: typeof displayName === "string" ? displayName : undefined, - isAdmin: Boolean(isAdmin), - provisionDefaultTenant: provisionDefaultTenant !== false, - }); - res.status(201).json({ user }); - } catch (err) { - handleAdminUsersError(res, err); - } - }); - - router.patch("/users/:userId", (req, res) => { - const { email, displayName, isAdmin, password } = req.body ?? {}; - try { - const core = getCoreDb(); - const user = updateAdminUser(core, req.params.userId, { - email: typeof email === "string" ? email : undefined, - displayName: typeof displayName === "string" ? displayName : undefined, - isAdmin: isAdmin !== undefined ? Boolean(isAdmin) : undefined, - password: typeof password === "string" && password ? password : undefined, - }); - res.json({ user }); - } catch (err) { - handleAdminUsersError(res, err); - } - }); - - router.delete("/users/:userId", (req, res) => { - try { - const core = getCoreDb(); - deleteAdminUser(core, req.params.userId, req.user!.id); - res.json({ ok: true }); - } catch (err) { - handleAdminUsersError(res, err); - } - }); - - router.post("/users/:userId/tenants", (req, res) => { - const { name, slug } = req.body ?? {}; - if (typeof name !== "string" || !name.trim()) { - res.status(400).json({ error: "name required" }); - return; - } - try { - const core = getCoreDb(); - const tenant = createAdminTenantForUser( - core, - req.params.userId, - name, - typeof slug === "string" ? slug : undefined - ); - const user = getAdminUser(core, req.params.userId); - res.status(201).json({ tenant, user }); - } catch (err) { - handleAdminUsersError(res, err); - } - }); - - router.patch("/tenants/:tenantId", (req, res) => { - const { name, slug } = req.body ?? {}; - try { - const core = getCoreDb(); - const tenant = updateAdminTenant(core, req.params.tenantId, { - name: typeof name === "string" ? name : undefined, - slug: typeof slug === "string" ? slug : undefined, - }); - res.json({ tenant }); - } catch (err) { - handleAdminUsersError(res, err); - } - }); - - router.delete("/tenants/:tenantId", (req, res) => { - try { - const core = getCoreDb(); - deleteAdminTenant(core, req.params.tenantId); - res.json({ ok: true }); - } catch (err) { - handleAdminUsersError(res, err); - } - }); - return router; } diff --git a/apps/bridge/src/routes/ai.ts b/apps/bridge/src/routes/ai.ts index facc193..0d164eb 100644 --- a/apps/bridge/src/routes/ai.ts +++ b/apps/bridge/src/routes/ai.ts @@ -143,7 +143,6 @@ import { import { listPlatformActions } from "../services/platform-scope.js"; import { getCoreDb, - createSharedChatSession, getSharedChatSession, listSharedChatSessionsForUser, type CoreSharedChatSession, @@ -153,6 +152,9 @@ import { resolveShareAccess } from "../services/share-service.js"; import { refreshScheduler } from "../services/scheduler.js"; import { getTenantDb } from "../tenant-registry.js"; import { getShareBroker, broadcastCardActivity } from "../ws-broker.js"; +import { createRecord } from "../kernel/record-api.js"; +import type { OperationContext } from "../kernel/adapter-registry.js"; +import { ensureAgentProject } from "../services/user-productivity.js"; export type { PlatformContext } from "../types/platform-context.js"; import type { PlatformContext } from "../types/platform-context.js"; @@ -363,55 +365,6 @@ export function createAiRouter( res.json(embeddings.getStatus()); }); - router.post("/embeddings/start", async (req, res) => { - if (!embeddings) { - res.status(503).json({ error: "Embedding engine not available" }); - return; - } - try { - res.json(await embeddings.start()); - } catch (err) { - res.status(500).json({ error: err instanceof Error ? err.message : String(err) }); - } - }); - - router.post("/embeddings/stop", async (req, res) => { - if (!embeddings) { - res.status(503).json({ error: "Embedding engine not available" }); - return; - } - try { - res.json(await embeddings.stop()); - } catch (err) { - res.status(500).json({ error: err instanceof Error ? err.message : String(err) }); - } - }); - - // Persisted master enable toggle. Survives a bridge restart and reconciles - // the embedder server (enabling starts it; disabling stops it). - router.post("/embeddings/enabled", async (req, res) => { - if (!embeddings) { - res.status(503).json({ error: "Embedding engine not available" }); - return; - } - const enabled = Boolean(req.body?.enabled); - try { - res.json(await embeddings.setEnabled(enabled)); - } catch (err) { - res.status(500).json({ error: err instanceof Error ? err.message : String(err) }); - } - }); - - router.post("/capabilities/rebuild", async (req, res) => { - try { - const embedder = embeddings?.getEmbeddingClient(); - const count = await rebuildAllAgentCapabilityIndexes(tdb(req), embedder); - res.json({ ok: true, count, indexRows: countCapabilityIndex(tdb(req)) }); - } catch (err) { - res.status(500).json({ error: err instanceof Error ? err.message : String(err) }); - } - }); - router.get("/capabilities/status", (req, res) => { try { res.json({ indexRows: countCapabilityIndex(tdb(req)) }); @@ -527,10 +480,6 @@ export function createAiRouter( res.json(llm.getSettings()); }); - router.put("/settings", (req, res) => { - res.json(llm.updateSettings(req.body ?? {})); - }); - // Everything that goes to the model: resolved system prompt template, the // sampling params, the launch command, the (empty) tool set, and a snapshot // of the last actual request. Lets the UI show "what is sent to the LLM". @@ -589,29 +538,6 @@ export function createAiRouter( res.json({ config, assembled }); }); - router.put("/prompt-flow", (req, res) => { - const config = (req.body?.config ?? req.body) as PromptFlowConfig; - if (!config?.sections?.length) { - res.status(400).json({ error: "Invalid prompt flow config" }); - return; - } - savePromptFlowConfig(tdb(req), config); - const settings = llm.getSettings(); - const agentId = String(req.body?.agentId ?? req.query.agentId ?? "intelligence"); - const agent = getAgent(tdb(req), agentId); - const assembled = assemblePrompt(tdb(req), { - basePrompt: agent?.systemPrompt ?? settings.systemPrompt, - flowConfig: config, - enableThinking: agent?.thinking.enableThinking ?? settings.enableThinking, - thinkingEfficiency: agent?.thinking.thinkingEfficiency ?? settings.thinkingEfficiency, - nativeTools: agent?.thinking.nativeTools ?? settings.nativeTools, - agentId, - tenantId: req.tenantId, - agent, - }); - res.json({ config, assembled }); - }); - router.get("/memories", (req, res) => { const chatId = req.query.chatId as string | undefined; const status = req.query.status as string | undefined; @@ -641,117 +567,6 @@ export function createAiRouter( res.json(rows); }); - router.post("/memories/:id/approve", (req, res) => { - const agentId = agentIdFromRequest(req); - const agentDb = agentDbFromRequest(req, res, agentId, "editor"); - if (!agentDb) return; - const result = agentDb - .prepare( - `UPDATE ai_memories SET status = 'active', updated_at = datetime('now') WHERE id = ?` - ) - .run(req.params.id); - if (result.changes === 0) { - res.status(404).json({ error: "Not found" }); - return; - } - const row = agentDb.prepare(`SELECT * FROM ai_memories WHERE id = ?`).get(req.params.id) as - | { id: string; text: string } - | undefined; - if (row) { - indexMemory( - agentDb, - embeddings?.isEmbedderReady() ? embeddings.getEmbeddingClient() : null, - row.id, - row.text - ); - } - res.json(row); - }); - - router.post("/memories", (req, res) => { - const id = uuidv4(); - const text = String(req.body?.text ?? "").trim(); - if (!text) { - res.status(400).json({ error: "text required" }); - return; - } - const agentId = agentIdFromRequest(req); - const agentDb = agentDbFromRequest(req, res, agentId, "editor"); - if (!agentDb) return; - const scope = req.body?.scope === "chat" ? "chat" : "global"; - const chatId = scope === "chat" ? String(req.body?.chatId ?? "") : null; - agentDb.prepare( - `INSERT INTO ai_memories (id, scope, chat_id, agent_id, text, category, source) - VALUES (?, ?, ?, ?, ?, ?, ?)` - ).run( - id, - scope, - chatId || null, - agentId, - text, - req.body?.category ?? null, - req.body?.source ?? "manual" - ); - indexMemory( - agentDb, - embeddings?.isEmbedderReady() ? embeddings.getEmbeddingClient() : null, - id, - text - ); - const row = agentDb.prepare(`SELECT * FROM ai_memories WHERE id = ?`).get(id); - res.status(201).json(row); - }); - - router.put("/memories/:id", (req, res) => { - const agentId = agentIdFromRequest(req); - const agentDb = agentDbFromRequest(req, res, agentId, "editor"); - if (!agentDb) return; - const { text, enabled, category } = req.body ?? {}; - const existing = agentDb - .prepare(`SELECT id, text FROM ai_memories WHERE id = ?`) - .get(req.params.id) as { id: string; text: string } | undefined; - if (!existing) { - res.status(404).json({ error: "Not found" }); - return; - } - if (text != null) { - agentDb.prepare( - `UPDATE ai_memories SET text = ?, updated_at = datetime('now') WHERE id = ?` - ).run(String(text), req.params.id); - } - if (enabled != null) { - agentDb.prepare( - `UPDATE ai_memories SET enabled = ?, updated_at = datetime('now') WHERE id = ?` - ).run(enabled ? 1 : 0, req.params.id); - } - if (category != null) { - agentDb.prepare( - `UPDATE ai_memories SET category = ?, updated_at = datetime('now') WHERE id = ?` - ).run(String(category), req.params.id); - } - const row = agentDb.prepare(`SELECT * FROM ai_memories WHERE id = ?`).get(req.params.id) as - | { id: string; text: string } - | undefined; - if (row) { - indexMemory( - agentDb, - embeddings?.isEmbedderReady() ? embeddings.getEmbeddingClient() : null, - row.id, - row.text - ); - } - res.json(row); - }); - - router.delete("/memories/:id", (req, res) => { - const agentId = agentIdFromRequest(req); - const agentDb = agentDbFromRequest(req, res, agentId, "editor"); - if (!agentDb) return; - removeMemoryFromIndex(agentDb, req.params.id); - const result = agentDb.prepare(`DELETE FROM ai_memories WHERE id = ?`).run(req.params.id); - res.json({ ok: result.changes > 0 }); - }); - router.get("/rules", (req, res) => { const agentId = agentIdFromRequest(req); const agentDb = agentDbFromRequest(req, res, agentId, "viewer"); @@ -759,35 +574,6 @@ export function createAiRouter( res.json({ rules: listAiRules(agentDb, agentId) }); }); - router.put("/rules/:id", (req, res) => { - const agentId = agentIdFromRequest(req); - const agentDb = agentDbFromRequest(req, res, agentId, "editor"); - if (!agentDb) return; - updateAiRuleState(agentDb, agentId, req.params.id, { - enabled: req.body?.enabled, - priorityOverride: req.body?.priorityOverride, - }); - res.json({ rules: listAiRules(agentDb, agentId) }); - }); - - // Approve a reflection-drafted (pending) rule → becomes active/applied. - router.post("/rules/:id/approve", (req, res) => { - const agentId = agentIdFromRequest(req); - const agentDb = agentDbFromRequest(req, res, agentId, "editor"); - if (!agentDb) return; - setAiRuleStatus(agentDb, agentId, req.params.id, "active"); - res.json({ rules: listAiRules(agentDb, agentId) }); - }); - - // Reject a pending rule → delete the file and its state. - router.delete("/rules/:id", (req, res) => { - const agentId = agentIdFromRequest(req); - const agentDb = agentDbFromRequest(req, res, agentId, "editor"); - if (!agentDb) return; - const ok = deleteRuleFile(agentDb, req.params.id); - res.json({ ok, rules: listAiRules(agentDb, agentId) }); - }); - router.get("/skills", (req, res) => { const includeBody = req.query.body === "1"; const agentId = agentIdFromRequest(req); @@ -808,36 +594,6 @@ export function createAiRouter( res.json({ id: req.params.id, body }); }); - router.put("/skills/:id", (req, res) => { - if (req.body?.enabled == null) { - res.status(400).json({ error: "enabled required" }); - return; - } - const agentId = agentIdFromRequest(req); - const agentDb = agentDbFromRequest(req, res, agentId, "editor"); - if (!agentDb) return; - updateAiSkillState(agentDb, agentId, req.params.id, Boolean(req.body.enabled)); - res.json({ skills: listAiSkills(agentDb, false, agentId) }); - }); - - // Approve a reflection-drafted (pending) skill → becomes injectable. - router.post("/skills/:id/approve", (req, res) => { - const agentId = agentIdFromRequest(req); - const agentDb = agentDbFromRequest(req, res, agentId, "editor"); - if (!agentDb) return; - setAiSkillStatus(agentDb, agentId, req.params.id, "active"); - res.json({ skills: listAiSkills(agentDb, false, agentId) }); - }); - - // Reject a pending skill → delete the SKILL.md and its state. - router.delete("/skills/:id", (req, res) => { - const agentId = agentIdFromRequest(req); - const agentDb = agentDbFromRequest(req, res, agentId, "editor"); - if (!agentDb) return; - const ok = deleteSkillFile(agentDb, req.params.id); - res.json({ ok, skills: listAiSkills(agentDb, false, agentId) }); - }); - router.get("/artifacts", (req, res) => { const agentId = agentIdFromRequest(req); const agentDb = agentDbFromRequest(req, res, agentId, "viewer"); @@ -869,37 +625,6 @@ export function createAiRouter( res.json(artifact); }); - router.post("/artifacts", (req, res) => { - const agentId = agentIdFromRequest(req); - const agentDb = agentDbFromRequest(req, res, agentId, "editor"); - if (!agentDb) return; - const name = String(req.body?.name ?? "").trim(); - if (!name) { - res.status(400).json({ error: "name required" }); - return; - } - try { - const artifact = saveArtifact(agentDb, agentId, { - name, - content: String(req.body?.content ?? ""), - kind: req.body?.kind, - mimeType: req.body?.mimeType, - description: req.body?.description, - source: req.body?.source ?? "manual", - }); - res.status(201).json(artifact); - } catch (err) { - res.status(400).json({ error: err instanceof Error ? err.message : String(err) }); - } - }); - - router.delete("/artifacts/:id", (req, res) => { - const agentId = agentIdFromRequest(req); - const agentDb = agentDbFromRequest(req, res, agentId, "editor"); - if (!agentDb) return; - res.json({ ok: deleteArtifact(agentDb, agentId, req.params.id) }); - }); - router.get("/commands", (req, res) => { res.json({ commands: AI_CHAT_COMMANDS }); }); @@ -971,34 +696,6 @@ export function createAiRouter( res.json({ actions: listPlatformActions(tdb(req), limit) }); }); - router.put("/agents/assignments", (req, res) => { - const { scopeType, scopeId, agentId, role } = req.body ?? {}; - if (!isAssignmentScopeType(scopeType)) { - res.status(400).json({ error: "invalid scopeType" }); - return; - } - if (typeof scopeId !== "string" || scopeId.trim().length === 0) { - res.status(400).json({ error: "scopeId required" }); - return; - } - try { - const assignment = setAssignment( - tdb(req), - scopeType, - scopeId, - agentId, - role ?? null - ); - res.json({ ok: true, assignment }); - } catch (err) { - if (err instanceof AssignmentError) { - res.status(err.status).json({ error: err.message }); - return; - } - throw err; - } - }); - router.get("/agents/resolve", (req, res) => { const departmentId = String(req.query.departmentId ?? "").trim(); if (!departmentId) { @@ -1026,85 +723,6 @@ export function createAiRouter( res.json({ ...agent, shared: !scope.owned, shareRole: scope.role }); }); - router.post("/agents", (req, res) => { - const { name, description, icon, backend, cloneFromId, parentId, systemPrompt, sampling, thinking, toolAllow, autoApprove, modelPath, adapterIds, config } = - req.body ?? {}; - if (!name?.trim()) { - res.status(400).json({ error: "name required" }); - return; - } - const agent = createAgent(tdb(req), { - name: String(name).trim(), - description, - icon, - backend, - cloneFromId, - parentId: parentId === null || parentId === undefined ? undefined : String(parentId), - systemPrompt, - sampling, - thinking, - toolAllow, - autoApprove, - modelPath, - adapterIds, - config, - }); - res.status(201).json(agent); - }); - - router.post("/agents/:id/clone", (req, res) => { - const scope = resolveAgentScope(req, req.params.id, "viewer"); - if (!scope) { - res.status(404).json({ error: "Not found" }); - return; - } - const src = getAgent(scope.db, req.params.id); - if (!src) { - res.status(404).json({ error: "Not found" }); - return; - } - const name = String(req.body?.name ?? `${src.name} copy`).trim(); - const agent = createAgent(tdb(req), { - name, - description: src.description ?? undefined, - icon: src.icon ?? undefined, - backend: src.backend, - systemPrompt: src.systemPrompt, - sampling: src.sampling, - thinking: src.thinking, - toolAllow: src.toolAllow, - autoApprove: src.autoApprove, - modelPath: src.modelPath, - adapterIds: src.adapterIds, - config: src.config, - }); - res.status(201).json(agent); - }); - - router.put("/agents/:id", (req, res) => { - const scope = resolveAgentScope(req, req.params.id, "editor"); - if (!scope) { - res.status(404).json({ error: "Not found" }); - return; - } - const body = (req.body ?? {}) as Record; - const patchConfig = body.config as Record | undefined; - if ( - patchConfig && - ("codeAccess" in patchConfig || "codeAutonomy" in patchConfig) && - !req.user?.isAdmin - ) { - res.status(403).json({ error: "Platform admin required to change coding permissions" }); - return; - } - const agent = updateAgent(scope.db, req.params.id, body); - if (!agent) { - res.status(404).json({ error: "Not found" }); - return; - } - res.json(agent); - }); - router.get("/agents/:id/accounts", (req, res) => { const scope = resolveAgentScope(req, req.params.id, "viewer"); if (!scope) { @@ -1114,49 +732,6 @@ export function createAiRouter( res.json({ accounts: listAgentAccounts(scope.db, req.params.id) }); }); - router.post("/agents/:id/accounts/apikey", (req, res) => { - const scope = resolveAgentScope(req, req.params.id, "editor"); - if (!scope) { - res.status(404).json({ error: "Not found" }); - return; - } - const provider = String(req.body?.provider ?? "").trim(); - const apiKey = String(req.body?.apiKey ?? "").trim(); - if (!provider || !apiKey) { - res.status(400).json({ error: "provider and apiKey required" }); - return; - } - const account = createAgentApiKeyAccount(scope.db, { - agentId: req.params.id, - provider, - label: typeof req.body?.label === "string" ? req.body.label : undefined, - apiKey, - }); - res.status(201).json({ account }); - }); - - router.delete("/agents/:id/accounts/:accountId", (req, res) => { - const scope = resolveAgentScope(req, req.params.id, "editor"); - if (!scope) { - res.status(404).json({ error: "Not found" }); - return; - } - const ok = revokeAgentAccount(scope.db, req.params.accountId, req.params.id); - res.json({ ok }); - }); - - router.delete("/agents/:id", (req, res) => { - const scope = resolveAgentScope(req, req.params.id, "owner"); - if (!scope?.owned) { - res.status(scope ? 403 : 404).json({ - error: scope ? "Cannot delete a shared agent" : "Not found", - }); - return; - } - const ok = deleteAgent(scope.db, req.params.id); - res.json({ ok }); - }); - router.get("/agents/:id/reflection", (req, res) => { const scope = resolveAgentScope(req, req.params.id, "viewer"); if (!scope) { @@ -1166,31 +741,6 @@ export function createAiRouter( res.json({ reflection: getReflectionConfig(scope.db, req.params.id) }); }); - router.patch("/agents/:id/reflection", (req, res) => { - const scope = resolveAgentScope(req, req.params.id, "editor"); - if (!scope) { - res.status(404).json({ error: "Not found" }); - return; - } - const next = patchReflectionConfig(scope.db, req.params.id, req.body ?? {}); - reflection?.reload(); - res.json({ reflection: next }); - }); - - router.post("/agents/:id/reflection/run", (req, res) => { - const scope = resolveAgentScope(req, req.params.id, "editor"); - if (!scope) { - res.status(404).json({ error: "Not found" }); - return; - } - if (!reflection) { - res.status(503).json({ error: "Reflection service unavailable" }); - return; - } - const jobId = reflection.enqueueReflection(req.params.id, "manual", scope.tenantId); - res.json({ ok: true, jobId }); - }); - router.get("/agents/:id/reflection/proposals", (req, res) => { const scope = resolveAgentScope(req, req.params.id, "viewer"); if (!scope) { @@ -1205,56 +755,6 @@ export function createAiRouter( res.json({ proposals: listReflectionProposals(scope.db, req.params.id, status) }); }); - router.post("/reflection/proposals/:id/approve", (req, res) => { - const ok = approveReflectionProposal(tdb(req), req.params.id); - if (!ok) { - res.status(404).json({ error: "Proposal not found or not pending" }); - return; - } - res.json({ ok: true }); - }); - - router.post("/reflection/proposals/:id/reject", (req, res) => { - const ok = rejectReflectionProposal(tdb(req), req.params.id); - if (!ok) { - res.status(404).json({ error: "Proposal not found or not pending" }); - return; - } - res.json({ ok: true }); - }); - - router.post("/memory/distill", (req, res) => { - if (!memoryMaintenance) { - res.status(503).json({ error: "Memory maintenance not available" }); - return; - } - const chatId = String(req.body?.chatId ?? ""); - const agentId = String(req.body?.agentId ?? "intelligence"); - if (!chatId) { - res.status(400).json({ error: "chatId required" }); - return; - } - const jobId = memoryMaintenance.enqueueDistill({ - chatId, - agentId, - tenantId: req.tenantId, - force: Boolean(req.body?.force), - }); - res.json({ ok: true, jobId }); - }); - - router.post("/memory/wiki-synthesize", (req, res) => { - if (!memoryMaintenance) { - res.status(503).json({ error: "Memory maintenance not available" }); - return; - } - const jobId = memoryMaintenance.enqueueWikiSynthesize( - req.tenantId ?? "", - String(req.body?.agentId ?? "intelligence") - ); - res.json({ ok: true, jobId }); - }); - router.get("/secrets", (req, res) => { // Cursor subscription key is managed by the Cursor card — hide from generic list. const secrets = listSecrets(tdb(req)).filter( @@ -1266,31 +766,6 @@ export function createAiRouter( res.json({ secrets }); }); - router.post("/secrets", (req, res) => { - const { name, value } = req.body ?? {}; - if (!name?.trim() || !value?.trim()) { - res.status(400).json({ error: "name and value required" }); - return; - } - const trimmedName = String(name).trim(); - if ( - trimmedName === "cursor_api_key" || - trimmedName === "CURSOR_API_KEY" || - trimmedName.toLowerCase() === "cursor-api-key" - ) { - res.status(400).json({ - error: - "Use Vault → Cursor subscription → Connect for Cursor API keys (not this list).", - }); - return; - } - res.status(201).json(createSecret(tdb(req), trimmedName, String(value))); - }); - - router.delete("/secrets/:id", (req, res) => { - res.json({ ok: deleteSecret(tdb(req), req.params.id) }); - }); - router.get("/cursor/status", async (req, res) => { const db = tdb(req); normalizeCursorVaultSecret(db); @@ -1306,58 +781,12 @@ export function createAiRouter( }); }); - router.post("/cursor/api-key", (req, res) => { - const apiKey = String(req.body?.apiKey ?? "").trim(); - if (!apiKey) { - res.status(400).json({ error: "apiKey required" }); - return; - } - const db = tdb(req); - upsertCursorApiKey(db, apiKey); - markLlmReady(db); - res.json({ ok: true, status: getCursorAuthStatus(db) }); - }); - - router.delete("/cursor/api-key", (req, res) => { - const db = tdb(req); - res.json({ ok: removeCursorApiKey(db), status: getCursorAuthStatus(db) }); - }); - router.get("/cursor/models", async (req, res) => { try { - const models = await listCursorSubscriptionModels(tdb(req)); - res.json({ models }); - } catch (err) { - res.status(400).json({ error: err instanceof Error ? err.message : String(err) }); - } - }); - - router.post("/cursor/cli-login-url", async (_req, res) => { - try { - const result = await startCursorCliLoginUrl(); - res.json(result); - } catch (err) { - res.status(400).json({ error: err instanceof Error ? err.message : String(err) }); - } - }); - - router.post("/cursor/use-for-intelligence", async (req, res) => { - try { - const db = tdb(req); - if (!getCursorAuthStatus(db).connected) { - res.status(400).json({ error: "Connect Cursor with an API key first" }); - return; - } - const model = req.body?.model ? String(req.body.model) : "auto"; - const result = await selectIntelligenceModel(db, llm, { - source: "cursor", - model, - }); - res.json({ ok: true, agent: getAgent(db, "intelligence"), active: result.active }); + const models = await listCursorSubscriptionModels(tdb(req)); + res.json({ models }); } catch (err) { - res.status(400).json({ - error: err instanceof Error ? err.message : String(err), - }); + res.status(400).json({ error: err instanceof Error ? err.message : String(err) }); } }); @@ -1377,65 +806,6 @@ export function createAiRouter( } }); - router.post("/select-model", async (req, res) => { - try { - const source = String(req.body?.source ?? ""); - if ( - source !== "local" && - source !== "cursor" && - source !== "provider" && - source !== "remote" - ) { - res.status(400).json({ error: "source must be local, cursor, provider, or remote" }); - return; - } - const result = await selectIntelligenceModel(tdb(req), llm, { - source, - path: req.body?.path ? String(req.body.path) : undefined, - model: req.body?.model ? String(req.body.model) : undefined, - provider: req.body?.provider, - endpointId: req.body?.endpointId ? String(req.body.endpointId) : undefined, - apiKeyRef: req.body?.apiKeyRef ? String(req.body.apiKeyRef) : undefined, - }); - res.json(result); - } catch (err) { - res.status(400).json({ - error: err instanceof Error ? err.message : String(err), - }); - } - }); - - router.post("/start", async (req, res) => { - try { - const status = await llm.start(req.body?.modelPath); - res.json(status); - } catch (err) { - res.status(500).json({ - error: err instanceof Error ? err.message : String(err), - }); - } - }); - - router.post("/stop", async (req, res) => { - try { - res.json(await llm.stop()); - } catch (err) { - res.status(500).json({ - error: err instanceof Error ? err.message : String(err), - }); - } - }); - - router.post("/restart", async (req, res) => { - try { - res.json(await llm.restart(req.body?.modelPath)); - } catch (err) { - res.status(500).json({ - error: err instanceof Error ? err.message : String(err), - }); - } - }); - router.get("/chats", (req, res) => { const userId = req.user!.id; const ownDb = tdb(req); @@ -1505,18 +875,6 @@ export function createAiRouter( res.json(chats); }); - router.post("/chats", (req, res) => { - const id = uuidv4(); - const title = String(req.body?.title ?? "New chat").slice(0, 120); - tdb(req) - .prepare(`INSERT INTO ai_chats (id, title, user_id) VALUES (?, ?, ?)`) - .run(id, title, req.user!.id); - const row = tdb(req) - .prepare(`SELECT id, title, created_at, updated_at FROM ai_chats WHERE id = ?`) - .get(id); - res.status(201).json(row); - }); - router.get("/chats/:id", (req, res) => { // Chats are the actor's work: read from the actor's own DB, or the shared // session's home DB when this chat was promoted to a collaborative session. @@ -1531,15 +889,6 @@ export function createAiRouter( res.json(row); }); - router.delete("/chats/:id", (req, res) => { - const { db: chatDb } = resolveChatWorkScope(req, req.params.id); - chatDb.prepare(`DELETE FROM ai_messages WHERE chat_id = ?`).run(req.params.id); - const result = chatDb - .prepare(`DELETE FROM ai_chats WHERE id = ?`) - .run(req.params.id); - res.json({ ok: result.changes > 0 }); - }); - router.get("/chats/:id/messages", (req, res) => { const { db: chatDb } = resolveChatWorkScope(req, req.params.id); const rows = chatDb @@ -1583,56 +932,6 @@ export function createAiRouter( }); }); - // Promote a chat to a collaborative shared session. The initiator's tenant - // becomes the session's home (the chat already lives there). Participants - // (agent owner / grantees) then route reads+writes to the home DB and both - // sides receive live updates via the agent room. - router.post("/chats/:id/share", (req, res) => { - const chatId = req.params.id; - const agentId = String(req.body?.agentId ?? agentIdFromRequest(req)); - // You can only start a shared session for a chat that lives in YOUR own - // workspace (your work DB) — i.e. a chat you initiated. - const ownDb = tdb(req); - const chat = ownDb - .prepare(`SELECT id FROM ai_chats WHERE id = ?`) - .get(chatId); - if (!chat) { - res.status(404).json({ error: "Chat not found in your workspace" }); - return; - } - // Confirm the caller can use this agent (owned or shared to them). - if (!resolveAgentScope(req, agentId, "viewer")) { - res.status(404).json({ error: "Agent not found" }); - return; - } - const session = createSharedChatSession(getCoreDb(), { - id: uuidv4(), - chatId, - homeTenantId: req.tenantId!, - agentId, - createdByUserId: req.user!.id, - }); - // Notify the agent room so participants can refresh into the shared session. - broadcastAgentEvent( - agentId, - "chat_session_shared", - { chatId, agentId, homeTenantId: session.home_tenant_id }, - req.tenantId - ); - res.status(201).json({ - ok: true, - session: { - id: session.id, - chatId: session.chat_id, - agentId: session.agent_id, - homeTenantId: session.home_tenant_id, - createdByUserId: session.created_by_user_id, - createdAt: session.created_at, - isHome: true, - }, - }); - }); - router.post("/chat", async (req, res) => { const { chatId, @@ -1703,6 +1002,14 @@ export function createAiRouter( const engineDb = scope.db; const work = resolveChatWorkScope(req, chatId); const workDb = work.db; + const chatKernelContext: OperationContext = { + tenantId: work.tenantId, + userId: req.user!.id, + isAdmin: req.user!.isAdmin, + role: req.tenantRole ?? "editor", + source: "http", + bus, + }; // Contribute-back: mirror new memories into the owner's engine DB only when // the caller opts in AND the agent is shared (no-op for owned agents). const contributeDb = @@ -1710,16 +1017,13 @@ export function createAiRouter( let activeChatId = chatId; if (!activeChatId) { - activeChatId = uuidv4(); const title = message.trim().slice(0, 80) || "New chat"; - workDb.prepare(`INSERT INTO ai_chats (id, title) VALUES (?, ?)`).run( - activeChatId, - title - ); - } else { - workDb.prepare(`UPDATE ai_chats SET updated_at = datetime('now') WHERE id = ?`).run( - activeChatId - ); + activeChatId = createRecord( + workDb, + "ChatSession", + { title }, + chatKernelContext + ).id; } const userParts: ChatMessagePart[] = []; @@ -1734,10 +1038,16 @@ export function createAiRouter( ? userParts[0].text! : userParts; - const userMsgId = uuidv4(); - workDb.prepare( - `INSERT INTO ai_messages (id, chat_id, role, content_json, user_id) VALUES (?, ?, 'user', ?, ?)` - ).run(userMsgId, activeChatId, JSON.stringify({ text: message, images }), req.user!.id); + const userMsgId = createRecord( + workDb, + "ChatMessage", + { + chat_id: activeChatId, + role: "user", + content: { text: message, images }, + }, + chatKernelContext + ).id; broadcastAgentEvent( resolvedAgentId, @@ -2177,14 +1487,16 @@ export function createAiRouter( p.endedAt = 0; } } - const assistantMsgId = uuidv4(); - workDb.prepare( - `INSERT INTO ai_messages (id, chat_id, role, content_json) VALUES (?, ?, 'assistant', ?)` - ).run( - assistantMsgId, - activeChatId, - JSON.stringify({ content: fullContent, thinking, answer, parts }) - ); + const assistantMsgId = createRecord( + workDb, + "ChatMessage", + { + chat_id: activeChatId, + role: "assistant", + content: { content: fullContent, thinking, answer, parts }, + }, + chatKernelContext + ).id; broadcastAgentEvent( resolvedAgentId, @@ -2236,48 +1548,6 @@ export function createAiRouter( }); }); - router.post("/chat/confirm-tool", (req, res) => { - const { toolCallId, approved } = req.body as { toolCallId?: string; approved?: boolean }; - if (!toolCallId) { - res.status(400).json({ error: "toolCallId required" }); - return; - } - const ok = resolveToolConfirmation(toolCallId, Boolean(approved)); - res.json({ ok }); - }); - - /** Delete a single message from a chat thread. */ - router.delete("/chats/:chatId/messages/:messageId", (req, res) => { - const work = resolveChatWorkScope(req, req.params.chatId); - const r = work.db - .prepare(`DELETE FROM ai_messages WHERE id = ? AND chat_id = ?`) - .run(req.params.messageId, req.params.chatId); - res.json({ ok: r.changes > 0 }); - }); - - /** Truncate chat history after a message (for edit/regenerate). */ - router.post("/chats/:chatId/truncate", (req, res) => { - const { afterMessageId } = req.body as { afterMessageId?: string }; - if (!afterMessageId) { - res.status(400).json({ error: "afterMessageId required" }); - return; - } - const work = resolveChatWorkScope(req, req.params.chatId); - const anchor = work.db - .prepare(`SELECT created_at FROM ai_messages WHERE id = ? AND chat_id = ?`) - .get(afterMessageId, req.params.chatId) as { created_at: string } | undefined; - if (!anchor) { - res.status(404).json({ error: "Message not found" }); - return; - } - const r = work.db - .prepare( - `DELETE FROM ai_messages WHERE chat_id = ? AND created_at > ?` - ) - .run(req.params.chatId, anchor.created_at); - res.json({ deleted: r.changes }); - }); - router.get("/lora-adapters", async (req, res) => { try { if (!llm.isReady()) { @@ -2290,59 +1560,11 @@ export function createAiRouter( } }); - router.post("/lora-adapters", async (req, res) => { - try { - res.json(await llm.proxyLoraAdapters("POST", req.body)); - } catch (err) { - res.status(500).json({ error: String(err) }); - } - }); - router.get("/adapters", (req, res) => { const rows = tdb(req).prepare(`SELECT * FROM ai_adapters ORDER BY name ASC`).all(); res.json({ adapters: rows }); }); - router.post("/adapters", (req, res) => { - const id = uuidv4(); - const { name, path, description, domain, defaultScale } = req.body ?? {}; - if (!name || !path) { - res.status(400).json({ error: "name and path required" }); - return; - } - tdb(req).prepare( - `INSERT INTO ai_adapters (id, name, path, description, domain, default_scale) - VALUES (?, ?, ?, ?, ?, ?)` - ).run(id, String(name), String(path), description ?? null, domain ?? null, defaultScale ?? 1); - res.status(201).json(tdb(req).prepare(`SELECT * FROM ai_adapters WHERE id = ?`).get(id)); - }); - - router.put("/adapters/:id", (req, res) => { - const { enabled, defaultScale, description } = req.body ?? {}; - if (enabled != null) { - tdb(req).prepare(`UPDATE ai_adapters SET enabled = ?, updated_at = datetime('now') WHERE id = ?`).run( - enabled ? 1 : 0, - req.params.id - ); - } - if (defaultScale != null) { - tdb(req).prepare( - `UPDATE ai_adapters SET default_scale = ?, updated_at = datetime('now') WHERE id = ?` - ).run(Number(defaultScale), req.params.id); - } - if (description != null) { - tdb(req).prepare( - `UPDATE ai_adapters SET description = ?, updated_at = datetime('now') WHERE id = ?` - ).run(String(description), req.params.id); - } - res.json(tdb(req).prepare(`SELECT * FROM ai_adapters WHERE id = ?`).get(req.params.id)); - }); - - router.delete("/adapters/:id", (req, res) => { - const r = tdb(req).prepare(`DELETE FROM ai_adapters WHERE id = ?`).run(req.params.id); - res.json({ ok: r.changes > 0 }); - }); - router.get("/queue", (req, res) => { const rows = tdb(req) .prepare( @@ -2354,30 +1576,6 @@ export function createAiRouter( res.json({ jobs: rows }); }); - router.post("/queue", (req, res) => { - const id = uuidv4(); - const { prompt, workflowId, priority, context, adapterIds } = req.body ?? {}; - tdb(req).prepare( - `INSERT INTO ai_prompt_queue (id, prompt, workflow_id, priority, context_json, adapter_ids_json) - VALUES (?, ?, ?, ?, ?, ?)` - ).run( - id, - prompt ? String(prompt) : null, - workflowId ?? null, - Number(priority ?? 0), - context ? JSON.stringify(context) : null, - adapterIds ? JSON.stringify(adapterIds) : null - ); - res.status(201).json(tdb(req).prepare(`SELECT * FROM ai_prompt_queue WHERE id = ?`).get(id)); - }); - - router.post("/queue/:id/cancel", (req, res) => { - tdb(req).prepare( - `UPDATE ai_prompt_queue SET status = 'cancelled', finished_at = datetime('now') WHERE id = ? AND status IN ('pending','running')` - ).run(req.params.id); - res.json({ ok: true }); - }); - const workflowApiRow = (workflow: AiWorkflow) => ({ id: workflow.id, agent_id: workflow.agent_id, @@ -2397,68 +1595,6 @@ export function createAiRouter( }); }); - router.post("/workflows", (req, res) => { - const name = String(req.body?.name ?? "Workflow"); - const agentId = String(req.body?.agentId ?? req.query.agentId ?? "intelligence"); - const config = req.body?.config ?? { nodes: [], edges: [] }; - const workflow = createWorkflow(tdb(req), { - name, - agentId, - config, - }); - res.status(201).json(workflowApiRow(workflow)); - }); - - router.put("/workflows/:id", (req, res) => { - const { name, config, enabled } = req.body ?? {}; - const workflow = updateWorkflow(tdb(req), req.params.id, { - name: name == null ? undefined : String(name), - config: config ?? undefined, - enabled: enabled == null ? undefined : Boolean(enabled), - }); - if (!workflow) { - res.status(404).json({ error: "Workflow not found" }); - return; - } - res.json(workflowApiRow(workflow)); - }); - - // Delete a workflow and detach anything that references it so no dangling - // schedules/hooks/runs are left pointing at a missing workflow. - router.delete("/workflows/:id", (req, res) => { - const id = req.params.id; - const wf = tdb(req) - .prepare(`SELECT id FROM ai_workflows WHERE id = ?`) - .get(id) as { id: string } | undefined; - if (!wf) { - res.status(404).json({ error: "Workflow not found" }); - return; - } - // Tenant-scoped dependents: cron schedules + finished run history. - const schedules = tdb(req) - .prepare(`DELETE FROM ai_schedules WHERE workflow_id = ?`) - .run(id).changes; - tdb(req).prepare(`DELETE FROM ai_workflow_runs WHERE workflow_id = ?`).run(id); - tdb(req).prepare(`DELETE FROM ai_workflow_comments WHERE workflow_id = ?`).run(id); - tdb(req).prepare(`DELETE FROM ai_workflows WHERE id = ?`).run(id); - // Core-db event/schedule hooks that run THIS workflow (action_config_json - // carries {"workflowId": id}). Remove them so they don't fire a ghost run. - let hooks = 0; - try { - hooks = getCoreDb() - .prepare( - `DELETE FROM hooks - WHERE action_kind = 'run_workflow' AND action_config_json LIKE ?` - ) - .run(`%"workflowId":"${id}"%`).changes; - } catch { - /* core hooks optional */ - } - scheduler.reload(); - refreshScheduler(); - res.json({ ok: true, deleted: { workflow: id, schedules, hooks } }); - }); - router.get("/workflows/:id/comments", (req, res) => { const rows = tdb(req) .prepare( @@ -2469,20 +1605,6 @@ export function createAiRouter( res.json({ comments: rows }); }); - router.post("/workflows/:id/comments", (req, res) => { - const body = String(req.body?.body ?? "").trim(); - if (!body) { - res.status(400).json({ error: "body required" }); - return; - } - const author = req.body?.author === "agent" ? "agent" : "user"; - const id = uuidv4(); - tdb(req).prepare( - `INSERT INTO ai_workflow_comments (id, workflow_id, author, body) VALUES (?, ?, ?, ?)` - ).run(id, req.params.id, author, body); - res.status(201).json(tdb(req).prepare(`SELECT * FROM ai_workflow_comments WHERE id = ?`).get(id)); - }); - // --- Workflow runs (durable pause/resume for the autonomous runner) --- router.get("/workflows/runs", (req, res) => { const status = req.query.status as string | undefined; @@ -2524,126 +1646,10 @@ export function createAiRouter( res.json(row); }); - router.post("/workflows/runs/:id/resume", (req, res) => { - const run = tdb(req) - .prepare(`SELECT id, status, card_id FROM ai_workflow_runs WHERE id = ?`) - .get(req.params.id) as - | { id: string; status: string; card_id: string | null } - | undefined; - if (!run) { - res.status(404).json({ error: "Run not found" }); - return; - } - if (run.status !== "awaiting_input") { - res.status(409).json({ error: `Run not awaiting input (status=${run.status})` }); - return; - } - const decision = req.body?.decision === "approve" ? "approve" : "request_changes"; - const comments = req.body?.comments ? String(req.body.comments) : undefined; - // Persist the reviewer's comment to the card thread for the change branch. - if (decision === "request_changes" && comments && run.card_id) { - tdb(req).prepare( - `INSERT INTO ai_card_comments (id, card_id, author, body) VALUES (?, ?, 'user', ?)` - ).run(uuidv4(), run.card_id, comments); - } - // Enqueue the resume so it obeys the serial queue and never blocks the - // request. The run lives in the caller's tenant DB, so route it back there. - queue.enqueue({ - context: { resumeRunId: run.id, resumeDecision: { decision, comments } }, - priority: 3, - tenantId: req.tenantId, - }); - res.json({ ok: true }); - }); - - router.post("/workflows/runs/:id/cancel", (req, res) => { - const r = tdb(req) - .prepare( - `UPDATE ai_workflow_runs SET status = 'failed', error = 'cancelled', updated_at = datetime('now') - WHERE id = ? AND status IN ('running','awaiting_input')` - ) - .run(req.params.id); - res.json({ ok: r.changes > 0 }); - }); - - // Kick the durable autonomous executor immediately (instead of waiting for - // its cron). No-op if a tick is already queued/running, so it never piles up. - router.post("/autonomous/kick", (req, res) => { - if (queue.hasPendingOrRunningWorkflow(AUTONOMOUS_RUNNER_ID)) { - res.json({ ok: true, alreadyRunning: true }); - return; - } - const jobId = queue.enqueue({ - workflowId: AUTONOMOUS_RUNNER_ID, - context: { autonomousTick: true, autoChainTick: 0 }, - priority: 1, - tenantId: req.tenantId, - }); - res.json({ ok: true, jobId }); - }); - router.get("/schedules", (req, res) => { res.json({ schedules: listSchedules(tdb(req)) }); }); - router.post("/schedules", (req, res) => { - const { workflowId, cronExpr, timezone, enabled } = req.body ?? {}; - if (!workflowId || !cronExpr) { - res.status(400).json({ error: "workflowId and cronExpr required" }); - return; - } - const schedule = createSchedule(tdb(req), { - workflowId: String(workflowId), - cronExpr: String(cronExpr), - timezone: timezone ? String(timezone) : undefined, - enabled: enabled == null ? undefined : Boolean(enabled), - }); - scheduler.reload(); - res.status(201).json(schedule); - }); - - router.put("/schedules/:id", (req, res) => { - const { cronExpr, timezone, enabled } = req.body ?? {}; - const schedule = updateSchedule(tdb(req), req.params.id, { - cronExpr: cronExpr == null ? undefined : String(cronExpr), - timezone: timezone == null ? undefined : String(timezone), - enabled: enabled == null ? undefined : Boolean(enabled), - }); - if (!schedule) { - res.status(404).json({ error: "Schedule not found" }); - return; - } - scheduler.reload(); - res.json(schedule); - }); - - router.delete("/schedules/:id", (req, res) => { - const deleted = deleteSchedule(tdb(req), req.params.id); - scheduler.reload(); - res.json({ ok: deleted }); - }); - - // Resolve (or lazily create) the single board project owned by an agent. The - // root 'intelligence' agent adopts the legacy 'default' project via the db - // backfill; other agents get a fresh project that reuses the shared canonical - // columns (backlog/in_progress/review/done) so the board UI keeps working. - const ensureAgentProject = (agentId: string, db: AppDatabase): string => { - const existing = db - .prepare(`SELECT id FROM ai_projects WHERE agent_id = ? ORDER BY created_at ASC LIMIT 1`) - .get(agentId) as { id: string } | undefined; - if (existing) return existing.id; - const agent = db - .prepare(`SELECT name FROM ai_agents WHERE id = ?`) - .get(agentId) as { name: string } | undefined; - const id = agentId === "intelligence" ? "default" : uuidv4(); - const name = `${agent?.name ?? "Agent"} Tasks`; - db.prepare( - `INSERT OR IGNORE INTO ai_projects (id, name, agent_id) VALUES (?, ?, ?)` - ).run(id, name, agentId); - db.prepare(`UPDATE ai_projects SET agent_id = ? WHERE id = ?`).run(agentId, id); - return id; - }; - router.get("/projects", (req, res) => { const agentId = String(req.query.agentId ?? "intelligence"); ensureAgentProject(agentId, tdb(req)); @@ -2666,203 +1672,6 @@ export function createAiRouter( res.json({ projects, columns, cards }); }); - router.post("/projects/cards", (req, res) => { - const id = uuidv4(); - const { - projectId, - agentId, - columnId, - title, - description, - prompt, - contextJson, - tags, - dueAt, - linkedChatId, - linkedWorkflowId, - priority, - parentCardId, - status, - assignedAgentId, - } = req.body ?? {}; - // Project resolution order: explicit projectId → parent card's project → - // the owning agent's board → legacy 'default'. - let pid: string; - if (projectId != null) { - pid = String(projectId); - } else if (parentCardId != null) { - const parent = tdb(req) - .prepare(`SELECT project_id FROM ai_project_cards WHERE id = ?`) - .get(String(parentCardId)) as { project_id: string } | undefined; - pid = parent?.project_id ?? ensureAgentProject(String(agentId ?? "intelligence"), tdb(req)); - } else { - pid = ensureAgentProject(String(agentId ?? "intelligence"), tdb(req)); - } - const cid = String(columnId ?? "backlog"); - const ctx = - contextJson == null - ? null - : typeof contextJson === "string" - ? contextJson - : JSON.stringify(contextJson); - const maxOrder = tdb(req) - .prepare(`SELECT COALESCE(MAX(sort_order), -1) as m FROM ai_project_cards WHERE column_id = ?`) - .get(cid) as { m: number }; - tdb(req).prepare( - `INSERT INTO ai_project_cards (id, project_id, column_id, title, description, prompt, context_json, tags_json, due_at, linked_chat_id, linked_workflow_id, priority, parent_card_id, status, assigned_agent_id, sort_order) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` - ).run( - id, - pid, - cid, - String(title ?? "Untitled"), - description ?? null, - prompt ?? null, - ctx, - tags ?? null, - dueAt ?? null, - linkedChatId ?? null, - linkedWorkflowId ?? null, - priority != null ? Number(priority) : 2, - parentCardId ?? null, - status ?? null, - assignedAgentId ?? null, - maxOrder.m + 1 - ); - res.status(201).json(tdb(req).prepare(`SELECT * FROM ai_project_cards WHERE id = ?`).get(id)); - }); - - router.patch("/projects/cards/:id", (req, res) => { - const { - columnId, - sortOrder, - title, - description, - prompt, - contextJson, - tags, - dueAt, - linkedChatId, - linkedWorkflowId, - priority, - parentCardId, - status, - assignedAgentId, - } = req.body ?? {}; - const nextColumnId = columnId != null ? String(columnId) : null; - if (priority != null) { - tdb(req).prepare( - `UPDATE ai_project_cards SET priority = ?, updated_at = datetime('now') WHERE id = ?` - ).run(Number(priority), req.params.id); - } - if (parentCardId !== undefined) { - tdb(req).prepare( - `UPDATE ai_project_cards SET parent_card_id = ?, updated_at = datetime('now') WHERE id = ?` - ).run(parentCardId === null ? null : String(parentCardId), req.params.id); - } - if (status != null) { - tdb(req).prepare( - `UPDATE ai_project_cards SET status = ?, updated_at = datetime('now') WHERE id = ?` - ).run(String(status), req.params.id); - } - if (nextColumnId != null) { - tdb(req).prepare( - `UPDATE ai_project_cards SET column_id = ?, updated_at = datetime('now') WHERE id = ?` - ).run(nextColumnId, req.params.id); - if (nextColumnId === "done" && status == null) { - tdb(req).prepare( - `UPDATE ai_project_cards SET status = 'done', updated_at = datetime('now') WHERE id = ?` - ).run(req.params.id); - } - } - if (sortOrder != null) { - tdb(req).prepare( - `UPDATE ai_project_cards SET sort_order = ?, updated_at = datetime('now') WHERE id = ?` - ).run(Number(sortOrder), req.params.id); - } - if (title != null) { - tdb(req).prepare( - `UPDATE ai_project_cards SET title = ?, updated_at = datetime('now') WHERE id = ?` - ).run(String(title), req.params.id); - } - if (description != null) { - tdb(req).prepare( - `UPDATE ai_project_cards SET description = ?, updated_at = datetime('now') WHERE id = ?` - ).run(String(description), req.params.id); - } - if (prompt != null) { - tdb(req).prepare( - `UPDATE ai_project_cards SET prompt = ?, updated_at = datetime('now') WHERE id = ?` - ).run(String(prompt), req.params.id); - } - if (contextJson != null) { - const ctx = - typeof contextJson === "string" ? contextJson : JSON.stringify(contextJson); - tdb(req).prepare( - `UPDATE ai_project_cards SET context_json = ?, updated_at = datetime('now') WHERE id = ?` - ).run(ctx, req.params.id); - } - if (tags != null) { - tdb(req).prepare( - `UPDATE ai_project_cards SET tags_json = ?, updated_at = datetime('now') WHERE id = ?` - ).run(String(tags), req.params.id); - } - if (dueAt != null) { - tdb(req).prepare( - `UPDATE ai_project_cards SET due_at = ?, updated_at = datetime('now') WHERE id = ?` - ).run(String(dueAt), req.params.id); - } - if (linkedChatId != null) { - tdb(req).prepare( - `UPDATE ai_project_cards SET linked_chat_id = ?, updated_at = datetime('now') WHERE id = ?` - ).run(String(linkedChatId), req.params.id); - } - if (linkedWorkflowId != null) { - tdb(req).prepare( - `UPDATE ai_project_cards SET linked_workflow_id = ?, updated_at = datetime('now') WHERE id = ?` - ).run(String(linkedWorkflowId), req.params.id); - } - if (assignedAgentId !== undefined) { - tdb(req).prepare( - `UPDATE ai_project_cards SET assigned_agent_id = ?, updated_at = datetime('now') WHERE id = ?` - ).run(assignedAgentId === null ? null : String(assignedAgentId), req.params.id); - } - const updatedCard = tdb(req) - .prepare(`SELECT * FROM ai_project_cards WHERE id = ?`) - .get(req.params.id) as - | { id: string; column_id: string; status: string | null; project_id: string; assigned_agent_id: string | null } - | undefined; - // Emit a bus signal when a card reaches Done so any interested listener can - // react (knowledge maintenance is owned by the Reflection engine). - if ( - bus && - updatedCard && - (updatedCard.column_id === "done" || updatedCard.status === "accepted") - ) { - const owner = - updatedCard.assigned_agent_id ?? - ( - tdb(req) - .prepare(`SELECT agent_id FROM ai_projects WHERE id = ?`) - .get(updatedCard.project_id) as { agent_id: string | null } | undefined - )?.agent_id ?? - "intelligence"; - bus.emit("card_completed", { cardId: updatedCard.id, agentId: owner }); - } - // Live ping so in-chat Active-Work panels reflect phase/status moves at once. - broadcastCardActivity(req.tenantId, { - cardId: req.params.id, - agentId: updatedCard?.assigned_agent_id ?? null, - reason: "card_updated", - }); - res.json(updatedCard); - }); - - router.delete("/projects/cards/:id", (req, res) => { - tdb(req).prepare(`DELETE FROM ai_project_cards WHERE id = ?`).run(req.params.id); - res.json({ ok: true }); - }); - router.get("/projects/cards/:id/subtasks", (req, res) => { const db = tdb(req); reconcileParentProgress(db, req.params.id, req.tenantId); @@ -2888,34 +1697,6 @@ export function createAiRouter( res.json({ comments: rows }); }); - router.post("/projects/cards/:id/comments", (req, res) => { - const body = String(req.body?.body ?? "").trim(); - if (!body) { - res.status(400).json({ error: "body required" }); - return; - } - const author = req.body?.author === "agent" ? "agent" : "user"; - const id = uuidv4(); - tdb(req).prepare( - `INSERT INTO ai_card_comments (id, card_id, author, body) VALUES (?, ?, ?, ?)` - ).run(id, req.params.id, author, body); - // Resolve the card's agent/chat so live panels can scope the refetch. - const card = tdb(req) - .prepare( - `SELECT linked_chat_id, assigned_agent_id FROM ai_project_cards WHERE id = ?` - ) - .get(req.params.id) as - | { linked_chat_id: string | null; assigned_agent_id: string | null } - | undefined; - broadcastCardActivity(req.tenantId, { - cardId: req.params.id, - agentId: card?.assigned_agent_id ?? null, - chatId: card?.linked_chat_id ?? null, - reason: "comment", - }); - res.status(201).json(tdb(req).prepare(`SELECT * FROM ai_card_comments WHERE id = ?`).get(id)); - }); - router.get("/training/jobs", (req, res) => { res.json({ jobs: training.listJobs() }); }); @@ -2937,36 +1718,10 @@ export function createAiRouter( res.json(job); }); - router.post("/training/jobs", async (req, res) => { - try { - const id = await training.startJob(req.body ?? {}); - res.status(201).json({ id, job: training.getJob(id) }); - } catch (err) { - res.status(500).json({ error: String(err) }); - } - }); - - router.post("/training/jobs/:id/cancel", (req, res) => { - res.json({ ok: training.cancelJob() }); - }); - router.get("/datasets", (req, res) => { res.json({ datasets: tdb(req).prepare(`SELECT * FROM ai_datasets ORDER BY updated_at DESC`).all() }); }); - router.post("/datasets", (req, res) => { - const id = uuidv4(); - const { name, domain, path, rowCount } = req.body ?? {}; - if (!name || !path) { - res.status(400).json({ error: "name and path required" }); - return; - } - tdb(req).prepare( - `INSERT INTO ai_datasets (id, name, domain, path, row_count) VALUES (?, ?, ?, ?, ?)` - ).run(id, String(name), domain ?? null, String(path), Number(rowCount ?? 0)); - res.status(201).json(tdb(req).prepare(`SELECT * FROM ai_datasets WHERE id = ?`).get(id)); - }); - const VALID_SOURCES: DatasetSource[] = ["chats", "workflows", "queue", "comments"]; router.get("/datasets/sources", (req, res) => { @@ -2991,30 +1746,6 @@ export function createAiRouter( } }); - router.post("/datasets/build", (req, res) => { - const { name, domain, source, chatIds, limit } = req.body ?? {}; - if (!name || !String(name).trim()) { - res.status(400).json({ error: "name required" }); - return; - } - if (!VALID_SOURCES.includes(source)) { - res.status(400).json({ error: "Invalid source" }); - return; - } - try { - const row = datasetBuilderFor(req).buildDataset({ - name: String(name), - domain: domain ? String(domain) : undefined, - source, - chatIds: Array.isArray(chatIds) ? chatIds.map(String) : undefined, - limit: limit != null ? Number(limit) : undefined, - }); - res.status(201).json(row); - } catch (err) { - res.status(500).json({ error: err instanceof Error ? err.message : String(err) }); - } - }); - // --- Per-agent calendar (Google-Calendar-like events + derived activity) --- const CALENDAR_KINDS = new Set(["event", "task", "appointment"]); @@ -3040,98 +1771,6 @@ export function createAiRouter( res.json({ events }); }); - router.post("/calendar/events", (req, res) => { - const { - agentId, - kind, - title, - description, - start_at, - end_at, - all_day, - location, - linked_card_id, - linked_run_id, - status, - } = req.body ?? {}; - if (!title || !String(title).trim() || !start_at || !String(start_at).trim()) { - res.status(400).json({ error: "title and start_at required" }); - return; - } - const id = uuidv4(); - const k = CALENDAR_KINDS.has(String(kind)) ? String(kind) : "event"; - tdb(req).prepare( - `INSERT INTO ai_calendar_events - (id, agent_id, kind, title, description, start_at, end_at, all_day, location, linked_card_id, linked_run_id, status) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` - ).run( - id, - String(agentId ?? "intelligence"), - k, - String(title), - description ?? null, - String(start_at), - end_at ?? null, - all_day ? 1 : 0, - location ?? null, - linked_card_id ?? null, - linked_run_id ?? null, - status ? String(status) : "scheduled" - ); - res.status(201).json(tdb(req).prepare(`SELECT * FROM ai_calendar_events WHERE id = ?`).get(id)); - }); - - router.patch("/calendar/events/:id", (req, res) => { - const { title, description, start_at, end_at, all_day, location, kind, status } = - req.body ?? {}; - if (title != null) { - tdb(req).prepare( - `UPDATE ai_calendar_events SET title = ?, updated_at = datetime('now') WHERE id = ?` - ).run(String(title), req.params.id); - } - if (description !== undefined) { - tdb(req).prepare( - `UPDATE ai_calendar_events SET description = ?, updated_at = datetime('now') WHERE id = ?` - ).run(description === null ? null : String(description), req.params.id); - } - if (start_at != null) { - tdb(req).prepare( - `UPDATE ai_calendar_events SET start_at = ?, updated_at = datetime('now') WHERE id = ?` - ).run(String(start_at), req.params.id); - } - if (end_at !== undefined) { - tdb(req).prepare( - `UPDATE ai_calendar_events SET end_at = ?, updated_at = datetime('now') WHERE id = ?` - ).run(end_at === null ? null : String(end_at), req.params.id); - } - if (all_day != null) { - tdb(req).prepare( - `UPDATE ai_calendar_events SET all_day = ?, updated_at = datetime('now') WHERE id = ?` - ).run(all_day ? 1 : 0, req.params.id); - } - if (location !== undefined) { - tdb(req).prepare( - `UPDATE ai_calendar_events SET location = ?, updated_at = datetime('now') WHERE id = ?` - ).run(location === null ? null : String(location), req.params.id); - } - if (kind != null && CALENDAR_KINDS.has(String(kind))) { - tdb(req).prepare( - `UPDATE ai_calendar_events SET kind = ?, updated_at = datetime('now') WHERE id = ?` - ).run(String(kind), req.params.id); - } - if (status != null) { - tdb(req).prepare( - `UPDATE ai_calendar_events SET status = ?, updated_at = datetime('now') WHERE id = ?` - ).run(String(status), req.params.id); - } - res.json(tdb(req).prepare(`SELECT * FROM ai_calendar_events WHERE id = ?`).get(req.params.id)); - }); - - router.delete("/calendar/events/:id", (req, res) => { - tdb(req).prepare(`DELETE FROM ai_calendar_events WHERE id = ?`).run(req.params.id); - res.json({ ok: true }); - }); - // Read-only timeline activity derived from real agent work: workflow runs // (joined to their owning agent via ai_workflows) plus optional due cards. router.get("/calendar/activity", (req, res) => { diff --git a/apps/bridge/src/routes/api-core.ts b/apps/bridge/src/routes/api-core.ts index 3301000..2860933 100644 --- a/apps/bridge/src/routes/api-core.ts +++ b/apps/bridge/src/routes/api-core.ts @@ -92,355 +92,6 @@ export function createCoreApiRouter( } }); - router.put("/structure/graph/layout", (req, res) => { - try { - const layout = req.body?.layout ?? req.body; - if (!layout || typeof layout !== "object") { - res.status(400).json({ error: "layout required" }); - return; - } - writeStructureGraphLayout(tdb(req), layout); - res.json({ ok: true }); - } catch (err) { - handleStructureError(err, res); - } - }); - - router.post("/departments", (req, res) => { - try { - const body = req.body ?? {}; - createRecord( - tdb(req), - "StructureNode", - { - id: String(body.id ?? ""), - parent_id: null, - label: String(body.label ?? ""), - icon: String(body.icon ?? ""), - }, - kernelContext(req) - ); - const dept = structureNodesToLegacy(readStructure(tdb(req))).departments.find( - (item) => item.id === String(body.id ?? "") - ); - res.json(dept); - } catch (err) { - handleStructureError(err, res); - } - }); - - router.put("/departments/:id", (req, res) => { - try { - updateRecord( - tdb(req), - "StructureNode", - req.params.id, - req.body ?? {}, - kernelContext(req) - ); - res.json({ ok: true }); - } catch (err) { - handleStructureError(err, res); - } - }); - - router.delete("/departments/:id", (req, res) => { - try { - deleteRecord(tdb(req), "StructureNode", req.params.id, kernelContext(req)); - res.json({ ok: true }); - } catch (err) { - handleStructureError(err, res); - } - }); - - router.post("/departments/:dept/divisions", (req, res) => { - try { - const body = req.body ?? {}; - createRecord( - tdb(req), - "StructureNode", - { - id: String(body.id ?? ""), - parent_id: req.params.dept, - label: String(body.label ?? ""), - icon: String(body.icon ?? ""), - right_sidebar: body.rightSidebar, - }, - kernelContext(req) - ); - const div = structureNodesToLegacy(readStructure(tdb(req))) - .departments.find((item) => item.id === req.params.dept) - ?.divisions.find((item) => item.id === String(body.id ?? "")); - res.json(div); - } catch (err) { - handleStructureError(err, res); - } - }); - - router.put("/divisions/:dept/:id", (req, res) => { - try { - const body = req.body ?? {}; - updateRecord( - tdb(req), - "StructureNode", - `${req.params.dept}-${req.params.id}`, - { - label: body.label, - icon: body.icon, - right_sidebar: body.rightSidebar, - }, - kernelContext(req) - ); - res.json({ ok: true }); - } catch (err) { - handleStructureError(err, res); - } - }); - - router.delete("/divisions/:dept/:id", (req, res) => { - try { - deleteRecord( - tdb(req), - "StructureNode", - `${req.params.dept}-${req.params.id}`, - kernelContext(req) - ); - res.json({ ok: true }); - } catch (err) { - handleStructureError(err, res); - } - }); - - router.post("/divisions/:dept/:div/pages", (req, res) => { - try { - const body = req.body ?? {}; - createRecord( - tdb(req), - "StructureNode", - { - id: String(body.id ?? ""), - parent_id: `${req.params.dept}-${req.params.div}`, - label: String(body.label ?? ""), - icon: String(body.icon ?? ""), - segment: String(body.segment ?? ""), - kind: body.kind ?? body.pageKind, - }, - kernelContext(req) - ); - const page = structureNodesToLegacy(readStructure(tdb(req))) - .departments.find((item) => item.id === req.params.dept) - ?.divisions.find((item) => item.id === req.params.div) - ?.pages.find((item) => item.id === String(body.id ?? "")); - res.json(page); - } catch (err) { - handleStructureError(err, res); - } - }); - - router.put("/pages/:dept/:div/:id", (req, res) => { - try { - const body = req.body ?? {}; - updateRecord( - tdb(req), - "StructureNode", - `${req.params.dept}-${req.params.div}-${req.params.id}`, - { - label: body.label, - icon: body.icon, - segment: body.segment, - kind: body.kind ?? body.pageKind, - }, - kernelContext(req) - ); - res.json({ ok: true }); - } catch (err) { - handleStructureError(err, res); - } - }); - - router.delete("/pages/:dept/:div/:id", (req, res) => { - try { - deleteRecord( - tdb(req), - "StructureNode", - `${req.params.dept}-${req.params.div}-${req.params.id}`, - kernelContext(req) - ); - res.json({ ok: true }); - } catch (err) { - handleStructureError(err, res); - } - }); - - router.post("/nodes", (req, res) => { - try { - const body = req.body ?? {}; - const node = createRecord(tdb(req), "StructureNode", { - id: String(body.id ?? ""), - parent_id: - body.parentId === null || body.parentId === undefined - ? null - : String(body.parentId), - label: String(body.label ?? ""), - icon: String(body.icon ?? ""), - segment: body.segment != null ? String(body.segment) : undefined, - kind: body.kind != null ? String(body.kind) : undefined, - object_type: - body.objectType === null - ? null - : body.objectType != null - ? String(body.objectType) - : undefined, - right_sidebar: - body.rightSidebar != null ? String(body.rightSidebar) : undefined, - }, kernelContext(req)); - res.json({ - id: node.id, - parentId: node.data.parent_id ?? null, - label: node.data.label, - icon: node.data.icon, - segment: node.data.segment, - kind: node.data.kind, - objectType: node.data.object_type ?? null, - rightSidebar: node.data.right_sidebar ?? null, - agentId: node.data.agent_id ?? null, - builtIn: node.data.built_in, - sortOrder: node.data.sort_order, - tabs: node.data.tabs_json, - path: node.data.path, - }); - } catch (err) { - handleStructureError(err, res); - } - }); - - router.put("/nodes/:id", (req, res) => { - try { - const body = req.body ?? {}; - updateRecord( - tdb(req), - "StructureNode", - req.params.id, - { - label: body.label, - icon: body.icon, - segment: body.segment, - kind: body.kind, - parent_id: body.parentId, - object_type: body.objectType, - right_sidebar: body.rightSidebar, - }, - kernelContext(req) - ); - res.json({ ok: true }); - } catch (err) { - handleStructureError(err, res); - } - }); - - router.delete("/nodes/:id", (req, res) => { - try { - deleteRecord(tdb(req), "StructureNode", req.params.id, kernelContext(req)); - res.json({ ok: true }); - } catch (err) { - handleStructureError(err, res); - } - }); - - router.post("/nodes/:id/agent", async (req, res) => { - try { - const agentId = - req.body?.agentId === null || req.body?.agentId === undefined - ? null - : String(req.body.agentId); - await executeRecordAction( - tdb(req), - "StructureNode", - req.params.id, - "set_agent", - { agent_id: agentId }, - kernelContext(req) - ); - res.json({ ok: true }); - } catch (err) { - handleStructureError(err, res); - } - }); - - router.delete("/nodes/:id/agent", async (req, res) => { - try { - await executeRecordAction( - tdb(req), - "StructureNode", - req.params.id, - "set_agent", - { agent_id: null }, - kernelContext(req) - ); - res.json({ ok: true }); - } catch (err) { - handleStructureError(err, res); - } - }); - - router.post("/structure/reorder", async (req, res) => { - try { - const body = req.body ?? {}; - const orderedIds = Array.isArray(body.orderedIds) - ? (body.orderedIds as string[]) - : []; - if (typeof body.parentId === "string" || body.parentId === null) { - await executeCollectionAction( - tdb(req), - "StructureNode", - "reorder", - { - parent_id: - body.parentId === null ? null : String(body.parentId), - ordered_ids: orderedIds, - }, - kernelContext(req) - ); - return res.json({ ok: true }); - } - const kind = body.kind as ReorderKind; - if (kind !== "department" && kind !== "division" && kind !== "page") { - return res.status(400).json({ error: "invalid kind" }); - } - const departmentId = - typeof body.departmentId === "string" ? body.departmentId : undefined; - const divisionId = - typeof body.divisionId === "string" ? body.divisionId : undefined; - const parentId = - kind === "department" - ? null - : kind === "division" - ? departmentId - : departmentId && divisionId - ? `${departmentId}-${divisionId}` - : undefined; - if (parentId === undefined) { - return res.status(400).json({ error: "parent scope required" }); - } - const normalizedIds = - kind === "department" - ? orderedIds - : orderedIds.map((id) => - id.startsWith(`${parentId}-`) ? id : `${parentId}-${id}` - ); - await executeCollectionAction( - tdb(req), - "StructureNode", - "reorder", - { parent_id: parentId, ordered_ids: normalizedIds }, - kernelContext(req) - ); - res.json({ ok: true }); - } catch (err) { - handleStructureError(err, res); - } - }); - router.get("/storage/usage", (req, res) => { try { const report = getStorageUsage(tdb(req)); diff --git a/apps/bridge/src/routes/auth.ts b/apps/bridge/src/routes/auth.ts index 8e539c4..dcea142 100644 --- a/apps/bridge/src/routes/auth.ts +++ b/apps/bridge/src/routes/auth.ts @@ -1,5 +1,4 @@ import { Router } from "express"; -import { v4 as uuidv4 } from "uuid"; import { config } from "../config.js"; import { getCoreDb, @@ -14,9 +13,8 @@ import { deleteSession, issueSessionCookies, parseSessionCookie, - slugFromEmail, } from "../services/auth/session-store.js"; -import { hashPassword, verifyPassword } from "../services/auth/password.js"; +import { verifyPassword } from "../services/auth/password.js"; import { attachAuthContext, getOperatorTenantIdCached, @@ -25,16 +23,18 @@ import { resolveTenant, } from "../services/auth/middleware.js"; import { - createTenantForUser, listUserTenants, - promoteFirstSignupAdmin, userHasTenantAccess, } from "../services/tenant-bootstrap.js"; import { refreshUserAgentPrompt } from "../services/agents/user-agent.js"; import { getUserOwnerTenantDb } from "../services/user-scope.js"; import { rateLimit } from "../services/auth/rate-limit.js"; -import { seedPersonalOsForNewTenant } from "../services/personal-os-seed.js"; -import { getTenantDb } from "../tenant-registry.js"; +import { + createSystemOperationContext, + executeCollectionAction, + executeRecordAction, + KernelError, +} from "../kernel/record-api.js"; export function createAuthRouter(): Router { const router = Router(); @@ -51,38 +51,6 @@ export function createAuthRouter(): Router { res.json({ tenants, operatorTenantId: getOperatorTenantIdCached() }); }); - router.post("/tenants", attachAuthContext, requireAuth, (req, res) => { - if (config.isClient) { - res.status(403).json({ error: "Additional workspaces are disabled in client mode" }); - return; - } - const { name, slug } = req.body ?? {}; - if (typeof name !== "string" || !name.trim()) { - res.status(400).json({ error: "name required" }); - return; - } - const tenantSlug = - typeof slug === "string" && slug.trim() - ? slug.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-") - : slugFromEmail(req.user!.email); - const core = getCoreDb(); - const existing = core - .prepare("SELECT id FROM tenants WHERE slug=?") - .get(tenantSlug); - if (existing) { - res.status(409).json({ error: "slug taken" }); - return; - } - const tenantId = createTenantForUser( - core, - req.user!.id, - name.trim(), - tenantSlug - ); - seedPersonalOsForNewTenant(getTenantDb(tenantId)); - res.status(201).json({ id: tenantId, slug: tenantSlug }); - }); - router.post("/login", authLimiter, (req, res) => { const { email, password } = req.body ?? {}; if (typeof email !== "string" || typeof password !== "string") { @@ -110,7 +78,7 @@ export function createAuthRouter(): Router { }); }); - router.post("/signup", authLimiter, (req, res) => { + router.post("/signup", authLimiter, async (req, res) => { if (!config.auth.allowSignup) { res.status(403).json({ error: "Signup is disabled; request an invite or contact the admin" }); return; @@ -145,26 +113,37 @@ export function createAuthRouter(): Router { return; } - const id = uuidv4(); - core.prepare( - `INSERT INTO users (id, email, display_name, avatar_url, is_admin, password_hash) - VALUES (?, ?, ?, NULL, 0, ?)` - ).run(id, normalized, displayName, hashPassword(password)); - - const tenantId = createTenantForUser(core, id, `${displayName}'s Project`, slugFromEmail(normalized)); - seedPersonalOsForNewTenant(getTenantDb(tenantId)); - promoteFirstSignupAdmin(core, id); - - const user = core.prepare("SELECT * FROM users WHERE id=?").get(id) as CoreUser; - const sessionId = createSession(core, id, config.auth.sessionTtlDays); - res.setHeader( - "Set-Cookie", - issueSessionCookies(sessionId, config.auth.sessionTtlDays, secure) - ); - res.status(201).json({ - user: coreUserToAuth(user), - ...(config.isProduction ? {} : { sessionToken: sessionId }), - }); + try { + const created = await executeCollectionAction( + core, + "User", + "signup", + { + email: normalized, + password, + display_name: displayName, + }, + createSystemOperationContext({ + requestId: req.get("X-Request-Id") || undefined, + }) + ) as { id: string }; + const user = core.prepare("SELECT * FROM users WHERE id=?").get(created.id) as CoreUser; + const sessionId = createSession(core, user.id, config.auth.sessionTtlDays); + res.setHeader( + "Set-Cookie", + issueSessionCookies(sessionId, config.auth.sessionTtlDays, secure) + ); + res.status(201).json({ + user: coreUserToAuth(user), + ...(config.isProduction ? {} : { sessionToken: sessionId }), + }); + } catch (err) { + if (err instanceof KernelError) { + res.status(err.status).json({ error: err.message }); + return; + } + throw err; + } }); router.post("/logout", attachAuthContext, (req, res) => { @@ -178,7 +157,7 @@ export function createAuthRouter(): Router { res.json({ ok: true }); }); - router.post("/change-password", attachAuthContext, requireAuth, (req, res) => { + router.post("/change-password", attachAuthContext, requireAuth, async (req, res) => { const { currentPassword, newPassword } = req.body ?? {}; if ( typeof currentPassword !== "string" || @@ -190,18 +169,31 @@ export function createAuthRouter(): Router { res.status(400).json({ error: "currentPassword and newPassword (min 6 chars) required" }); return; } - const core = getCoreDb(); - const user = core - .prepare("SELECT * FROM users WHERE id=?") - .get(req.user!.id) as CoreUser | undefined; - if (!user?.password_hash || !verifyPassword(currentPassword, user.password_hash)) { - res.status(401).json({ error: "Current password is incorrect" }); - return; + try { + await executeRecordAction( + getCoreDb(), + "UserCredential", + req.user!.id, + "change_password", + { + current_password: currentPassword, + new_password: newPassword, + }, + { + userId: req.user!.id, + isAdmin: req.user!.isAdmin, + role: "editor", + source: "http", + } + ); + res.json({ ok: true }); + } catch (err) { + if (err instanceof KernelError) { + res.status(err.status).json({ error: err.message }); + return; + } + throw err; } - core.prepare( - `UPDATE users SET password_hash=?, updated_at=datetime('now') WHERE id=?` - ).run(hashPassword(newPassword), user.id); - res.json({ ok: true }); }); router.get("/profile", attachAuthContext, requireAuth, (req, res) => { @@ -219,90 +211,6 @@ export function createAuthRouter(): Router { res.json({ profile: mergeProfile(user, profile) }); }); - router.patch("/profile", attachAuthContext, requireAuth, (req, res) => { - const body = (req.body ?? {}) as Record; - const str = (v: unknown): string | null => { - if (v == null) return null; - const trimmed = String(v).trim(); - return trimmed ? trimmed : null; - }; - - const core = getCoreDb(); - const userId = req.user!.id; - - if (typeof body.displayName === "string") { - const displayName = body.displayName.trim(); - if (!displayName) { - res.status(400).json({ error: "displayName cannot be empty" }); - return; - } - core.prepare( - `UPDATE users SET display_name=?, updated_at=datetime('now') WHERE id=?` - ).run(displayName, userId); - } - if ("avatarUrl" in body) { - core.prepare( - `UPDATE users SET avatar_url=?, updated_at=datetime('now') WHERE id=?` - ).run(str(body.avatarUrl), userId); - } - - core.prepare( - `INSERT INTO user_profiles (user_id) VALUES (?) - ON CONFLICT(user_id) DO NOTHING` - ).run(userId); - - const fieldMap: Record = { - headline: "headline", - bio: "bio", - pronouns: "pronouns", - location: "location", - timezone: "timezone", - phone: "phone", - company: "company", - jobTitle: "job_title", - website: "website", - twitter: "twitter", - github: "github", - linkedin: "linkedin", - emoji: "emoji", - birthday: "birthday", - languages: "languages", - interests: "interests", - values: "values", - goals: "goals", - personalityNotes: "personality_notes", - decisionStyle: "decision_style", - riskTolerance: "risk_tolerance", - }; - const sets: string[] = []; - const values: Array = []; - for (const [key, column] of Object.entries(fieldMap)) { - if (key in body) { - sets.push(`"${column}"=?`); - values.push(str(body[key])); - } - } - if (sets.length > 0) { - values.push(userId); - core.prepare( - `UPDATE user_profiles SET ${sets.join(", ")}, updated_at=datetime('now') WHERE user_id=?` - ).run(...values); - } - - const user = core - .prepare("SELECT * FROM users WHERE id=?") - .get(userId) as CoreUser; - const profile = core - .prepare("SELECT * FROM user_profiles WHERE user_id=?") - .get(userId) as CoreUserProfile | undefined; - try { - refreshUserAgentPrompt(getUserOwnerTenantDb(userId), userId); - } catch { - /* tenant may not exist yet */ - } - res.json({ profile: mergeProfile(user, profile) }); - }); - router.get( "/users/lookup", attachAuthContext, diff --git a/apps/bridge/src/routes/connections.ts b/apps/bridge/src/routes/connections.ts index 2a3fb7e..8904f87 100644 --- a/apps/bridge/src/routes/connections.ts +++ b/apps/bridge/src/routes/connections.ts @@ -25,108 +25,6 @@ export function createConnectionsRouter(): Router { res.json({ connections, bridgePublicUrl: config.auth.publicUrl }); }); - router.post("/", (req, res) => { - const mode = req.body?.mode === "remote" ? "remote" : "local"; - if (mode === "local") { - const label = - typeof req.body?.label === "string" && req.body.label.trim() - ? req.body.label.trim() - : "Local connector"; - const core = getCoreDb(); - const existing = listBridgeConnections(core, req.tenantId!).find( - (c) => c.mode === "local" - ); - if (existing) { - res.json({ connection: existing }); - return; - } - const connection = createBridgeConnection(core, { - ownerTenantId: req.tenantId!, - ownerUserId: req.user!.id, - label, - mode: "local", - }); - res.status(201).json({ connection }); - return; - } - const { label, remoteBridgeUrl, remoteBridgeToken } = req.body ?? {}; - if (typeof remoteBridgeUrl !== "string" || !remoteBridgeUrl.trim()) { - res.status(400).json({ error: "remoteBridgeUrl required" }); - return; - } - if (typeof remoteBridgeToken !== "string" || !remoteBridgeToken.trim()) { - res.status(400).json({ error: "remoteBridgeToken required" }); - return; - } - const connection = createBridgeConnection(getCoreDb(), { - ownerTenantId: req.tenantId!, - ownerUserId: req.user!.id, - label: - typeof label === "string" && label.trim() ? label.trim() : "Remote Bridge", - mode: "remote", - remoteBridgeUrl: remoteBridgeUrl.trim(), - remoteBridgeToken: remoteBridgeToken.trim(), - }); - res.status(201).json({ connection }); - }); - - router.delete("/:id", (req, res) => { - const core = getCoreDb(); - const row = listBridgeConnections(core, req.tenantId!).find( - (c) => c.id === req.params.id - ); - if (!row) { - res.status(404).json({ error: "Connection not found" }); - return; - } - deleteBridgeConnection(core, req.params.id); - res.json({ ok: true }); - }); - - router.post("/local", (req, res) => { - const label = - typeof req.body?.label === "string" && req.body.label.trim() - ? req.body.label.trim() - : "Local connector"; - const core = getCoreDb(); - const existing = listBridgeConnections(core, req.tenantId!).find( - (c) => c.mode === "local" - ); - if (existing) { - res.json({ connection: existing, created: false }); - return; - } - const connection = createBridgeConnection(core, { - ownerTenantId: req.tenantId!, - ownerUserId: req.user!.id, - label, - mode: "local", - }); - res.status(201).json({ connection, created: true }); - }); - - router.post("/remote", (req, res) => { - const { label, remoteBridgeUrl, remoteBridgeToken } = req.body ?? {}; - if (typeof remoteBridgeUrl !== "string" || !remoteBridgeUrl.trim()) { - res.status(400).json({ error: "remoteBridgeUrl required" }); - return; - } - if (typeof remoteBridgeToken !== "string" || !remoteBridgeToken.trim()) { - res.status(400).json({ error: "remoteBridgeToken required" }); - return; - } - const connection = createBridgeConnection(getCoreDb(), { - ownerTenantId: req.tenantId!, - ownerUserId: req.user!.id, - label: - typeof label === "string" && label.trim() ? label.trim() : "Remote Bridge", - mode: "remote", - remoteBridgeUrl: remoteBridgeUrl.trim(), - remoteBridgeToken: remoteBridgeToken.trim(), - }); - res.status(201).json({ connection }); - }); - router.get("/resolve/:kind/:resourceId", (req, res) => { const resolved = resolveConnectionForResource(getCoreDb(), { userId: req.user!.id, @@ -137,31 +35,5 @@ export function createConnectionsRouter(): Router { res.json({ resolved, bridgePublicUrl: config.auth.publicUrl }); }); - router.post("/federation/execute", async (req, res) => { - const { resourceKind, resourceId, line, chartbookKey } = req.body ?? {}; - if (typeof line !== "string" || !line.trim()) { - res.status(400).json({ error: "line required" }); - return; - } - if (typeof resourceKind !== "string" || typeof resourceId !== "string") { - res.status(400).json({ error: "resourceKind and resourceId required" }); - return; - } - const resolved = resolveConnectionForResource(getCoreDb(), { - userId: req.user!.id, - tenantId: req.tenantId!, - resourceKind: resourceKind as MarketplaceListingKind, - resourceId, - }); - const result = await dispatchScLine({ - resolved, - line: line.trim(), - chartbookKey: typeof chartbookKey === "string" ? chartbookKey : undefined, - localEnqueue: (line, chartbookKey) => - getPluginHost().enqueueScLine!(line, chartbookKey), - }); - res.status(result.ok ? 200 : 502).json(result); - }); - return router; } diff --git a/apps/bridge/src/routes/dm.ts b/apps/bridge/src/routes/dm.ts index 4353071..0cd9a33 100644 --- a/apps/bridge/src/routes/dm.ts +++ b/apps/bridge/src/routes/dm.ts @@ -10,6 +10,7 @@ import { } from "../services/auth/middleware.js"; import { addConversationMember, + assertConversationMember, createConversation, createMessage, DmError, @@ -30,12 +31,15 @@ import { BlobStoreError, getDmBlob, readDmBlobBytes, - storeDmBlob, } from "../services/blob-store.js"; import { getShareBroker } from "../ws-broker.js"; import { isUserOnline } from "../services/presence.js"; import { createNotification } from "../services/notification-service.js"; import { emitEvent } from "../services/event-bus.js"; +import { + executeCollectionAction, + KernelError, +} from "../kernel/record-api.js"; const upload = multer({ storage: multer.memoryStorage(), @@ -60,6 +64,29 @@ function broadcastDm( } } +export function authorizeTypingEvent( + core: ReturnType, + conversationId: string, + authenticatedUserId: string, + body: unknown +): string[] { + assertConversationMember(core, conversationId, authenticatedUserId); + const input = + body && typeof body === "object" + ? (body as Record) + : {}; + for (const field of ["userId", "senderUserId", "sender_user_id"]) { + const claimed = input[field]; + if ( + claimed !== undefined && + (typeof claimed !== "string" || claimed !== authenticatedUserId) + ) { + throw new DmError("Typing sender does not match authenticated user", 403); + } + } + return listConversationMemberUserIds(core, conversationId); +} + export interface DmRouterDeps { llm: LlmManager; bridgePort: number; @@ -85,59 +112,6 @@ export function createDmRouter(deps: DmRouterDeps): Router { res.json({ conversations }); }); - router.post("/conversations", resolveTenant, (req, res) => { - const { kind, title, memberUserIds, memberEmails, memberAgents } = req.body ?? {}; - const core = getCoreDb(); - const userId = req.user!.id; - const tenantId = req.tenantId!; - const ids = new Set( - Array.isArray(memberUserIds) - ? memberUserIds.filter((id: unknown) => typeof id === "string") - : [] - ); - - if (Array.isArray(memberEmails)) { - for (const email of memberEmails) { - if (typeof email !== "string") continue; - const found = lookupUserByEmail(core, email, userId); - if (found) ids.add(found.id); - } - } - - const agents = Array.isArray(memberAgents) - ? memberAgents - .filter( - (a: unknown) => - a && - typeof a === "object" && - typeof (a as { agentId?: string }).agentId === "string" - ) - .map((a: { agentId: string; agentTenantId?: string }) => ({ - agentId: a.agentId, - agentTenantId: a.agentTenantId ?? tenantId, - })) - : []; - - try { - const conversation = createConversation(core, { - creatorUserId: userId, - kind: kind === "group" ? "group" : "direct", - title: typeof title === "string" ? title : null, - memberUserIds: Array.from(ids), - memberAgents: agents, - }); - const memberIds = listConversationMemberUserIds(core, conversation.id); - broadcastDm(conversation.id, "dm_conversation_created", { conversation }, memberIds); - res.status(201).json({ conversation }); - } catch (err) { - if (err instanceof DmError) { - res.status(err.status).json({ error: err.message }); - return; - } - throw err; - } - }); - router.get("/conversations/:id", (req, res) => { try { const conversation = getConversationForUser( @@ -175,160 +149,21 @@ export function createDmRouter(deps: DmRouterDeps): Router { } }); - router.post("/conversations/:id/messages", resolveTenant, (req, res) => { - const { bodyText, attachments } = req.body ?? {}; + router.post("/conversations/:id/typing", (req, res) => { const core = getCoreDb(); - const userId = req.user!.id; - const tenantId = req.tenantId!; const conversationId = paramId(req.params.id); - try { - const message = createMessage(core, { - conversationId, - senderUserId: userId, - bodyText: typeof bodyText === "string" ? bodyText : "", - attachments: Array.isArray(attachments) ? attachments : [], - }); - const memberIds = listConversationMemberUserIds(core, conversationId); - broadcastDm(conversationId, "dm_message", { message, conversationId }, memberIds); - - const senderName = message.sender?.displayName ?? "Someone"; - const preview = - typeof bodyText === "string" && bodyText.trim() - ? bodyText.trim().slice(0, 140) - : "Sent an attachment"; - for (const memberId of memberIds) { - if (memberId === userId || isUserOnline(memberId)) continue; - createNotification({ - recipientKind: "user", - recipientId: memberId, - category: "dm", - title: `New message from ${senderName}`, - body: preview, - link: "/?conversation=" + conversationId, - resourceKind: "conversation", - resourceId: conversationId, - }); - } - - emitEvent({ - type: "dm.message.created", - actor: { kind: "user", id: userId }, - tenantId, - payload: { - conversationId, - messageId: message.id, - senderUserId: userId, - senderDisplayName: senderName, - text: typeof bodyText === "string" ? bodyText : "", - }, - }); - - const text = typeof bodyText === "string" ? bodyText.trim() : ""; - if (text) { - scheduleAgentResponses( - { llm: deps.llm, bridgePort: deps.bridgePort }, - { - core, - conversationId, - messageText: text, - senderUserId: userId, - senderDisplayName: message.sender?.displayName ?? "User", - tenantId, - } - ); - } - - res.status(201).json({ message }); - } catch (err) { - if (err instanceof DmError) { - res.status(err.status).json({ error: err.message }); - return; - } - throw err; - } - }); - - router.post("/conversations/:id/read", (req, res) => { - const { messageId } = req.body ?? {}; - const core = getCoreDb(); const userId = req.user!.id; - const conversationId = paramId(req.params.id); try { - markConversationRead( + const memberIds = authorizeTypingEvent( core, conversationId, userId, - typeof messageId === "string" ? messageId : undefined + req.body ); - const memberIds = listConversationMemberUserIds(core, conversationId); broadcastDm( conversationId, - "dm_read", - { conversationId, userId, messageId: messageId ?? null }, - memberIds - ); - res.json({ ok: true }); - } catch (err) { - if (err instanceof DmError) { - res.status(err.status).json({ error: err.message }); - return; - } - throw err; - } - }); - - router.post("/conversations/:id/members", (req, res) => { - const { userId: newUserId, email } = req.body ?? {}; - const core = getCoreDb(); - const actorId = req.user!.id; - const conversationId = paramId(req.params.id); - try { - let targetId = typeof newUserId === "string" ? newUserId : undefined; - if (!targetId && typeof email === "string") { - const found = lookupUserByEmail(core, email, actorId); - if (!found) { - res.status(404).json({ error: "User not found" }); - return; - } - targetId = found.id; - } - if (!targetId) { - res.status(400).json({ error: "userId or email required" }); - return; - } - const member = addConversationMember(core, conversationId, actorId, targetId); - const memberIds = listConversationMemberUserIds(core, conversationId); - broadcastDm( - conversationId, - "dm_member_added", - { conversationId, member }, - memberIds - ); - res.status(201).json({ member }); - } catch (err) { - if (err instanceof DmError) { - res.status(err.status).json({ error: err.message }); - return; - } - throw err; - } - }); - - router.delete("/conversations/:id/members/:userId", (req, res) => { - const core = getCoreDb(); - const conversationId = paramId(req.params.id); - try { - removeConversationMember( - core, - conversationId, - req.user!.id, - paramId(req.params.userId) - ); - const memberIds = listConversationMemberUserIds(core, conversationId); - broadcastDm( - conversationId, - "dm_member_removed", - { conversationId, userId: paramId(req.params.userId) }, + "dm_typing", + { conversationId, userId, displayName: req.user!.displayName }, memberIds ); res.json({ ok: true }); @@ -341,71 +176,38 @@ export function createDmRouter(deps: DmRouterDeps): Router { } }); - router.post("/conversations/:id/share", resolveTenant, (req, res) => { - const { resourceKind, resourceId, role } = req.body ?? {}; - if (typeof resourceKind !== "string" || typeof resourceId !== "string") { - res.status(400).json({ error: "resourceKind and resourceId required" }); - return; - } - const core = getCoreDb(); - const conversationId = paramId(req.params.id); - try { - const grants = shareResourceToConversation(core, { - conversationId, - actorUserId: req.user!.id, - actorTenantId: req.tenantId!, - resourceKind: resourceKind as MarketplaceListingKind, - resourceId, - role: (role as ShareGrantRole) ?? "viewer", - }); - const memberIds = listConversationMemberUserIds(core, conversationId); - broadcastDm( - conversationId, - "dm_share", - { conversationId, resourceKind, resourceId, grants }, - memberIds - ); - res.json({ grants }); - } catch (err) { - if (err instanceof DmError) { - res.status(err.status).json({ error: err.message }); - return; - } - throw err; - } - }); - - router.post("/conversations/:id/typing", (req, res) => { - const core = getCoreDb(); - const conversationId = paramId(req.params.id); - const userId = req.user!.id; - try { - const memberIds = listConversationMemberUserIds(core, conversationId); - broadcastDm( - conversationId, - "dm_typing", - { conversationId, userId, displayName: req.user!.displayName }, - memberIds - ); - res.json({ ok: true }); - } catch { - res.json({ ok: true }); - } - }); - - router.post("/uploads", upload.single("file"), (req, res) => { + router.post("/uploads", upload.single("file"), async (req, res) => { const file = req.file; if (!file) { res.status(400).json({ error: "file required" }); return; } try { - const blob = storeDmBlob(getCoreDb(), { - ownerUserId: req.user!.id, - filename: file.originalname || "upload", - mime: file.mimetype || "application/octet-stream", - buffer: file.buffer, - }); + const uploaded = await executeCollectionAction( + getCoreDb(), + "DmBlob", + "upload", + { + filename: file.originalname || "upload", + mime: file.mimetype || "application/octet-stream", + buffer: file.buffer, + }, + { + tenantId: req.tenantId, + userId: req.user!.id, + isAdmin: req.user!.isAdmin, + role: req.tenantRole ?? "viewer", + source: "http", + } + ) as { + data: { + id: string; + filename: string; + mime: string; + size: number; + }; + }; + const blob = uploaded.data; res.status(201).json({ blob: { id: blob.id, @@ -416,8 +218,8 @@ export function createDmRouter(deps: DmRouterDeps): Router { }, }); } catch (err) { - if (err instanceof BlobStoreError) { - res.status(400).json({ error: err.message }); + if (err instanceof KernelError || err instanceof BlobStoreError) { + res.status(err instanceof KernelError ? err.status : 400).json({ error: err.message }); return; } throw err; diff --git a/apps/bridge/src/routes/federation.ts b/apps/bridge/src/routes/federation.ts index b2fa433..04b4ae0 100644 --- a/apps/bridge/src/routes/federation.ts +++ b/apps/bridge/src/routes/federation.ts @@ -1,19 +1,127 @@ import { Router, type Request, type Response, type NextFunction } from "express"; -import { randomUUID } from "node:crypto"; -import { config } from "../config.js"; -import { getCoreDb } from "../core-db.js"; +import { getCoreDb, type CoreDatabase } from "../core-db.js"; import { getPluginHost } from "@godmode/plugin-host"; -import { createShareGrant } from "../services/share-service.js"; -import type { MarketplaceListingKind, ShareGrantRole } from "../core-db.js"; import { attachAuthContext, requireAuth, } from "../services/auth/middleware.js"; +import { + executeCollectionAction, + KernelError, +} from "../kernel/record-api.js"; function scEnqueue(line: string, chartbookKey?: string): string { return getPluginHost().enqueueScLine!(line, chartbookKey); } +interface FederationGrant { + id: string; + owner_tenant_id: string; + resource_kind: string; + resource_id: string; + grantee_user_id: string | null; + grantee_tenant_id: string | null; + role: string; + expires_at: string | null; +} + +export class FederationAuthorizationError extends Error { + constructor( + readonly status: number, + message: string + ) { + super(message); + } +} + +function federationGrantForToken( + core: CoreDatabase, + token: string +): FederationGrant { + const grant = core + .prepare( + `SELECT id, owner_tenant_id, resource_kind, resource_id, + grantee_user_id, grantee_tenant_id, role, expires_at + FROM share_grants + WHERE federation_token=? + LIMIT 1` + ) + .get(token) as FederationGrant | undefined; + if (!grant) { + throw new FederationAuthorizationError(403, "Invalid federation token"); + } + const expiresAt = grant.expires_at ? Date.parse(grant.expires_at) : NaN; + if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) { + throw new FederationAuthorizationError(403, "Federation token expired"); + } + return grant; +} + +export function authorizeFederationScCommand( + core: CoreDatabase, + token: string, + binding: { + verb: string; + resourceKind: string; + resourceId: string; + ownerTenantId: string; + targetTenantId: string; + } +): FederationGrant { + const grant = federationGrantForToken(core, token); + if (binding.verb !== "execute") { + throw new FederationAuthorizationError(403, "Federation verb not permitted"); + } + if ( + !["department", "division"].includes(grant.resource_kind) || + !["editor", "owner"].includes(grant.role) + ) { + throw new FederationAuthorizationError(403, "Federation capability not permitted"); + } + if ( + grant.resource_kind !== binding.resourceKind || + grant.resource_id !== binding.resourceId || + grant.owner_tenant_id !== binding.ownerTenantId + ) { + throw new FederationAuthorizationError(403, "Federation token resource mismatch"); + } + if (grant.grantee_tenant_id !== binding.targetTenantId) { + throw new FederationAuthorizationError(403, "Federation target tenant mismatch"); + } + return grant; +} + +const SC_COMMANDS = new Set([ + "ADD", "REMOVE", "WIRE", "ENABLE", "FLATTEN", "RECALC", "PING", + "SET_DOM_LEVELS", "STATE", "DISCOVER", "WIRETAP", "BACKTEST_SIM", + "BACKTEST_RESET_STATS", "BACKTEST_TEARDOWN", "REPLAY_START", + "REPLAY_STOP", "REPLAY_STOP_ALL", "REPLAY_PAUSE", "LIST_CHARTS", + "REPLAY_SET_SPEED", "BACKTEST_AUTO_REPLAY", "SET_TRADE_SIMULATION_MODE", + "SET_CHART_TRADE_MODE", "SET_AUTO_TRADING_ENABLED", + "SET_CHART_TRADE_ACCOUNT", "SET_CHART_DAYS_TO_LOAD", + "SET_CHART_UPDATE_INTERVAL", "SET_CHART_SYMBOL", "GET_TRADE_LIST", + "SET_STUDY_INPUT", "STUDY_INPUTS_DUMP", "REMOVE_ALL", "LIST_STUDIES", +]); + +export function validateScCommandLine(line: unknown): string { + if (typeof line !== "string") { + throw new FederationAuthorizationError(400, "SC command line required"); + } + const normalized = line.trim(); + if ( + !normalized || + normalized.length > 4096 || + /[\r\n\0]/.test(normalized) + ) { + throw new FederationAuthorizationError(400, "Malformed SC command"); + } + const command = normalized.split("|", 1)[0]!; + if (!SC_COMMANDS.has(command)) { + throw new FederationAuthorizationError(400, "SC command not permitted"); + } + return normalized; +} + function federationAuth(req: Request, res: Response, next: NextFunction): void { const header = req.headers.authorization ?? ""; const token = header.startsWith("Bearer ") ? header.slice(7).trim() : ""; @@ -21,30 +129,16 @@ function federationAuth(req: Request, res: Response, next: NextFunction): void { res.status(401).json({ error: "Federation token required" }); return; } - const core = getCoreDb(); - const grant = core - .prepare( - `SELECT id FROM share_grants WHERE federation_token=? LIMIT 1` - ) - .get(token); - const conn = core - .prepare( - `SELECT id FROM bridge_connections WHERE remote_bridge_token=? LIMIT 1` - ) - .get(token); - const peer = core - .prepare(`SELECT id FROM peer_connections WHERE federation_token=? LIMIT 1`) - .get(token); - const invite = core - .prepare( - `SELECT id FROM federated_share_invites WHERE invite_token=? AND status='accepted' LIMIT 1` - ) - .get(token); - if (!grant && !conn && !peer && !invite) { - res.status(403).json({ error: "Invalid federation token" }); - return; + try { + federationGrantForToken(getCoreDb(), token); + next(); + } catch (err) { + if (err instanceof FederationAuthorizationError) { + res.status(err.status).json({ error: err.message }); + return; + } + next(err); } - next(); } /** @@ -80,54 +174,38 @@ export function createFederationRouter(deps: { res.json({ invite: payload }); }); - router.post("/invites/:token/accept", attachAuthContext, requireAuth, (req, res) => { - const core = getCoreDb(); + router.post("/invites/:token/accept", attachAuthContext, requireAuth, async (req, res) => { const token = String(req.params.token); - const invite = core - .prepare(`SELECT * FROM federated_share_invites WHERE invite_token=? AND status='pending'`) - .get(token) as Record | undefined; - if (!invite) { - res.status(404).json({ error: "Invite not found or already accepted" }); + const { granteeTenantId } = req.body ?? {}; + if (!granteeTenantId) { + res.status(400).json({ error: "granteeTenantId required" }); return; } - const expiresAt = invite.expires_at ? Date.parse(String(invite.expires_at)) : NaN; - if (Number.isFinite(expiresAt) && expiresAt < Date.now()) { - res.status(410).json({ error: "Invite expired" }); - return; - } - const callerEmail = req.user?.email?.trim().toLowerCase() ?? ""; - const inviteeEmail = String(invite.invitee_email ?? "").trim().toLowerCase(); - if (!callerEmail || callerEmail !== inviteeEmail) { - res.status(403).json({ error: "Invite is bound to a different account email" }); - return; - } - const { - granteeUserId, - granteeTenantId, - granteeEmail, - granteeDisplayName, - granteeBridgeUrl, - } = req.body ?? {}; - if (!granteeUserId || !granteeTenantId) { - res.status(400).json({ error: "granteeUserId and granteeTenantId required" }); - return; + try { + const result = await executeCollectionAction( + getCoreDb(), + "FederatedShareInvite", + "accept", + { + invite_token: token, + grantee_tenant_id: String(granteeTenantId), + }, + { + tenantId: String(granteeTenantId), + userId: req.user!.id, + isAdmin: req.user!.isAdmin, + role: req.tenantRole ?? "viewer", + source: "http", + } + ); + res.json(result); + } catch (err) { + if (err instanceof KernelError) { + res.status(err.status).json({ error: err.message }); + return; + } + throw err; } - const federationToken = randomUUID(); - const grantId = createShareGrant(core, { - ownerTenantId: String(invite.owner_tenant_id), - ownerUserId: String(invite.owner_user_id), - resourceKind: invite.resource_kind as MarketplaceListingKind, - resourceId: String(invite.resource_id), - granteeUserId: String(granteeUserId), - granteeTenantId: String(granteeTenantId), - role: (invite.role as ShareGrantRole) ?? "viewer", - bridgeUrl: config.federation.publicUrl, - federationToken, - }); - core.prepare( - `UPDATE federated_share_invites SET status='accepted' WHERE invite_token=?` - ).run(token); - res.json({ grantId, federationToken, ownerBridgeUrl: config.federation.publicUrl }); }); router.use(federationAuth); @@ -141,24 +219,40 @@ export function createFederationRouter(deps: { const body = (req.body ?? {}) as { line?: string; chartbookKey?: string; - chartNumber?: number; - deployId?: string; + resourceKind?: string; + resourceId?: string; + ownerTenantId?: string; + targetTenantId?: string; }; - if (typeof body.line === "string" && body.line.trim()) { - const file = scEnqueue(body.line.trim(), body.chartbookKey); - res.json({ ok: true, verb: req.params.verb, enqueued: body.line.trim(), file }); - return; + const token = String(req.headers.authorization).slice(7).trim(); + try { + for (const [name, value] of Object.entries({ + resourceKind: body.resourceKind, + resourceId: body.resourceId, + ownerTenantId: body.ownerTenantId, + targetTenantId: body.targetTenantId, + })) { + if (typeof value !== "string" || !value.trim()) { + throw new FederationAuthorizationError(400, `${name} required`); + } + } + authorizeFederationScCommand(getCoreDb(), token, { + verb: String(req.params.verb ?? "").toLowerCase(), + resourceKind: body.resourceKind!, + resourceId: body.resourceId!, + ownerTenantId: body.ownerTenantId!, + targetTenantId: body.targetTenantId!, + }); + const line = validateScCommandLine(body.line); + const file = scEnqueue(line, body.chartbookKey); + res.json({ ok: true, verb: "execute", enqueued: line, file }); + } catch (err) { + if (err instanceof FederationAuthorizationError) { + res.status(err.status).json({ error: err.message }); + return; + } + throw err; } - const verb = String(req.params.verb ?? "").toUpperCase(); - if (verb === "ADD" && body.chartNumber && body.deployId) { - const dllFunc = String((body as { dllFunc?: string }).dllFunc ?? ""); - scEnqueue(`ADD|${body.chartNumber}|${dllFunc}|${body.deployId}`, body.chartbookKey); - res.json({ ok: true, verb, mode: "add" }); - return; - } - res.status(400).json({ - error: "Provide { line } or ADD payload { chartNumber, dllFunc, deployId }", - }); }); router.get("/market/:symbol", (req, res) => { diff --git a/apps/bridge/src/routes/financial.ts b/apps/bridge/src/routes/financial.ts index 571ec69..031bc4f 100644 --- a/apps/bridge/src/routes/financial.ts +++ b/apps/bridge/src/routes/financial.ts @@ -42,39 +42,6 @@ export function createFinancialRouter(_db?: AppDatabase, _deps?: FinancialDeps): }); }); - router.post("/config/moralis", async (req, res) => { - const { credentials, crypto } = financialDeps(req); - const apiKey = String((req.body as { apiKey?: string })?.apiKey ?? "").trim(); - if (!apiKey) return res.status(400).json({ error: "apiKey required" }); - credentials.setMoralisApiKey(apiKey); - const test = await crypto.testConnection(); - if (!test.ok) { - return res.status(400).json({ error: test.error ?? "Moralis key rejected" }); - } - res.json({ ok: true, configured: true }); - }); - - router.post("/config/paypal", async (req, res) => { - const { credentials, paypal } = financialDeps(req); - const body = req.body as { - clientId?: string; - clientSecret?: string; - env?: string; - }; - const clientId = String(body.clientId ?? "").trim(); - const clientSecret = String(body.clientSecret ?? "").trim(); - const env = body.env === "live" ? "live" : "sandbox"; - if (!clientId || !clientSecret) { - return res.status(400).json({ error: "clientId and clientSecret required" }); - } - credentials.setPayPalCredentials({ clientId, clientSecret, env }); - const test = await paypal.testConnection(); - if (!test.ok) { - return res.status(400).json({ error: test.error ?? "PayPal credentials rejected" }); - } - res.json({ ok: true, configured: true, env }); - }); - router.get("/connections", (req, res) => { const { holdings } = financialDeps(req); const list = holdings.list(); @@ -84,133 +51,5 @@ export function createFinancialRouter(_db?: AppDatabase, _deps?: FinancialDeps): }); }); - router.post("/connections", async (req, res) => { - const { holdings } = financialDeps(req); - const body = req.body as { - category?: string; - provider?: string; - label?: string; - balance?: number; - currency?: string; - reference?: string; - }; - if (!body.category || !body.provider || !body.label) { - return res.status(400).json({ error: "category, provider, label required" }); - } - const balance = Number(body.balance ?? 0); - const currency = String(body.currency ?? "CAD").toUpperCase(); - let balanceCad = balance; - if (currency === "USD") { - balanceCad = await usdToCad(balance); - } - const conn = holdings.create({ - category: body.category as "manual", - provider: body.provider, - label: body.label, - currency, - reference: body.reference, - balance, - balanceCad, - status: "active", - }); - res.json(conn); - }); - - router.delete("/connections/:id", (req, res) => { - const { holdings } = financialDeps(req); - const ok = holdings.delete(req.params.id); - if (!ok) return res.status(404).json({ error: "not found" }); - res.json({ ok: true, netWorthCad: holdings.netWorthCad() }); - }); - - router.post("/connections/:id/refresh", async (req, res) => { - const { holdings, crypto, paypal } = financialDeps(req); - const conn = holdings.get(req.params.id); - if (!conn) return res.status(404).json({ error: "not found" }); - - try { - if (conn.category === "wallet" && conn.reference) { - const portfolio = await crypto.fetchPortfolio(conn.reference); - const updated = holdings.updateBalance( - conn.id, - portfolio.totalUsd, - "USD", - portfolio.totalCad, - { tokens: portfolio.tokens } - ); - return res.json(updated); - } - if (conn.category === "paypal") { - const balance = await paypal.fetchBalance(); - const updated = holdings.updateBalance( - conn.id, - balance.total, - balance.currency, - balance.totalCad, - balance.raw - ); - return res.json(updated); - } - return res.status(400).json({ error: "Refresh not supported for this connection type" }); - } catch (err) { - holdings.updateBalance(conn.id, conn.balance, conn.currency, conn.balanceCad, conn.breakdown, "error"); - return res.status(502).json({ - error: err instanceof Error ? err.message : String(err), - }); - } - }); - - router.post("/crypto/balance", async (req, res) => { - const { crypto } = financialDeps(req); - const body = req.body as { address?: string; chains?: string[] }; - const address = String(body.address ?? "").trim(); - if (!address) return res.status(400).json({ error: "address required" }); - try { - const portfolio = await crypto.fetchPortfolio(address, body.chains); - res.json(portfolio); - } catch (err) { - res.status(502).json({ - error: err instanceof Error ? err.message : String(err), - }); - } - }); - - router.post("/crypto/connect", async (req, res) => { - const { crypto, holdings } = financialDeps(req); - const body = req.body as { - address?: string; - provider?: string; - label?: string; - chains?: string[]; - }; - const address = String(body.address ?? "").trim(); - const provider = String(body.provider ?? "metamask"); - const label = String(body.label ?? provider).trim(); - if (!address) return res.status(400).json({ error: "address required" }); - try { - const portfolio = await crypto.fetchPortfolio(address, body.chains); - const conn = holdings.upsertCryptoWallet(provider, label, portfolio); - res.json({ connection: conn, portfolio }); - } catch (err) { - res.status(502).json({ - error: err instanceof Error ? err.message : String(err), - }); - } - }); - - router.post("/paypal/connect", async (req, res) => { - const { paypal, holdings } = financialDeps(req); - const label = String((req.body as { label?: string })?.label ?? "PayPal Business").trim(); - try { - const balance = await paypal.fetchBalance(); - const conn = holdings.upsertPayPal(label, balance); - res.json({ connection: conn, balance }); - } catch (err) { - res.status(502).json({ - error: err instanceof Error ? err.message : String(err), - }); - } - }); - return router; } diff --git a/apps/bridge/src/routes/hooks.ts b/apps/bridge/src/routes/hooks.ts index dd439b7..82dea9e 100644 --- a/apps/bridge/src/routes/hooks.ts +++ b/apps/bridge/src/routes/hooks.ts @@ -43,50 +43,6 @@ export function createHooksRouter(): Router { res.json({ hooks: listHooks(scope), agentIds: scope.agentIds }); }); - router.post("/", (req, res) => { - const scope = resolveScope(req.user!.id); - const b = req.body ?? {}; - try { - const hook = createHook( - { - ownerKind: b.ownerKind === "agent" ? "agent" : "user", - ownerId: b.ownerKind === "agent" ? String(b.ownerId) : req.user!.id, - ownerTenantId: b.ownerTenantId ?? scope.tenantId, - name: String(b.name ?? "Untitled hook"), - enabled: b.enabled, - triggerKind: b.triggerKind === "schedule" ? "schedule" : "event", - eventType: b.eventType ?? null, - scheduleCron: b.scheduleCron ?? null, - conditionJson: - typeof b.conditionJson === "string" - ? b.conditionJson - : b.condition - ? JSON.stringify(b.condition) - : null, - actionKind: b.actionKind, - actionConfigJson: - typeof b.actionConfigJson === "string" - ? b.actionConfigJson - : b.actionConfig - ? JSON.stringify(b.actionConfig) - : null, - rateLimitPerHour: - b.rateLimitPerHour != null ? Number(b.rateLimitPerHour) : null, - requireApproval: !!b.requireApproval, - }, - scope - ); - refreshScheduler(); - res.status(201).json({ hook }); - } catch (err) { - if (err instanceof HookError) { - res.status(err.status).json({ error: err.message }); - return; - } - throw err; - } - }); - router.get("/:id", (req, res) => { const scope = resolveScope(req.user!.id); try { @@ -100,43 +56,6 @@ export function createHooksRouter(): Router { } }); - router.patch("/:id", (req, res) => { - const scope = resolveScope(req.user!.id); - try { - const patch = { ...(req.body ?? {}) }; - if (patch.condition && !patch.conditionJson) { - patch.conditionJson = JSON.stringify(patch.condition); - } - if (patch.actionConfig && !patch.actionConfigJson) { - patch.actionConfigJson = JSON.stringify(patch.actionConfig); - } - const hook = updateHook(paramId(req.params.id), patch, scope); - refreshScheduler(); - res.json({ hook }); - } catch (err) { - if (err instanceof HookError) { - res.status(err.status).json({ error: err.message }); - return; - } - throw err; - } - }); - - router.delete("/:id", (req, res) => { - const scope = resolveScope(req.user!.id); - try { - deleteHook(paramId(req.params.id), scope); - refreshScheduler(); - res.json({ ok: true }); - } catch (err) { - if (err instanceof HookError) { - res.status(err.status).json({ error: err.message }); - return; - } - throw err; - } - }); - router.get("/:id/runs", (req, res) => { const scope = resolveScope(req.user!.id); try { @@ -150,38 +69,6 @@ export function createHooksRouter(): Router { } }); - router.post("/runs/:runId/approve", async (req, res) => { - const scope = resolveScope(req.user!.id); - const runId = paramId(req.params.runId); - try { - getHookForRun(runId, scope); - await approveHookRun(runId); - res.json({ ok: true }); - } catch (err) { - if (err instanceof HookError) { - res.status(err.status).json({ error: err.message }); - return; - } - throw err; - } - }); - - router.post("/runs/:runId/reject", (req, res) => { - const scope = resolveScope(req.user!.id); - const runId = paramId(req.params.runId); - try { - getHookForRun(runId, scope); - rejectHookRun(runId); - res.json({ ok: true }); - } catch (err) { - if (err instanceof HookError) { - res.status(err.status).json({ error: err.message }); - return; - } - throw err; - } - }); - return router; } @@ -202,32 +89,5 @@ export function createEventsRouter(): Router { }); }); - // Emit a custom event over HTTP using the same emitEvent → dispatchEvent path - // the `emit_event` tool uses, so event-triggered hooks fire. Attributed to an - // owned agent when `actorAgentId` is one of the caller's agents, else the user. - router.post("/", (req, res) => { - const userId = req.user!.id; - const tenantId = getUserOwnerTenantId(userId); - const eventType = String(req.body?.eventType ?? req.body?.type ?? "").trim(); - if (!eventType) { - res.status(400).json({ error: "eventType required" }); - return; - } - const payload = - req.body?.payload && typeof req.body.payload === "object" - ? (req.body.payload as Record) - : {}; - const actorAgentId = req.body?.actorAgentId - ? String(req.body.actorAgentId) - : null; - const ownedAgentIds = listAgents(getUserOwnerTenantDb(userId)).map((a) => a.id); - const actor = - actorAgentId && ownedAgentIds.includes(actorAgentId) - ? { kind: "agent" as const, id: actorAgentId } - : { kind: "user" as const, id: userId }; - const event = emitEvent({ type: eventType, actor, payload, tenantId }); - res.status(201).json({ event }); - }); - return router; } diff --git a/apps/bridge/src/routes/inference.ts b/apps/bridge/src/routes/inference.ts index f34fe43..9751d5f 100644 --- a/apps/bridge/src/routes/inference.ts +++ b/apps/bridge/src/routes/inference.ts @@ -33,55 +33,5 @@ export function createInferenceRouter(llm: LlmManager): Router { res.json({ endpoints: listInferenceEndpoints(getCoreDb(), req.user!.id) }); }); - router.post("/endpoints", (req, res) => { - const { name, baseModelPath, adapterIds, meterUnit, meterRate, capacityHint } = - req.body ?? {}; - if (typeof name !== "string" || typeof baseModelPath !== "string") { - res.status(400).json({ error: "name and baseModelPath required" }); - return; - } - const id = createInferenceEndpoint(getCoreDb(), { - ownerTenantId: req.tenantId!, - ownerUserId: req.user!.id, - name, - baseModelPath, - adapterIds: Array.isArray(adapterIds) ? adapterIds.map(String) : undefined, - meterUnit: typeof meterUnit === "string" ? meterUnit : undefined, - meterRate: meterRate != null ? Number(meterRate) : undefined, - capacityHint: capacityHint != null ? Number(capacityHint) : undefined, - }); - res.status(201).json({ id }); - }); - - router.post("/run", (req, res) => { - const { endpointId, messages, sampling } = req.body ?? {}; - if (typeof endpointId !== "string" || !Array.isArray(messages)) { - res.status(400).json({ error: "endpointId and messages required" }); - return; - } - const base = llm.getSamplingParams(); - const merged: AgentSampling = { - ...base, - ...(sampling && typeof sampling === "object" ? sampling : {}), - }; - void runRemoteInference(getCoreDb(), llm, { - endpointId, - buyerUserId: req.user!.id, - buyerTenantId: req.tenantId!, - messages: messages.map((m: { role?: string; content?: string }) => ({ - role: toRole(m.role), - content: String(m.content ?? ""), - })), - sampling: merged, - }) - .then((text) => res.json({ ok: true, content: text })) - .catch((err) => { - const status = err instanceof CreditsError ? err.status : 500; - res - .status(status) - .json({ error: err instanceof Error ? err.message : "Inference failed" }); - }); - }); - return router; } diff --git a/apps/bridge/src/routes/integrations.ts b/apps/bridge/src/routes/integrations.ts index d1e15e8..15f6ca2 100644 --- a/apps/bridge/src/routes/integrations.ts +++ b/apps/bridge/src/routes/integrations.ts @@ -63,44 +63,10 @@ export function createIntegrationsRouter(): Router { res.json(calendarStatus(db)); }); - router.post("/calendar/sync", (req, res) => { - const db = getReqTenantDb(req); - const status = calendarStatus(db); - if (!status.connected) { - res.status(400).json({ - error: "Calendar not connected", - hint: status.message, - }); - return; - } - res.json({ - ok: true, - queued: true, - message: "Calendar sync queued (provider pull runs on next scheduler tick)", - }); - }); - router.get("/email/status", (req, res) => { const db = getReqTenantDb(req); res.json(emailStatus(db)); }); - router.post("/email/sync", (req, res) => { - const db = getReqTenantDb(req); - const status = emailStatus(db); - if (!status.connected) { - res.status(400).json({ - error: "Email not connected", - hint: status.message, - }); - return; - } - res.json({ - ok: true, - queued: true, - message: "Email sync queued — desktop mail requires the local connector", - }); - }); - return router; } diff --git a/apps/bridge/src/routes/marketplace-catalog.ts b/apps/bridge/src/routes/marketplace-catalog.ts index 3251dd3..3256308 100644 --- a/apps/bridge/src/routes/marketplace-catalog.ts +++ b/apps/bridge/src/routes/marketplace-catalog.ts @@ -6,23 +6,15 @@ import { getReqTenantDb, } from "../services/auth/middleware.js"; import { - addCatalogSource, fetchOfficialCatalog, fetchUnofficialCatalog, - installCatalogEntry, - installDiscoveredPlugin, listCatalogInstalls, listCatalogSources, listDiscoveredPluginsForTenant, - registerLocalPluginFolder, - removeCatalogSource, - removeLocalPluginFolder, - uninstallDiscoveredPlugin, extraPluginPathsFromMeta, } from "../services/marketplace-catalog.js"; import { getCoreDb } from "../core-db.js"; import { listInstalledPlugins, listAvailablePlugins } from "../plugins/plugin-install.js"; -import { requireTenantRole } from "../services/auth/middleware.js"; export function createMarketplaceCatalogRouter(): Router { const router = Router(); @@ -59,27 +51,6 @@ export function createMarketplaceCatalogRouter(): Router { res.json({ sources: listCatalogSources(core, req.user!.id) }); }); - router.post("/sources", (req, res) => { - const { name, url } = req.body ?? {}; - if (!name || !url) { - res.status(400).json({ error: "name and url required" }); - return; - } - const core = getCoreDb(); - const id = addCatalogSource(core, req.user!.id, String(name), String(url)); - res.status(201).json({ id }); - }); - - router.delete("/sources/:id", (req, res) => { - const core = getCoreDb(); - const ok = removeCatalogSource(core, req.user!.id, String(req.params.id)); - if (!ok) { - res.status(404).json({ error: "Source not found" }); - return; - } - res.json({ ok: true }); - }); - router.get("/installed", (req, res) => { const core = getCoreDb(); const catalogInstalls = listCatalogInstalls(core, req.tenantId!); @@ -89,93 +60,5 @@ export function createMarketplaceCatalogRouter(): Router { res.json({ catalogInstalls, plugins, available, discovered }); }); - router.post("/local-plugins", requireTenantRole("owner"), async (req, res) => { - try { - const core = getCoreDb(); - const pathInput = String(req.body?.path ?? "").trim(); - if (!pathInput) { - res.status(400).json({ error: "path required" }); - return; - } - const result = await registerLocalPluginFolder(core, req.tenantId!, pathInput, { - userId: req.user!.id, - installForTenant: req.body?.installForTenant !== false, - }); - res.status(201).json(result); - } catch (err) { - res.status(400).json({ - error: err instanceof Error ? err.message : "Failed to register local plugin", - }); - } - }); - - router.delete("/local-plugins", requireTenantRole("owner"), (req, res) => { - const core = getCoreDb(); - const pathInput = String(req.body?.path ?? "").trim(); - if (!pathInput) { - res.status(400).json({ error: "path required" }); - return; - } - const ok = removeLocalPluginFolder(core, pathInput); - if (!ok) { - res.status(404).json({ error: "Local plugin path not registered" }); - return; - } - res.json({ ok: true }); - }); - - router.post("/plugins/install", requireTenantRole("owner"), async (req, res) => { - try { - const core = getCoreDb(); - const pluginId = String(req.body?.pluginId ?? "").trim(); - if (!pluginId) { - res.status(400).json({ error: "pluginId required" }); - return; - } - await installDiscoveredPlugin(core, req.tenantId!, pluginId); - res.json({ ok: true, pluginId }); - } catch (err) { - res.status(400).json({ - error: err instanceof Error ? err.message : "Install failed", - }); - } - }); - - router.post("/plugins/uninstall", requireTenantRole("owner"), async (req, res) => { - try { - const core = getCoreDb(); - const pluginId = String(req.body?.pluginId ?? "").trim(); - if (!pluginId) { - res.status(400).json({ error: "pluginId required" }); - return; - } - await uninstallDiscoveredPlugin(core, req.tenantId!, pluginId); - res.json({ ok: true, pluginId }); - } catch (err) { - res.status(400).json({ - error: err instanceof Error ? err.message : "Uninstall failed", - }); - } - }); - - router.post("/install/:entryId", async (req, res) => { - try { - const core = getCoreDb(); - const tenantDb = getReqTenantDb(req); - const result = await installCatalogEntry(core, tenantDb, { - userId: req.user!.id, - tenantId: req.tenantId!, - entryId: String(req.params.entryId), - sourceCatalog: - typeof req.body?.sourceCatalog === "string" ? req.body.sourceCatalog : undefined, - }); - res.json(result); - } catch (err) { - res.status(400).json({ - error: err instanceof Error ? err.message : "Install failed", - }); - } - }); - return router; } diff --git a/apps/bridge/src/routes/marketplace.ts b/apps/bridge/src/routes/marketplace.ts index a8cfa32..d012761 100644 --- a/apps/bridge/src/routes/marketplace.ts +++ b/apps/bridge/src/routes/marketplace.ts @@ -1,5 +1,4 @@ import { Router, type Request, type Response, type NextFunction } from "express"; -import { v4 as uuidv4 } from "uuid"; import { config } from "../config.js"; import { getCoreDb } from "../core-db.js"; import { getReqTenantDb } from "../services/auth/middleware.js"; @@ -27,6 +26,10 @@ import { createInferenceEndpoint, listInferenceEndpoints, } from "../services/inference-service.js"; +import { + acquireCloneListing, + publishMarketplaceListing, +} from "../services/marketplace-listings.js"; const LISTING_COLS = `id, seller_user_id, seller_tenant_id, kind, resource_id, title, description, price_credits, visibility, status, @@ -121,231 +124,13 @@ export function createMarketplaceRouter(): Router { }); }); - router.post("/entitlements/:id/cancel", (req, res) => { - try { - cancelEntitlement(getCoreDb(), String(req.params.id), req.user!.id); - res.json({ ok: true }); - } catch (err) { - const status = err instanceof Error && "status" in err ? Number((err as { status: number }).status) : 400; - res.status(status).json({ error: err instanceof Error ? err.message : "Cancel failed" }); - } - }); - router.get("/wallet", (req, res) => { res.json({ balance: 0, ledger: [], deprecated: "Credits removed; use Marketplace catalog install" }); }); - router.post("/wallet/purchase", (_req, res) => { - res.status(410).json({ error: "Marketplace credits removed; install packs from the catalog for free" }); - }); - - router.post("/wallet/checkout", (_req, res) => { - res.status(410).json({ error: "Marketplace credits removed; install packs from the catalog for free" }); - }); - router.get("/inference/endpoints", (req, res) => { res.json({ endpoints: listInferenceEndpoints(getCoreDb(), req.user!.id) }); }); - router.post("/inference/endpoints", (req, res) => { - const { name, baseModelPath, adapterIds, meterUnit, meterRate, capacityHint } = - req.body ?? {}; - if (typeof name !== "string" || typeof baseModelPath !== "string") { - res.status(400).json({ error: "name and baseModelPath required" }); - return; - } - const id = createInferenceEndpoint(getCoreDb(), { - ownerTenantId: req.tenantId!, - ownerUserId: req.user!.id, - name, - baseModelPath, - adapterIds: Array.isArray(adapterIds) ? adapterIds.map(String) : undefined, - meterUnit: typeof meterUnit === "string" ? meterUnit : undefined, - meterRate: meterRate != null ? Number(meterRate) : undefined, - capacityHint: capacityHint != null ? Number(capacityHint) : undefined, - }); - res.status(201).json({ id }); - }); - - router.post("/listings", (req, res) => { - const { - kind, - resourceId, - title, - description, - priceCredits, - deliveryMode, - pricingModel, - pricePeriod, - meterUnit, - meterRate, - license, - inferenceEndpointId, - bundleChildren, - } = req.body ?? {}; - if (typeof kind !== "string") { - res.status(400).json({ error: "kind required" }); - return; - } - const delivery = (deliveryMode ?? "clone") as DeliveryMode; - const pricing = (pricingModel ?? "one_time") as PricingModel; - const db = getReqTenantDb(req); - const core = getCoreDb(); - let bundleJson = "{}"; - let listingTitle = typeof title === "string" ? title : kind; - let endpointId = - typeof inferenceEndpointId === "string" ? inferenceEndpointId : null; - - if (kind === "inference") { - if (!endpointId && typeof resourceId === "string") { - endpointId = resourceId; - } - if (!endpointId) { - res.status(400).json({ error: "inferenceEndpointId required for inference listings" }); - return; - } - const ep = core - .prepare(`SELECT name FROM inference_endpoints WHERE id=? AND owner_user_id=?`) - .get(endpointId, req.user!.id) as { name: string } | undefined; - if (!ep) { - res.status(404).json({ error: "Inference endpoint not found" }); - return; - } - listingTitle = typeof title === "string" ? title : ep.name; - bundleJson = "{}"; - } else if (kind === "bundle") { - const children = Array.isArray(bundleChildren) - ? (bundleChildren as PortableBundle[]) - : []; - if (!children.length) { - res.status(400).json({ error: "bundleChildren required for bundle listings" }); - return; - } - bundleJson = JSON.stringify({ title: listingTitle, children }); - } else if (delivery === "clone") { - if (typeof resourceId !== "string") { - res.status(400).json({ error: "resourceId required for clone listings" }); - return; - } - try { - const bundle = exportEntity(db, kind as MarketplaceListingKind, resourceId); - listingTitle = typeof title === "string" ? title : bundle.title; - bundleJson = JSON.stringify(bundle); - } catch (err) { - res.status(404).json({ error: err instanceof Error ? err.message : "Export failed" }); - return; - } - } else if (typeof resourceId !== "string") { - res.status(400).json({ error: "resourceId required for live listings" }); - return; - } - - const id = uuidv4(); - core.prepare( - `INSERT INTO marketplace_listings - (id, seller_user_id, seller_tenant_id, kind, resource_id, title, description, - price_credits, bundle_json, visibility, status, - delivery_mode, pricing_model, price_period, meter_unit, meter_rate, license, - inference_endpoint_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'public', 'active', ?, ?, ?, ?, ?, ?, ?)` - ).run( - id, - req.user!.id, - req.tenantId!, - kind, - typeof resourceId === "string" ? resourceId : endpointId ?? id, - listingTitle, - typeof description === "string" ? description : null, - Number(priceCredits ?? 0), - bundleJson, - delivery, - pricing, - typeof pricePeriod === "string" ? pricePeriod : null, - typeof meterUnit === "string" ? meterUnit : null, - meterRate != null ? Number(meterRate) : null, - typeof license === "string" ? license : null, - endpointId - ); - res.status(201).json({ id }); - }); - - router.post("/listings/:id/acquire", (req, res) => { - const core = getCoreDb(); - const listing = core - .prepare("SELECT * FROM marketplace_listings WHERE id=? AND status='active'") - .get(req.params.id) as Record | undefined; - if (!listing) { - res.status(404).json({ error: "Listing not found" }); - return; - } - const deliveryMode = String(listing.delivery_mode ?? "clone"); - const buyerDb = getReqTenantDb(req); - - try { - if (deliveryMode === "live") { - res.status(400).json({ - error: "Live listings use Shared grants, not marketplace acquire", - }); - return; - } - - const parsed = JSON.parse(String(listing.bundle_json)) as - | PortableBundle - | { children?: PortableBundle[] }; - const bundle: PortableBundle = - "version" in parsed && parsed.version === 1 - ? parsed - : { - version: 1, - kind: "bundle", - exportedAt: new Date().toISOString(), - sourceId: String(listing.id), - title: String(listing.title), - data: { children: (parsed as { children?: PortableBundle[] }).children ?? [] }, - }; - const result = importEntity(buyerDb, bundle); - core.prepare( - `INSERT INTO marketplace_purchases - (id, listing_id, buyer_user_id, buyer_tenant_id, price_credits) - VALUES (?, ?, ?, ?, ?)` - ).run(uuidv4(), listing.id, req.user!.id, req.tenantId!, 0); - res.json({ ok: true, mode: "clone", import: result }); - } catch (err) { - res.status(500).json({ error: err instanceof Error ? err.message : "Acquire failed" }); - } - }); - - router.post("/import", (req, res) => { - const bundle = req.body?.bundle as PortableBundle | undefined; - if (!bundle || bundle.version !== 1) { - res.status(400).json({ error: "Invalid bundle" }); - return; - } - try { - const result = importEntity(getReqTenantDb(req), bundle); - res.json({ ok: true, ...result }); - } catch (err) { - res.status(400).json({ error: err instanceof Error ? err.message : "Import failed" }); - } - }); - - router.post("/export", (req, res) => { - const { kind, resourceId } = req.body ?? {}; - if (typeof kind !== "string" || typeof resourceId !== "string") { - res.status(400).json({ error: "kind and resourceId required" }); - return; - } - try { - const bundle = exportEntity( - getReqTenantDb(req), - kind as MarketplaceListingKind, - resourceId - ); - res.json({ bundle }); - } catch (err) { - res.status(404).json({ error: err instanceof Error ? err.message : "Export failed" }); - } - }); - return router; } diff --git a/apps/bridge/src/routes/network.ts b/apps/bridge/src/routes/network.ts index ded628b..f5b77ac 100644 --- a/apps/bridge/src/routes/network.ts +++ b/apps/bridge/src/routes/network.ts @@ -19,84 +19,10 @@ export function createNetworkRouter(): Router { res.json(getNetworkStatus()); }); - router.post("/tailscale/enable", (_req, res) => { - res.json(enableTailscaleFederation()); - }); - router.get("/peers", (req, res) => { const core = getCoreDb(); res.json({ peers: listPeerConnections(core, req.user!.id) }); }); - router.post("/peers/invite", (req, res) => { - const { email, remoteBridgeUrl } = req.body ?? {}; - if (!email) { - res.status(400).json({ error: "email required" }); - return; - } - const core = getCoreDb(); - const result = invitePeerByEmail(core, req.user!.id, String(email), remoteBridgeUrl); - res.status(201).json(result); - }); - - router.post("/peers/refresh", async (req, res) => { - const core = getCoreDb(); - await refreshPeerHealth(core, req.user!.id); - res.json({ peers: listPeerConnections(core, req.user!.id) }); - }); - - router.post("/share-invites", (req, res) => { - const { resourceKind, resourceId, role, inviteeEmail } = req.body ?? {}; - if (!resourceKind || !resourceId || !inviteeEmail) { - res.status(400).json({ error: "resourceKind, resourceId, inviteeEmail required" }); - return; - } - const core = getCoreDb(); - const result = createFederatedShareInvite(core, { - ownerTenantId: req.tenantId!, - ownerUserId: req.user!.id, - resourceKind: String(resourceKind) as MarketplaceListingKind, - resourceId: String(resourceId), - role: role ?? "viewer", - inviteeEmail: String(inviteeEmail), - }); - res.status(201).json(result); - }); - - router.post("/share-invites/accept", async (req, res) => { - const { inviteToken, ownerBridgeUrl } = req.body ?? {}; - if (!inviteToken || !ownerBridgeUrl) { - res.status(400).json({ error: "inviteToken and ownerBridgeUrl required" }); - return; - } - try { - const ownerBase = String(ownerBridgeUrl).replace(/\/$/, ""); - const acceptRes = await fetch( - `${ownerBase}/api/federation/invites/${encodeURIComponent(String(inviteToken))}/accept`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - granteeUserId: req.user!.id, - granteeTenantId: req.tenantId, - granteeEmail: req.user!.email, - granteeDisplayName: req.user!.displayName, - granteeBridgeUrl: config.federation.publicUrl, - }), - } - ); - const data = (await acceptRes.json()) as Record; - if (!acceptRes.ok) { - res.status(acceptRes.status).json(data); - return; - } - res.json(data); - } catch (err) { - res.status(502).json({ - error: err instanceof Error ? err.message : "Failed to accept invite", - }); - } - }); - return router; } diff --git a/apps/bridge/src/routes/notifications.ts b/apps/bridge/src/routes/notifications.ts index 04787a4..810678e 100644 --- a/apps/bridge/src/routes/notifications.ts +++ b/apps/bridge/src/routes/notifications.ts @@ -28,36 +28,5 @@ export function createNotificationsRouter(): Router { res.json({ unreadCount: unreadCount({ kind: "user", id: req.user!.id }) }); }); - router.post("/read", (req, res) => { - const userId = req.user!.id; - const { ids, all } = req.body ?? {}; - if (all === true) { - const updated = markAllRead({ kind: "user", id: userId }); - res.json({ updated, unreadCount: unreadCount({ kind: "user", id: userId }) }); - return; - } - const list = Array.isArray(ids) ? ids.filter((x): x is string => typeof x === "string") : []; - const updated = markRead(list); - res.json({ updated, unreadCount: unreadCount({ kind: "user", id: userId }) }); - }); - - // Clear notifications for the caller. `?readOnly=1` keeps unread ones. - router.post("/clear", (req, res) => { - const userId = req.user!.id; - const readOnly = req.body?.readOnly === true || req.query.readOnly === "1"; - const deleted = clearNotifications({ kind: "user", id: userId }, { readOnly }); - res.json({ deleted, unreadCount: unreadCount({ kind: "user", id: userId }) }); - }); - - router.delete("/:id", (req, res) => { - const userId = req.user!.id; - const deleted = deleteNotification(req.params.id, { kind: "user", id: userId }); - if (deleted === 0) { - res.status(404).json({ error: "Notification not found" }); - return; - } - res.json({ ok: true, unreadCount: unreadCount({ kind: "user", id: userId }) }); - }); - return router; } diff --git a/apps/bridge/src/routes/onboarding.ts b/apps/bridge/src/routes/onboarding.ts index 6209a89..9b35f11 100644 --- a/apps/bridge/src/routes/onboarding.ts +++ b/apps/bridge/src/routes/onboarding.ts @@ -23,45 +23,5 @@ export function createOnboardingRouter(llm: LlmManager): Router { res.json({ ollama, localModels }); }); - router.post("/llm/local", async (req, res) => { - const model = String(req.body?.modelPath ?? req.body?.model ?? ""); - if (!model) { - res.status(400).json({ error: "modelPath required" }); - return; - } - const db = req.tenantDb; - if (!db) { - res.status(400).json({ error: "No active workspace" }); - return; - } - try { - await llm.start(model); - markLlmReady(db); - res.json({ ok: true, status: llm.getStatus() }); - } catch (err) { - res.status(400).json({ error: err instanceof Error ? err.message : String(err) }); - } - }); - - router.post("/llm/cloud-ready", (req, res) => { - const db = req.tenantDb; - if (!db) { - res.status(400).json({ error: "No active workspace" }); - return; - } - markLlmReady(db); - res.json({ ok: true }); - }); - - router.post("/complete", (req, res) => { - const db = req.tenantDb; - if (!db) { - res.status(400).json({ error: "No active workspace" }); - return; - } - markOnboardingComplete(db); - res.json({ ok: true }); - }); - return router; } diff --git a/apps/bridge/src/routes/plugins.ts b/apps/bridge/src/routes/plugins.ts index 4f8e0c9..953417b 100644 --- a/apps/bridge/src/routes/plugins.ts +++ b/apps/bridge/src/routes/plugins.ts @@ -4,15 +4,12 @@ import { Router } from "express"; import type { CoreDatabase } from "../core-db.js"; import { pluginRuntime } from "../plugins/runtime.js"; import { - installPluginForTenant, installedPluginIdsForTenant, isPluginEnabledForTenant, listAvailablePlugins, listInstalledPlugins, - uninstallPluginForTenant, } from "../plugins/plugin-install.js"; import { listPluginManifestsForWeb } from "../plugins/loader.js"; -import { requireTenantRole, attachAuthContext, requireAuth } from "../services/auth/middleware.js"; function safeSharedExportName(exportName: string): string | null { if (exportName.includes("/") || exportName.includes("\\") || exportName.includes("..")) { @@ -252,47 +249,5 @@ export function createPluginsRouter(coreDb: CoreDatabase): Router { }); }); - router.post("/install", requireTenantRole("owner"), async (req, res) => { - try { - const tenantId = req.tenantId; - if (!tenantId) { - res.status(400).json({ error: "tenant required" }); - return; - } - const pluginId = String(req.body?.pluginId ?? "").trim(); - if (!pluginId) { - res.status(400).json({ error: "pluginId required" }); - return; - } - await installPluginForTenant(coreDb, tenantId, pluginId, req.body?.pluginRoot); - res.json({ ok: true, pluginId }); - } catch (err) { - res.status(400).json({ - error: err instanceof Error ? err.message : "install failed", - }); - } - }); - - router.post("/uninstall", requireTenantRole("owner"), async (req, res) => { - try { - const tenantId = req.tenantId; - if (!tenantId) { - res.status(400).json({ error: "tenant required" }); - return; - } - const pluginId = String(req.body?.pluginId ?? "").trim(); - if (!pluginId) { - res.status(400).json({ error: "pluginId required" }); - return; - } - await uninstallPluginForTenant(coreDb, tenantId, pluginId); - res.json({ ok: true, pluginId }); - } catch (err) { - res.status(400).json({ - error: err instanceof Error ? err.message : "uninstall failed", - }); - } - }); - return router; } diff --git a/apps/bridge/src/routes/shares.ts b/apps/bridge/src/routes/shares.ts index ecd58bd..c87e49a 100644 --- a/apps/bridge/src/routes/shares.ts +++ b/apps/bridge/src/routes/shares.ts @@ -42,145 +42,11 @@ export function createSharesRouter(): Router { }); }); - router.post("/", (req, res) => { - const { - resourceKind, - resourceId, - granteeUserId, - granteeTenantId, - role, - } = req.body ?? {}; - if (typeof resourceKind !== "string" || typeof resourceId !== "string") { - res.status(400).json({ error: "resourceKind and resourceId required" }); - return; - } - // Plugin-backed resources (departments/divisions) carry federation metadata - // so the grantee's Bridge can proxy SC operations to the owner's Bridge. - const isScResource = - resourceKind === "department" || resourceKind === "division"; - const isUserProductivity = - resourceKind === "user_calendar" || resourceKind === "user_tasks"; - const ownerTenantId = isUserProductivity - ? getUserOwnerTenantId(req.user!.id) - : req.tenantId!; - try { - const id = createShareGrant(getCoreDb(), { - ownerTenantId, - ownerUserId: req.user!.id, - resourceKind: resourceKind as MarketplaceListingKind, - resourceId, - granteeUserId: - typeof granteeUserId === "string" ? granteeUserId : undefined, - granteeTenantId: - typeof granteeTenantId === "string" ? granteeTenantId : undefined, - role: (role as ShareGrantRole) ?? "viewer", - bridgeUrl: isScResource ? config.federation.publicUrl : null, - federationToken: isScResource ? uuidv4() : null, - }); - getShareBroker().broadcastResource( - resourceKind, - resourceId, - { type: "share_granted", grantId: id } - ); - res.status(201).json({ id }); - } catch (err) { - if (err instanceof ShareError) { - res.status(err.status).json({ error: err.message }); - return; - } - throw err; - } - }); - - // Free friend-to-friend model sharing. Resolves (or de-dupes) an inference - // endpoint for the owner's local model path, then grants a `model` share to - // the friend. No marketplace listing, no credits — `runRemoteInference` - // treats `model` grants as free. - router.post("/model", (req, res) => { - const core = getCoreDb(); - const { modelPath, granteeUserId, granteeEmail, name } = req.body ?? {}; - if (typeof modelPath !== "string" || !modelPath.trim()) { - res.status(400).json({ error: "modelPath required" }); - return; - } - let resolvedGranteeUserId: string | undefined = - typeof granteeUserId === "string" && granteeUserId.trim() - ? granteeUserId.trim() - : undefined; - if (!resolvedGranteeUserId && typeof granteeEmail === "string" && granteeEmail.trim()) { - const row = core - .prepare("SELECT id FROM users WHERE email=?") - .get(granteeEmail.trim().toLowerCase()) as { id: string } | undefined; - if (!row) { - res.status(404).json({ error: "No user with that email" }); - return; - } - resolvedGranteeUserId = row.id; - } - if (!resolvedGranteeUserId) { - res.status(400).json({ error: "granteeUserId or granteeEmail required" }); - return; - } - if (resolvedGranteeUserId === req.user!.id) { - res.status(400).json({ error: "Cannot share a model with yourself" }); - return; - } - const ownerTenantId = req.tenantId!; - const existing = findActiveEndpointByModelPath(core, req.user!.id, modelPath.trim()); - const derivedName = - (typeof name === "string" && name.trim()) || - modelPath.trim().split(/[\\/]/).pop()!.replace(/\.gguf$/i, ""); - const endpointId = - (existing?.id as string | undefined) ?? - createInferenceEndpoint(core, { - ownerTenantId, - ownerUserId: req.user!.id, - name: derivedName, - baseModelPath: modelPath.trim(), - }); - try { - const grantId = createShareGrant(core, { - ownerTenantId, - ownerUserId: req.user!.id, - resourceKind: "model", - resourceId: endpointId, - granteeUserId: resolvedGranteeUserId, - role: "viewer", - bridgeUrl: null, - federationToken: null, - }); - getShareBroker().broadcastResource("model", endpointId, { - type: "share_granted", - grantId, - }); - res.status(201).json({ id: grantId, endpointId }); - } catch (err) { - if (err instanceof ShareError) { - res.status(err.status).json({ error: err.message }); - return; - } - throw err; - } - }); - // Models shared WITH the caller (incoming `model` grants → endpoint + owner). router.get("/models", (req, res) => { res.json({ models: listSharedModelsForUser(getCoreDb(), req.user!.id) }); }); - router.delete("/:id", (req, res) => { - try { - revokeShareGrant(getCoreDb(), req.params.id, req.user!.id); - res.json({ ok: true }); - } catch (err) { - if (err instanceof ShareError) { - res.status(err.status).json({ error: err.message }); - return; - } - throw err; - } - }); - router.get("/resource/:kind/:resourceId", (req, res) => { const isUserProductivity = req.params.kind === "user_calendar" || req.params.kind === "user_tasks"; @@ -216,82 +82,5 @@ export function createSharesRouter(): Router { }); }); - router.post("/live/:kind/:resourceId/mutate", (req, res) => { - const access = resolveShareAccess(getCoreDb(), { - userId: req.user!.id, - tenantId: req.tenantId!, - resourceKind: req.params.kind as MarketplaceListingKind, - resourceId: req.params.resourceId, - minRole: "editor", - }); - if (!access) { - res.status(403).json({ error: "No edit access" }); - return; - } - assertShareRole(access.role, "editor"); - const { action, payload } = req.body ?? {}; - const ownerDb = access.db; - ownerDb.exec(` - CREATE TABLE IF NOT EXISTS share_mutation_log ( - id TEXT PRIMARY KEY, - resource_kind TEXT NOT NULL, - resource_id TEXT NOT NULL, - action TEXT, - payload_json TEXT, - actor_user_id TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')) - ); - `); - ownerDb.prepare( - `INSERT INTO share_mutation_log (id, resource_kind, resource_id, action, payload_json, actor_user_id) - VALUES (?, ?, ?, ?, ?, ?)` - ).run( - uuidv4(), - req.params.kind, - req.params.resourceId, - action != null ? String(action) : null, - payload != null ? JSON.stringify(payload) : null, - req.user!.id - ); - getShareBroker().broadcastResource( - req.params.kind, - req.params.resourceId, - { - type: "shared_mutation", - action, - payload, - actorUserId: req.user!.id, - tenantId: req.tenantId, - } - ); - res.json({ - ok: true, - persisted: true, - ownerTenantId: access.ownerTenantId, - }); - }); - - router.post("/clone/:kind/:resourceId", (req, res) => { - const access = resolveShareAccess(getCoreDb(), { - userId: req.user!.id, - tenantId: req.tenantId!, - resourceKind: req.params.kind as MarketplaceListingKind, - resourceId: req.params.resourceId, - minRole: "viewer", - }); - const sourceDb = access?.db ?? getReqTenantDb(req); - try { - const bundle = exportEntity( - sourceDb, - req.params.kind as MarketplaceListingKind, - req.params.resourceId - ); - const result = importEntity(getReqTenantDb(req), bundle); - res.json({ ok: true, ...result }); - } catch (err) { - res.status(400).json({ error: err instanceof Error ? err.message : "Clone failed" }); - } - }); - return router; } diff --git a/apps/bridge/src/routes/support.ts b/apps/bridge/src/routes/support.ts index 2ccf160..2b7b3ec 100644 --- a/apps/bridge/src/routes/support.ts +++ b/apps/bridge/src/routes/support.ts @@ -48,48 +48,6 @@ export function createSupportRouter(): Router { const router = Router(); router.use(attachAuthContext, requireAuth); - router.post("/tickets", (req, res) => { - const userId = req.user!.id; - const { subject, body, category, targetKind, sharedGrantId, ownerUserId } = req.body ?? {}; - try { - let resolvedOwnerId = - typeof ownerUserId === "string" ? ownerUserId : undefined; - if (!resolvedOwnerId && sharedGrantId) { - const grant = getCoreDb() - .prepare(`SELECT owner_user_id FROM share_grants WHERE id=?`) - .get(String(sharedGrantId)) as { owner_user_id: string } | undefined; - resolvedOwnerId = grant?.owner_user_id; - } - const result = createTicket({ - requesterKind: "user", - requesterId: userId, - requesterTenantId: getUserOwnerTenantId(userId), - subject: String(subject ?? ""), - body: String(body ?? ""), - category: category ?? null, - targetKind: - targetKind === "platform_github" - ? "platform_github" - : targetKind === "platform_admin" - ? "platform_admin" - : "resource_owner", - sharedGrantId: sharedGrantId ? String(sharedGrantId) : null, - ownerUserId: resolvedOwnerId ?? null, - }); - if ("redirectUrl" in result) { - res.status(201).json(result); - return; - } - res.status(201).json({ ticket: result }); - } catch (err) { - if (err instanceof SupportError) { - res.status(err.status).json({ error: err.message }); - return; - } - throw err; - } - }); - router.get("/tickets", (req, res) => { res.json({ tickets: listTicketsForRequester("user", req.user!.id) }); }); @@ -128,61 +86,6 @@ export function createSupportRouter(): Router { res.json({ ticket, messages: getTicketMessages(id) }); }); - router.post("/tickets/:id/messages", (req, res) => { - const id = paramId(req.params.id); - const ticket = getTicket(id); - if (!ticket) { - res.status(404).json({ error: "Ticket not found" }); - return; - } - if (!canAccessTicket(ticket, req.user!)) { - res.status(403).json({ error: "Not allowed" }); - return; - } - const isRequester = - ticket.requester_kind === "user" && ticket.requester_id === req.user!.id; - const isStaff = canStaffSupportAsUser(req.user!); - try { - const message = addMessage( - id, - { - kind: isStaff && !isRequester ? "admin" : "user", - id: req.user!.id, - }, - String(req.body?.body ?? "") - ); - res.status(201).json({ message }); - } catch (err) { - if (err instanceof SupportError) { - res.status(err.status).json({ error: err.message }); - return; - } - throw err; - } - }); - - router.patch("/tickets/:id", (req, res) => { - const id = paramId(req.params.id); - if (!canStaffSupportAsUser(req.user!)) { - res.status(403).json({ error: "Not allowed" }); - return; - } - const { status, priority } = req.body ?? {}; - try { - const ticket = updateTicket(id, { - status: status && STATUSES.includes(status) ? status : undefined, - priority: priority !== undefined ? priority : undefined, - }); - res.json({ ticket }); - } catch (err) { - if (err instanceof SupportError) { - res.status(err.status).json({ error: err.message }); - return; - } - throw err; - } - }); - // --- Support group membership --- router.get("/group", (req, res) => { const group = getGroupBySlug(SUPPORT_GROUP_SLUG); @@ -207,56 +110,6 @@ export function createSupportRouter(): Router { }); }); - router.post("/group/members", requirePlatformAdmin, (req, res) => { - const group = getGroupBySlug(SUPPORT_GROUP_SLUG); - if (!group) { - res.status(404).json({ error: "Support group not found" }); - return; - } - const memberKind = req.body?.memberKind === "agent" ? "agent" : "user"; - const memberId = String(req.body?.memberId ?? "").trim(); - if (!memberId) { - res.status(400).json({ error: "memberId required" }); - return; - } - try { - const member = addGroupMember({ - groupId: group.id, - memberKind, - memberId, - tenantId: req.body?.tenantId ? String(req.body.tenantId) : null, - }); - res.status(201).json({ - member: { ...member, tenant_id: member.tenant_id || null }, - }); - } catch (err) { - res.status(400).json({ - error: err instanceof Error ? err.message : String(err), - }); - } - }); - - router.delete("/group/members", requirePlatformAdmin, (req, res) => { - const group = getGroupBySlug(SUPPORT_GROUP_SLUG); - if (!group) { - res.status(404).json({ error: "Support group not found" }); - return; - } - const memberKind = req.body?.memberKind === "agent" ? "agent" : "user"; - const memberId = String(req.body?.memberId ?? "").trim(); - if (!memberId) { - res.status(400).json({ error: "memberId required" }); - return; - } - const ok = removeGroupMember({ - groupId: group.id, - memberKind, - memberId, - tenantId: req.body?.tenantId ? String(req.body.tenantId) : null, - }); - res.json({ ok }); - }); - // --- admin triage (kept for Admin page; same ACL as staff) --- router.get("/admin/tickets", (req, res) => { if (!canStaffSupportAsUser(req.user!)) { @@ -271,27 +124,5 @@ export function createSupportRouter(): Router { }); }); - router.patch("/admin/tickets/:id", (req, res) => { - if (!canStaffSupportAsUser(req.user!)) { - res.status(403).json({ error: "Not allowed" }); - return; - } - const id = paramId(req.params.id); - const { status, priority } = req.body ?? {}; - try { - const ticket = updateTicket(id, { - status: status && STATUSES.includes(status) ? status : undefined, - priority: priority !== undefined ? priority : undefined, - }); - res.json({ ticket }); - } catch (err) { - if (err instanceof SupportError) { - res.status(err.status).json({ error: err.message }); - return; - } - throw err; - } - }); - return router; } diff --git a/apps/bridge/src/routes/user-productivity.ts b/apps/bridge/src/routes/user-productivity.ts index 3d4910f..5f87269 100644 --- a/apps/bridge/src/routes/user-productivity.ts +++ b/apps/bridge/src/routes/user-productivity.ts @@ -60,134 +60,6 @@ export function createUserProductivityRouter(): Router { } }); - router.post("/calendar/events", (req, res) => { - try { - const access = resolveUserCalendarAccess(req, "editor"); - requireWriteAccess(access); - const { - kind, - title, - description, - start_at, - end_at, - all_day, - location, - linked_card_id, - linked_run_id, - status, - } = req.body ?? {}; - if (!title || !String(title).trim() || !start_at || !String(start_at).trim()) { - res.status(400).json({ error: "title and start_at required" }); - return; - } - const id = uuidv4(); - const k = CALENDAR_KINDS.has(String(kind)) ? String(kind) : "event"; - access.db.prepare( - `INSERT INTO ai_calendar_events - (id, agent_id, user_id, kind, title, description, start_at, end_at, all_day, location, linked_card_id, linked_run_id, status) - VALUES (?, '', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` - ).run( - id, - access.ownerUserId, - k, - String(title), - description ?? null, - String(start_at), - end_at ?? null, - all_day ? 1 : 0, - location ?? null, - linked_card_id ?? null, - linked_run_id ?? null, - status ? String(status) : "scheduled" - ); - res - .status(201) - .json(access.db.prepare(`SELECT * FROM ai_calendar_events WHERE id = ?`).get(id)); - } catch (err) { - if (!handleShareError(err, res)) { - res.status(500).json({ error: String(err) }); - } - } - }); - - router.patch("/calendar/events/:id", (req, res) => { - try { - const access = resolveUserCalendarAccess(req, "editor"); - requireWriteAccess(access); - const row = access.db - .prepare(`SELECT id FROM ai_calendar_events WHERE id=? AND user_id=?`) - .get(req.params.id, access.ownerUserId); - if (!row) { - res.status(404).json({ error: "Not found" }); - return; - } - const { title, description, start_at, end_at, all_day, location, kind, status } = - req.body ?? {}; - if (title != null) { - access.db.prepare( - `UPDATE ai_calendar_events SET title = ?, updated_at = datetime('now') WHERE id = ?` - ).run(String(title), req.params.id); - } - if (description !== undefined) { - access.db.prepare( - `UPDATE ai_calendar_events SET description = ?, updated_at = datetime('now') WHERE id = ?` - ).run(description === null ? null : String(description), req.params.id); - } - if (start_at != null) { - access.db.prepare( - `UPDATE ai_calendar_events SET start_at = ?, updated_at = datetime('now') WHERE id = ?` - ).run(String(start_at), req.params.id); - } - if (end_at !== undefined) { - access.db.prepare( - `UPDATE ai_calendar_events SET end_at = ?, updated_at = datetime('now') WHERE id = ?` - ).run(end_at === null ? null : String(end_at), req.params.id); - } - if (all_day != null) { - access.db.prepare( - `UPDATE ai_calendar_events SET all_day = ?, updated_at = datetime('now') WHERE id = ?` - ).run(all_day ? 1 : 0, req.params.id); - } - if (location !== undefined) { - access.db.prepare( - `UPDATE ai_calendar_events SET location = ?, updated_at = datetime('now') WHERE id = ?` - ).run(location === null ? null : String(location), req.params.id); - } - if (kind != null && CALENDAR_KINDS.has(String(kind))) { - access.db.prepare( - `UPDATE ai_calendar_events SET kind = ?, updated_at = datetime('now') WHERE id = ?` - ).run(String(kind), req.params.id); - } - if (status != null) { - access.db.prepare( - `UPDATE ai_calendar_events SET status = ?, updated_at = datetime('now') WHERE id = ?` - ).run(String(status), req.params.id); - } - res.json( - access.db.prepare(`SELECT * FROM ai_calendar_events WHERE id = ?`).get(req.params.id) - ); - } catch (err) { - if (!handleShareError(err, res)) { - res.status(500).json({ error: String(err) }); - } - } - }); - - router.delete("/calendar/events/:id", (req, res) => { - try { - const access = resolveUserCalendarAccess(req, "editor"); - requireWriteAccess(access); - const r = access.db - .prepare(`DELETE FROM ai_calendar_events WHERE id=? AND user_id=?`) - .run(req.params.id, access.ownerUserId); - res.json({ ok: r.changes > 0 }); - } catch (err) { - if (!handleShareError(err, res)) { - res.status(500).json({ error: String(err) }); - } - } - }); - router.get("/calendar/activity", (req, res) => { try { const access = resolveUserCalendarAccess(req, "viewer"); @@ -255,202 +127,6 @@ export function createUserProductivityRouter(): Router { } }); - router.post("/projects/cards", (req, res) => { - try { - const access = resolveUserTasksAccess(req, "editor"); - requireWriteAccess(access); - const id = newId(); - const { - columnId, - title, - description, - prompt, - contextJson, - tags, - dueAt, - linkedChatId, - linkedWorkflowId, - priority, - parentCardId, - status, - assignedAgentId, - } = req.body ?? {}; - const pid = ensureUserProject(access.ownerUserId, access.db); - let resolvedPid = pid; - if (parentCardId != null) { - const parent = access.db - .prepare(`SELECT project_id FROM ai_project_cards WHERE id = ? AND project_id = ?`) - .get(String(parentCardId), pid) as { project_id: string } | undefined; - resolvedPid = parent?.project_id ?? pid; - } - const cid = String(columnId ?? "backlog"); - const ctx = - contextJson == null - ? null - : typeof contextJson === "string" - ? contextJson - : JSON.stringify(contextJson); - const maxOrder = access.db - .prepare(`SELECT COALESCE(MAX(sort_order), -1) as m FROM ai_project_cards WHERE column_id = ? AND project_id = ?`) - .get(cid, resolvedPid) as { m: number }; - access.db.prepare( - `INSERT INTO ai_project_cards (id, project_id, column_id, title, description, prompt, context_json, tags_json, due_at, linked_chat_id, linked_workflow_id, priority, parent_card_id, status, assigned_agent_id, sort_order) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` - ).run( - id, - resolvedPid, - cid, - String(title ?? "Untitled"), - description ?? null, - prompt ?? null, - ctx, - tags ?? null, - dueAt ?? null, - linkedChatId ?? null, - linkedWorkflowId ?? null, - priority != null ? Number(priority) : 2, - parentCardId ?? null, - status ?? null, - assignedAgentId ?? null, - maxOrder.m + 1 - ); - res - .status(201) - .json(access.db.prepare(`SELECT * FROM ai_project_cards WHERE id = ?`).get(id)); - } catch (err) { - if (!handleShareError(err, res)) { - res.status(500).json({ error: String(err) }); - } - } - }); - - router.patch("/projects/cards/:id", (req, res) => { - try { - const access = resolveUserTasksAccess(req, "editor"); - requireWriteAccess(access); - const pid = ensureUserProject(access.ownerUserId, access.db); - const owned = access.db - .prepare(`SELECT id FROM ai_project_cards WHERE id=? AND project_id=?`) - .get(req.params.id, pid); - if (!owned) { - res.status(404).json({ error: "Not found" }); - return; - } - const { - columnId, - sortOrder, - title, - description, - prompt, - contextJson, - tags, - dueAt, - linkedChatId, - linkedWorkflowId, - priority, - parentCardId, - status, - assignedAgentId, - } = req.body ?? {}; - const patch = access.db; - if (priority != null) { - patch.prepare( - `UPDATE ai_project_cards SET priority = ?, updated_at = datetime('now') WHERE id = ?` - ).run(Number(priority), req.params.id); - } - if (parentCardId !== undefined) { - patch.prepare( - `UPDATE ai_project_cards SET parent_card_id = ?, updated_at = datetime('now') WHERE id = ?` - ).run(parentCardId === null ? null : String(parentCardId), req.params.id); - } - if (status != null) { - patch.prepare( - `UPDATE ai_project_cards SET status = ?, updated_at = datetime('now') WHERE id = ?` - ).run(String(status), req.params.id); - } - if (columnId != null) { - patch.prepare( - `UPDATE ai_project_cards SET column_id = ?, updated_at = datetime('now') WHERE id = ?` - ).run(String(columnId), req.params.id); - } - if (sortOrder != null) { - patch.prepare( - `UPDATE ai_project_cards SET sort_order = ?, updated_at = datetime('now') WHERE id = ?` - ).run(Number(sortOrder), req.params.id); - } - if (title != null) { - patch.prepare( - `UPDATE ai_project_cards SET title = ?, updated_at = datetime('now') WHERE id = ?` - ).run(String(title), req.params.id); - } - if (description != null) { - patch.prepare( - `UPDATE ai_project_cards SET description = ?, updated_at = datetime('now') WHERE id = ?` - ).run(String(description), req.params.id); - } - if (prompt != null) { - patch.prepare( - `UPDATE ai_project_cards SET prompt = ?, updated_at = datetime('now') WHERE id = ?` - ).run(String(prompt), req.params.id); - } - if (contextJson != null) { - const ctx = - typeof contextJson === "string" ? contextJson : JSON.stringify(contextJson); - patch.prepare( - `UPDATE ai_project_cards SET context_json = ?, updated_at = datetime('now') WHERE id = ?` - ).run(ctx, req.params.id); - } - if (tags != null) { - patch.prepare( - `UPDATE ai_project_cards SET tags_json = ?, updated_at = datetime('now') WHERE id = ?` - ).run(String(tags), req.params.id); - } - if (dueAt != null) { - patch.prepare( - `UPDATE ai_project_cards SET due_at = ?, updated_at = datetime('now') WHERE id = ?` - ).run(String(dueAt), req.params.id); - } - if (linkedChatId != null) { - patch.prepare( - `UPDATE ai_project_cards SET linked_chat_id = ?, updated_at = datetime('now') WHERE id = ?` - ).run(String(linkedChatId), req.params.id); - } - if (linkedWorkflowId != null) { - patch.prepare( - `UPDATE ai_project_cards SET linked_workflow_id = ?, updated_at = datetime('now') WHERE id = ?` - ).run(String(linkedWorkflowId), req.params.id); - } - if (assignedAgentId !== undefined) { - patch.prepare( - `UPDATE ai_project_cards SET assigned_agent_id = ?, updated_at = datetime('now') WHERE id = ?` - ).run(assignedAgentId === null ? null : String(assignedAgentId), req.params.id); - } - res.json( - patch.prepare(`SELECT * FROM ai_project_cards WHERE id = ?`).get(req.params.id) - ); - } catch (err) { - if (!handleShareError(err, res)) { - res.status(500).json({ error: String(err) }); - } - } - }); - - router.delete("/projects/cards/:id", (req, res) => { - try { - const access = resolveUserTasksAccess(req, "editor"); - requireWriteAccess(access); - const pid = ensureUserProject(access.ownerUserId, access.db); - const r = access.db - .prepare(`DELETE FROM ai_project_cards WHERE id=? AND project_id=?`) - .run(req.params.id, pid); - res.json({ ok: r.changes > 0 }); - } catch (err) { - if (!handleShareError(err, res)) { - res.status(500).json({ error: String(err) }); - } - } - }); - router.get("/projects/cards/:id/subtasks", (req, res) => { try { const access = resolveUserTasksAccess(req, "viewer"); @@ -504,37 +180,5 @@ export function createUserProductivityRouter(): Router { } }); - router.post("/projects/cards/:id/comments", (req, res) => { - try { - const access = resolveUserTasksAccess(req, "editor"); - requireWriteAccess(access); - const pid = ensureUserProject(access.ownerUserId, access.db); - const card = access.db - .prepare(`SELECT id FROM ai_project_cards WHERE id=? AND project_id=?`) - .get(req.params.id, pid); - if (!card) { - res.status(404).json({ error: "Not found" }); - return; - } - const body = String(req.body?.body ?? "").trim(); - if (!body) { - res.status(400).json({ error: "body required" }); - return; - } - const author = req.body?.author === "agent" ? "agent" : "user"; - const id = uuidv4(); - access.db.prepare( - `INSERT INTO ai_card_comments (id, card_id, author, body) VALUES (?, ?, ?, ?)` - ).run(id, req.params.id, author, body); - res - .status(201) - .json(access.db.prepare(`SELECT * FROM ai_card_comments WHERE id = ?`).get(id)); - } catch (err) { - if (!handleShareError(err, res)) { - res.status(500).json({ error: String(err) }); - } - } - }); - return router; } diff --git a/apps/bridge/src/routes/wiki.ts b/apps/bridge/src/routes/wiki.ts index f7f6c49..ea43169 100644 --- a/apps/bridge/src/routes/wiki.ts +++ b/apps/bridge/src/routes/wiki.ts @@ -74,31 +74,6 @@ export function createWikiRouter(embeddings?: EmbeddingManager): Router { }); }); - router.post("/proposals/:id/approve", (req, res) => { - const scope = resolveScope(req.user!.id); - const result = approveWikiProposal(paramId(req.params.id), { - authorUserId: req.user!.id, - scope, - embedder: embeddings?.isEmbedderReady() - ? embeddings.getEmbeddingClient() - : null, - }); - if (!result.ok) { - res.status(400).json({ error: result.error ?? "Approve failed" }); - return; - } - res.json(result); - }); - - router.post("/proposals/:id/reject", (req, res) => { - const ok = rejectWikiProposal(paramId(req.params.id)); - if (!ok) { - res.status(404).json({ error: "Proposal not found or not pending" }); - return; - } - res.json({ ok: true }); - }); - router.get("/pages", (req, res) => { const scope = resolveScope(req.user!.id); const visibility = req.query.visibility as WikiVisibility | undefined; @@ -133,70 +108,5 @@ export function createWikiRouter(embeddings?: EmbeddingManager): Router { } }); - router.post("/pages", (req, res) => { - const userId = req.user!.id; - const tenantId = getUserOwnerTenantId(userId); - const b = req.body ?? {}; - try { - const page = createPage({ - tenantId, - authorUserId: userId, - title: String(b.title ?? ""), - bodyMarkdown: typeof b.bodyMarkdown === "string" ? b.bodyMarkdown : "", - space: b.space ?? null, - visibility: b.visibility === "external" ? "external" : "internal", - slug: typeof b.slug === "string" ? b.slug : undefined, - }); - res.status(201).json({ page }); - } catch (err) { - if (err instanceof WikiError) { - res.status(err.status).json({ error: err.message }); - return; - } - throw err; - } - }); - - router.patch("/pages/:id", (req, res) => { - const scope = resolveScope(req.user!.id); - const b = req.body ?? {}; - try { - const page = updatePage( - paramId(req.params.id), - { - title: b.title, - bodyMarkdown: b.bodyMarkdown, - space: b.space, - visibility: - b.visibility === "internal" || b.visibility === "external" - ? b.visibility - : undefined, - }, - scope - ); - res.json({ page }); - } catch (err) { - if (err instanceof WikiError) { - res.status(err.status).json({ error: err.message }); - return; - } - throw err; - } - }); - - router.delete("/pages/:id", (req, res) => { - const scope = resolveScope(req.user!.id); - try { - deletePage(paramId(req.params.id), scope); - res.json({ ok: true }); - } catch (err) { - if (err instanceof WikiError) { - res.status(err.status).json({ error: err.message }); - return; - } - throw err; - } - }); - return router; } diff --git a/apps/bridge/src/services/__tests__/db-boot-smoke.test.ts b/apps/bridge/src/services/__tests__/db-boot-smoke.test.ts new file mode 100644 index 0000000..640a6ea --- /dev/null +++ b/apps/bridge/src/services/__tests__/db-boot-smoke.test.ts @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import Database from "better-sqlite3"; +import { migrateTenantDb, TENANT_BOOT_MIGRATIONS } from "../../db.js"; + +const db = new Database(":memory:"); +migrateTenantDb(db); + +const firstVersions = db + .prepare("SELECT version, name FROM schema_version ORDER BY version") + .all() as Array<{ version: number; name: string }>; +assert.deepEqual( + firstVersions.filter((row) => row.version >= 7), + TENANT_BOOT_MIGRATIONS.map(({ version, name }) => ({ version, name })) +); +assert.deepEqual(db.prepare("PRAGMA foreign_key_check").all(), []); + +const firstSchemaCount = ( + db + .prepare( + `SELECT COUNT(*) AS count FROM sqlite_master + WHERE type IN ('table', 'index', 'trigger', 'view')` + ) + .get() as { count: number } +).count; +migrateTenantDb(db); +assert.equal( + ( + db + .prepare( + `SELECT COUNT(*) AS count FROM sqlite_master + WHERE type IN ('table', 'index', 'trigger', 'view')` + ) + .get() as { count: number } + ).count, + firstSchemaCount +); +assert.deepEqual( + db.prepare("SELECT version, name FROM schema_version ORDER BY version").all(), + firstVersions +); +assert.deepEqual(db.prepare("PRAGMA foreign_key_check").all(), []); + +db.close(); +console.log("db-boot-smoke.test.ts: ok"); diff --git a/apps/bridge/src/services/__tests__/db-migrations-upgrade.test.ts b/apps/bridge/src/services/__tests__/db-migrations-upgrade.test.ts new file mode 100644 index 0000000..03b9221 --- /dev/null +++ b/apps/bridge/src/services/__tests__/db-migrations-upgrade.test.ts @@ -0,0 +1,375 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import Database from "better-sqlite3"; +import { + addCol, + columnExists, + runMigrations, + type Migration, +} from "../db-migrations.js"; +import { migrateScLevelsSchema } from "../sc-levels-migration.js"; +import { migrateStructureNodes } from "../structure-nodes-migration.js"; +import { CORE_MIGRATIONS } from "../../core-db.js"; + +const fixturesDir = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "fixtures" +); + +function fixture(name: string): Database.Database { + const db = new Database(":memory:"); + db.exec(fs.readFileSync(path.join(fixturesDir, name), "utf8")); + return db; +} + +function checksum(db: Database.Database, sql: string): string { + const rows = db.prepare(sql).all(); + return createHash("sha256").update(JSON.stringify(rows)).digest("hex"); +} + +function foreignKeyViolations(db: Database.Database): unknown[] { + return db.prepare("PRAGMA foreign_key_check").all(); +} + +{ + const db = fixture("historical-core.sql"); + const before = checksum( + db, + `SELECT u.id, u.email, u.display_name, t.id AS tenant_id, m.role + FROM users u + LEFT JOIN tenant_memberships m ON m.user_id=u.id + LEFT JOIN tenants t ON t.id=m.tenant_id + ORDER BY u.id` + ); + db.exec(` + CREATE TABLE schema_version ( + version INTEGER PRIMARY KEY, + name TEXT NOT NULL, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + INSERT INTO schema_version (version, name) VALUES (2, 'existing_higher_version'); + `); + + const coreMigrations: Migration[] = [ + { + version: 1, + name: "newly_introduced_lower_version", + up: (target) => addCol(target, "users", "timezone", "TEXT"), + }, + { + version: 2, + name: "existing_higher_version", + up: () => assert.fail("an applied migration must not run again"), + }, + ]; + runMigrations(db, coreMigrations); + + assert.equal(columnExists(db, "users", "timezone"), true); + assert.deepEqual( + db.prepare("SELECT version FROM schema_version ORDER BY version").all(), + [{ version: 1 }, { version: 2 }] + ); + assert.equal( + checksum( + db, + `SELECT u.id, u.email, u.display_name, t.id AS tenant_id, m.role + FROM users u + LEFT JOIN tenant_memberships m ON m.user_id=u.id + LEFT JOIN tenants t ON t.id=m.tenant_id + ORDER BY u.id` + ), + before + ); + assert.equal( + (db.prepare("SELECT COUNT(*) AS count FROM users").get() as { count: number }).count, + 2 + ); + assert.deepEqual(foreignKeyViolations(db), []); + + runMigrations(db, coreMigrations); + assert.equal( + (db.prepare("SELECT COUNT(*) AS count FROM schema_version").get() as { + count: number; + }).count, + 2, + "a second upgrade must be idempotent" + ); + db.close(); +} + +{ + const db = fixture("historical-tenant.sql"); + const legacyStructureChecksum = checksum( + db, + `SELECT d.id, d.label, v.id AS division_id, p.id AS page_id, p.page_kind + FROM departments d + JOIN divisions v ON v.department_id=d.id + JOIN division_pages p + ON p.department_id=v.department_id AND p.division_id=v.id + ORDER BY d.id, v.id, p.id` + ); + const levelChecksum = checksum( + db, + `SELECT symbol, label, price, kind, chart_number, study_id, subgraph_index, ts + FROM sc_levels ORDER BY symbol, label` + ); + const customStructureChecksum = checksum( + db, + `SELECT id, parent_id, label, icon, segment, kind, built_in, sort_order + FROM structure_nodes WHERE id='custom-root'` + ); + const tenantMigrations: Migration[] = [ + { version: 2, name: "structure_nodes_flatten_v1", up: migrateStructureNodes }, + { version: 5, name: "sc_levels_source_key_v1", up: migrateScLevelsSchema }, + ]; + + runMigrations(db, tenantMigrations); + + assert.equal( + checksum( + db, + `SELECT d.id, d.label, v.id AS division_id, p.id AS page_id, p.page_kind + FROM departments d + JOIN divisions v ON v.department_id=d.id + JOIN division_pages p + ON p.department_id=v.department_id AND p.division_id=v.id + ORDER BY d.id, v.id, p.id` + ), + legacyStructureChecksum + ); + assert.equal( + checksum( + db, + `SELECT symbol, label, price, kind, chart_number, study_id, subgraph_index, ts + FROM sc_levels ORDER BY symbol, label` + ), + levelChecksum + ); + assert.equal( + (db.prepare("SELECT COUNT(*) AS count FROM structure_nodes").get() as { + count: number; + }).count, + 4 + ); + assert.equal( + checksum( + db, + `SELECT id, parent_id, label, icon, segment, kind, built_in, sort_order + FROM structure_nodes WHERE id='custom-root'` + ), + customStructureChecksum, + "pre-existing custom structure must remain unchanged" + ); + assert.equal( + (db.prepare("SELECT COUNT(*) AS count FROM sc_levels").get() as { + count: number; + }).count, + 2 + ); + assert.deepEqual( + db.prepare("SELECT source_key FROM sc_levels ORDER BY source_key").all(), + [{ source_key: "Prior High" }, { source_key: "VWAP" }] + ); + assert.deepEqual( + db.prepare("SELECT agent_id FROM ai_agent_assignments ORDER BY agent_id").all(), + [{ agent_id: "custom-agent" }], + "unrelated assignments must survive structure cleanup" + ); + assert.deepEqual(foreignKeyViolations(db), []); + + const after = checksum( + db, + `SELECT id, parent_id, label, segment, kind, built_in, sort_order + FROM structure_nodes ORDER BY id` + ); + runMigrations(db, tenantMigrations); + assert.equal( + checksum( + db, + `SELECT id, parent_id, label, segment, kind, built_in, sort_order + FROM structure_nodes ORDER BY id` + ), + after + ); + assert.deepEqual(foreignKeyViolations(db), []); + db.close(); +} + +{ + const db = new Database(":memory:"); + db.pragma("foreign_keys = ON"); + db.exec(` + CREATE TABLE parent_rows (id TEXT PRIMARY KEY); + CREATE TABLE child_rows ( + id TEXT PRIMARY KEY, + parent_id TEXT NOT NULL REFERENCES parent_rows(id) + ); + INSERT INTO parent_rows VALUES ('parent-1'); + INSERT INTO child_rows VALUES ('child-1', 'parent-1'); + `); + assert.throws( + () => + runMigrations(db, [ + { + version: 1, + name: "unsafe_fk_rebuild", + foreignKeysOff: true, + up: (target) => { + target.exec(` + DROP TABLE parent_rows; + CREATE TABLE parent_rows (id TEXT PRIMARY KEY); + `); + }, + }, + ]), + /foreign key violation/ + ); + assert.equal(db.pragma("foreign_keys", { simple: true }), 1); + assert.deepEqual(db.prepare("SELECT id FROM parent_rows").all(), [ + { id: "parent-1" }, + ]); + assert.equal( + db.prepare("SELECT 1 FROM schema_version WHERE version=1").get(), + undefined + ); + + runMigrations(db, [ + { + version: 1, + name: "safe_fk_rebuild", + foreignKeysOff: true, + up: (target) => { + target.exec(` + CREATE TABLE parent_rows_new (id TEXT PRIMARY KEY); + INSERT INTO parent_rows_new SELECT id FROM parent_rows; + DROP TABLE parent_rows; + ALTER TABLE parent_rows_new RENAME TO parent_rows; + `); + }, + }, + ]); + assert.equal(db.pragma("foreign_keys", { simple: true }), 1); + assert.deepEqual(foreignKeyViolations(db), []); + db.close(); +} + +{ + const db = fixture("historical-core.sql"); + const before = checksum( + db, + `SELECT 'listing' AS kind, id, title AS value FROM marketplace_listings + UNION ALL SELECT 'message', id, body_text FROM dm_messages + UNION ALL SELECT 'hook-run', id, status FROM hook_runs + UNION ALL SELECT 'wiki', id, body_markdown FROM wiki_pages + UNION ALL SELECT 'revision', id, body_markdown FROM wiki_revisions + UNION ALL SELECT 'ticket', id, body FROM support_tickets + ORDER BY kind, id` + ); + + runMigrations(db, CORE_MIGRATIONS); + + assert.equal(columnExists(db, "users", "password_hash"), true); + assert.equal(columnExists(db, "marketplace_listings", "pricing_model"), true); + assert.equal(columnExists(db, "dm_messages", "sender_kind"), true); + assert.equal(columnExists(db, "wiki_pages", "embedding"), true); + assert.equal(columnExists(db, "support_tickets", "target_kind"), true); + assert.match( + ( + db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='hooks'") + .get() as { sql: string } + ).sql, + /run_workflow/ + ); + assert.equal( + ( + db.prepare( + "SELECT COUNT(*) AS count FROM pragma_foreign_key_list('dm_conversation_members') WHERE `table`='users'" + ).get() as { count: number } + ).count, + 0 + ); + assert.equal( + checksum( + db, + `SELECT 'listing' AS kind, id, title AS value FROM marketplace_listings + UNION ALL SELECT 'message', id, body_text FROM dm_messages + UNION ALL SELECT 'hook-run', id, status FROM hook_runs + UNION ALL SELECT 'wiki', id, body_markdown FROM wiki_pages + UNION ALL SELECT 'revision', id, body_markdown FROM wiki_revisions + UNION ALL SELECT 'ticket', id, body FROM support_tickets + ORDER BY kind, id` + ), + before + ); + assert.equal( + (db.prepare("SELECT COUNT(*) AS count FROM schema_version").get() as { + count: number; + }).count, + CORE_MIGRATIONS.length + ); + assert.deepEqual(foreignKeyViolations(db), []); + + const migrated = checksum( + db, + `SELECT type, name, sql FROM sqlite_master + WHERE name NOT LIKE 'sqlite_%' ORDER BY type, name` + ); + runMigrations(db, CORE_MIGRATIONS); + assert.equal( + checksum( + db, + `SELECT type, name, sql FROM sqlite_master + WHERE name NOT LIKE 'sqlite_%' ORDER BY type, name` + ), + migrated, + "a second core upgrade must be idempotent" + ); + assert.deepEqual(foreignKeyViolations(db), []); + db.close(); +} + +{ + const db = fixture("historical-core.sql"); + const interrupted: Migration = { + version: 7, + name: "interrupted_upgrade", + up: (target) => { + target.exec("CREATE TABLE interrupted_data (id TEXT PRIMARY KEY)"); + target.prepare("INSERT INTO interrupted_data VALUES (?)").run("partial"); + throw new Error("simulated interruption"); + }, + }; + assert.throws(() => runMigrations(db, [interrupted]), /simulated interruption/); + assert.equal( + db.prepare( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='interrupted_data'" + ).get(), + undefined + ); + assert.equal( + db.prepare("SELECT 1 FROM schema_version WHERE version=7").get(), + undefined + ); + + runMigrations(db, [ + { + version: 7, + name: "interrupted_upgrade", + up: (target) => { + target.exec("CREATE TABLE interrupted_data (id TEXT PRIMARY KEY)"); + target.prepare("INSERT INTO interrupted_data VALUES (?)").run("recovered"); + }, + }, + ]); + assert.deepEqual(db.prepare("SELECT id FROM interrupted_data").all(), [ + { id: "recovered" }, + ]); + assert.ok(db.prepare("SELECT 1 FROM schema_version WHERE version=7").get()); + assert.throws(() => addCol(db, "missing_table", "value", "TEXT"), /does not exist/); + db.close(); +} + +console.log("db-migrations-upgrade.test.ts: ok"); diff --git a/apps/bridge/src/services/__tests__/fixtures/historical-core.sql b/apps/bridge/src/services/__tests__/fixtures/historical-core.sql new file mode 100644 index 0000000..db0e642 --- /dev/null +++ b/apps/bridge/src/services/__tests__/fixtures/historical-core.sql @@ -0,0 +1,154 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL +); +CREATE TABLE tenants ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + owner_user_id TEXT NOT NULL REFERENCES users(id) +); +CREATE TABLE tenant_memberships ( + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + role TEXT NOT NULL, + PRIMARY KEY (user_id, tenant_id) +); +CREATE TABLE user_profiles ( + user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + headline TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE TABLE marketplace_listings ( + id TEXT PRIMARY KEY, + seller_user_id TEXT NOT NULL REFERENCES users(id), + seller_tenant_id TEXT NOT NULL REFERENCES tenants(id), + kind TEXT NOT NULL, + resource_id TEXT NOT NULL, + title TEXT NOT NULL, + bundle_json TEXT NOT NULL +); +CREATE TABLE share_grants ( + id TEXT PRIMARY KEY, + owner_tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + owner_user_id TEXT NOT NULL REFERENCES users(id), + resource_kind TEXT NOT NULL, + resource_id TEXT NOT NULL, + grantee_user_id TEXT REFERENCES users(id) ON DELETE CASCADE, + grantee_tenant_id TEXT REFERENCES tenants(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'viewer', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE TABLE dm_conversations ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + created_by_user_id TEXT NOT NULL REFERENCES users(id) +); +CREATE TABLE dm_conversation_members ( + conversation_id TEXT NOT NULL REFERENCES dm_conversations(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'member', + joined_at TEXT NOT NULL DEFAULT (datetime('now')), + last_read_at TEXT, + last_read_message_id TEXT, + PRIMARY KEY (conversation_id, user_id) +); +CREATE TABLE dm_messages ( + id TEXT PRIMARY KEY, + conversation_id TEXT NOT NULL REFERENCES dm_conversations(id) ON DELETE CASCADE, + sender_user_id TEXT NOT NULL REFERENCES users(id), + body_text TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + edited_at TEXT, + deleted_at TEXT +); +CREATE TABLE hooks ( + id TEXT PRIMARY KEY, + owner_kind TEXT NOT NULL CHECK (owner_kind IN ('user', 'agent')), + owner_id TEXT NOT NULL, + owner_tenant_id TEXT, + name TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + trigger_kind TEXT NOT NULL CHECK (trigger_kind IN ('event', 'schedule')), + event_type TEXT, + schedule_cron TEXT, + condition_json TEXT, + action_kind TEXT NOT NULL CHECK (action_kind IN ('notify', 'run_agent', 'send_message', 'webhook')), + action_config_json TEXT, + rate_limit_per_hour INTEGER, + require_approval INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + last_fired_at TEXT +); +CREATE TABLE hook_runs ( + id TEXT PRIMARY KEY, + hook_id TEXT NOT NULL REFERENCES hooks(id) ON DELETE CASCADE, + status TEXT NOT NULL +); +CREATE TABLE wiki_pages ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + space TEXT, + slug TEXT NOT NULL, + title TEXT NOT NULL, + body_markdown TEXT NOT NULL DEFAULT '', + visibility TEXT NOT NULL DEFAULT 'internal', + author_user_id TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (visibility, slug) +); +CREATE TABLE wiki_revisions ( + id TEXT PRIMARY KEY, + page_id TEXT NOT NULL REFERENCES wiki_pages(id) ON DELETE CASCADE, + title TEXT NOT NULL, + body_markdown TEXT NOT NULL DEFAULT '', + author_user_id TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE TABLE support_tickets ( + id TEXT PRIMARY KEY, + requester_kind TEXT NOT NULL, + requester_id TEXT NOT NULL, + subject TEXT NOT NULL, + body TEXT NOT NULL DEFAULT '' +); + +INSERT INTO users VALUES + ('user-1', 'owner@example.test', 'Owner'), + ('user-2', 'member@example.test', 'Member'); +INSERT INTO tenants VALUES ('tenant-1', 'Historical tenant', 'user-1'); +INSERT INTO tenant_memberships VALUES + ('user-1', 'tenant-1', 'owner'), + ('user-2', 'tenant-1', 'editor'); +INSERT INTO user_profiles (user_id, headline) VALUES ('user-1', 'Historical owner'); +INSERT INTO marketplace_listings + (id, seller_user_id, seller_tenant_id, kind, resource_id, title, bundle_json) +VALUES ('listing-1', 'user-1', 'tenant-1', 'agent', 'agent-1', 'Legacy listing', '{}'); +INSERT INTO share_grants + (id, owner_tenant_id, owner_user_id, resource_kind, resource_id, grantee_user_id) +VALUES ('grant-1', 'tenant-1', 'user-1', 'agent', 'agent-1', 'user-2'); +INSERT INTO dm_conversations (id, kind, created_by_user_id) +VALUES ('conversation-1', 'direct', 'user-1'); +INSERT INTO dm_conversation_members (conversation_id, user_id) +VALUES ('conversation-1', 'user-1'); +INSERT INTO dm_messages (id, conversation_id, sender_user_id, body_text) +VALUES ('message-1', 'conversation-1', 'user-1', 'Historical message'); +INSERT INTO hooks + (id, owner_kind, owner_id, name, trigger_kind, action_kind) +VALUES ('hook-1', 'user', 'user-1', 'Historical hook', 'event', 'notify'); +INSERT INTO hook_runs VALUES ('hook-run-1', 'hook-1', 'success'); +INSERT INTO wiki_pages + (id, tenant_id, slug, title, body_markdown, visibility, author_user_id) +VALUES ('wiki-1', 'tenant-1', 'welcome', 'Welcome', 'Historical body', 'internal', 'user-1'); +INSERT INTO wiki_revisions + (id, page_id, title, body_markdown, author_user_id) +VALUES ('revision-1', 'wiki-1', 'Welcome', 'Historical body', 'user-1'); +INSERT INTO support_tickets + (id, requester_kind, requester_id, subject, body) +VALUES ('ticket-1', 'user', 'user-1', 'Historical ticket', 'Please preserve me'); diff --git a/apps/bridge/src/services/__tests__/fixtures/historical-tenant.sql b/apps/bridge/src/services/__tests__/fixtures/historical-tenant.sql new file mode 100644 index 0000000..1b4af4c --- /dev/null +++ b/apps/bridge/src/services/__tests__/fixtures/historical-tenant.sql @@ -0,0 +1,88 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE departments ( + id TEXT PRIMARY KEY, + label TEXT NOT NULL, + icon TEXT NOT NULL, + base_path TEXT NOT NULL UNIQUE, + built_in INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE divisions ( + id TEXT NOT NULL, + department_id TEXT NOT NULL REFERENCES departments(id) ON DELETE CASCADE, + label TEXT NOT NULL, + icon TEXT NOT NULL, + base_path TEXT NOT NULL UNIQUE, + right_sidebar TEXT, + built_in INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (department_id, id) +); +CREATE TABLE division_pages ( + id TEXT NOT NULL, + division_id TEXT NOT NULL, + department_id TEXT NOT NULL, + label TEXT NOT NULL, + icon TEXT NOT NULL, + segment TEXT NOT NULL DEFAULT '', + page_kind TEXT NOT NULL, + built_in INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (department_id, division_id, id), + FOREIGN KEY (department_id, division_id) + REFERENCES divisions(department_id, id) ON DELETE CASCADE +); +CREATE TABLE ai_agents (id TEXT PRIMARY KEY); +CREATE TABLE ai_agent_assignments ( + scope_type TEXT NOT NULL, + scope_id TEXT NOT NULL, + agent_id TEXT NOT NULL, + PRIMARY KEY (scope_type, scope_id) +); +CREATE TABLE structure_nodes ( + id TEXT PRIMARY KEY, + parent_id TEXT REFERENCES structure_nodes(id) ON DELETE CASCADE, + label TEXT NOT NULL, + icon TEXT NOT NULL, + segment TEXT NOT NULL DEFAULT '', + kind TEXT NOT NULL DEFAULT 'placeholder', + right_sidebar TEXT, + agent_id TEXT, + built_in INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE TABLE sc_levels ( + symbol TEXT NOT NULL, + label TEXT NOT NULL, + price REAL NOT NULL, + kind TEXT, + chart_number INTEGER, + study_id INTEGER, + subgraph_index INTEGER, + ts TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (symbol, label) +); + +INSERT INTO departments VALUES ('trading', 'Trading', 'chart', '/trading', 1, 0); +INSERT INTO divisions VALUES + ('sierra', 'trading', 'Sierra', 'activity', '/trading/sierra', NULL, 1, 0); +INSERT INTO division_pages VALUES + ('dashboard', 'sierra', 'trading', 'Dashboard', 'layout', '', 'sierra-dashboard', 1, 0), + ('journal', 'sierra', 'trading', 'Journal', 'book', 'journal', 'journal', 0, 1); +INSERT INTO ai_agents VALUES ('dept-trading'), ('custom-agent'); +INSERT INTO ai_agent_assignments VALUES + ('department', 'trading', 'dept-trading'), + ('page', 'custom-page', 'custom-agent'); +INSERT INTO structure_nodes + (id, parent_id, label, icon, segment, kind, built_in, sort_order) +VALUES + ('custom-root', NULL, 'Custom Root', 'star', 'custom', 'custom-page', 0, 99); +INSERT INTO sc_levels + (symbol, label, price, kind, chart_number, study_id, subgraph_index, ts) +VALUES + ('ES', 'VWAP', 5500.25, 'study', 2, 10, 0, '2026-01-01T00:00:00Z'), + ('NQ', 'Prior High', 20100.5, 'reference', 3, 11, 1, '2026-01-01T00:00:01Z'); diff --git a/apps/bridge/src/services/__tests__/marketplace-acquisition.test.ts b/apps/bridge/src/services/__tests__/marketplace-acquisition.test.ts new file mode 100644 index 0000000..5fbea91 --- /dev/null +++ b/apps/bridge/src/services/__tests__/marketplace-acquisition.test.ts @@ -0,0 +1,238 @@ +import Database from "better-sqlite3"; +import { describe, expect, it } from "vitest"; +import { + acquireCloneListing, + type CloneAcquisitionFailurePoint, +} from "../marketplace-listings.js"; + +const failurePoints: CloneAcquisitionFailurePoint[] = [ + "before_operation_registered", + "after_operation_registered", + "before_tenant_imported", + "after_tenant_imported", + "before_purchase_recorded", + "after_purchase_recorded", + "before_completed", + "after_completed", +]; + +function workflowBundle(sourceId = "workflow-source") { + return { + version: 1 as const, + kind: "workflow" as const, + exportedAt: "2026-01-01T00:00:00.000Z", + sourceId, + title: "Imported workflow", + data: { + workflow: { + id: sourceId, + name: "Imported workflow", + config_json: "{}", + enabled: 1, + agent_id: "intelligence", + }, + }, + }; +} + +function coreDb() { + const db = new Database(":memory:"); + db.exec(` + CREATE TABLE marketplace_listings ( + id TEXT PRIMARY KEY, + seller_user_id TEXT NOT NULL, + seller_tenant_id TEXT NOT NULL, + kind TEXT NOT NULL, + resource_id TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT, + price_credits INTEGER NOT NULL DEFAULT 0, + bundle_json TEXT NOT NULL, + visibility TEXT NOT NULL DEFAULT 'public', + status TEXT NOT NULL DEFAULT 'active', + delivery_mode TEXT NOT NULL DEFAULT 'clone' + ); + CREATE TABLE marketplace_purchases ( + id TEXT PRIMARY KEY, + listing_id TEXT NOT NULL, + buyer_user_id TEXT NOT NULL, + buyer_tenant_id TEXT NOT NULL, + price_credits INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + `); + db.prepare( + `INSERT INTO marketplace_listings + (id, seller_user_id, seller_tenant_id, kind, resource_id, title, + bundle_json, visibility, status, delivery_mode) + VALUES ('listing-a', 'seller', 'seller-tenant', 'workflow', 'workflow-source', + 'Imported workflow', ?, 'public', 'active', 'clone')` + ).run(JSON.stringify(workflowBundle())); + db.prepare( + `INSERT INTO marketplace_listings + (id, seller_user_id, seller_tenant_id, kind, resource_id, title, + bundle_json, visibility, status, delivery_mode) + VALUES ('listing-b', 'seller', 'seller-tenant', 'workflow', 'workflow-other', + 'Other workflow', ?, 'public', 'active', 'clone')` + ).run(JSON.stringify(workflowBundle("workflow-other"))); + return db; +} + +function tenantDb(withWorkflowTable = true) { + const db = new Database(":memory:"); + if (withWorkflowTable) { + db.exec(` + CREATE TABLE ai_workflows ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + config_json TEXT NOT NULL, + enabled INTEGER NOT NULL, + agent_id TEXT NOT NULL + ); + `); + } + return db; +} + +function acquire( + core: Database.Database, + tenant: Database.Database, + overrides: Partial<{ + listingId: string; + buyerUserId: string; + buyerTenantId: string; + idempotencyKey: string; + }> = {}, + fail?: (point: CloneAcquisitionFailurePoint) => void +) { + return acquireCloneListing( + { core, buyerTenant: tenant }, + { + listingId: "listing-a", + buyerUserId: "buyer-a", + buyerTenantId: "tenant-a", + idempotencyKey: "acquire-once", + ...overrides, + }, + { fail } + ); +} + +describe("cross-database marketplace clone acquisition", () => { + for (const failurePoint of failurePoints) { + it(`recovers a crash at ${failurePoint}`, () => { + const core = coreDb(); + const tenant = tenantDb(); + let injected = false; + + expect(() => + acquire(core, tenant, {}, (point) => { + if (!injected && point === failurePoint) { + injected = true; + throw new Error(`crash:${point}`); + } + }) + ).toThrow(`crash:${failurePoint}`); + + const result = acquire(core, tenant) as Record; + expect(result).toMatchObject({ ok: true, status: "completed" }); + expect(core.prepare(`SELECT count(*) AS n FROM marketplace_purchases`).get()).toEqual({ + n: 1, + }); + expect(tenant.prepare(`SELECT count(*) AS n FROM ai_workflows`).get()).toEqual({ n: 1 }); + expect( + core.prepare(`SELECT count(*) AS n FROM marketplace_acquisition_steps`).get() + ).toEqual({ n: 4 }); + }); + } + + it("returns the original result for duplicate idempotency keys", () => { + const core = coreDb(); + const tenant = tenantDb(); + const first = acquire(core, tenant); + const duplicate = acquire(core, tenant); + + expect(duplicate).toEqual(first); + expect(core.prepare(`SELECT count(*) AS n FROM marketplace_purchases`).get()).toEqual({ + n: 1, + }); + expect(tenant.prepare(`SELECT count(*) AS n FROM ai_workflows`).get()).toEqual({ n: 1 }); + }); + + it("rejects reuse of a key for a different listing", () => { + const core = coreDb(); + const tenant = tenantDb(); + acquire(core, tenant); + expect(() => acquire(core, tenant, { listingId: "listing-b" })).toThrow( + /different listing/i + ); + }); + + it("rolls back a failed tenant import and safely resumes", () => { + const core = coreDb(); + const tenant = tenantDb(false); + + expect(() => acquire(core, tenant)).toThrow(/ai_workflows/i); + expect( + tenant.prepare(`SELECT count(*) AS n FROM marketplace_acquisition_imports`).get() + ).toEqual({ n: 0 }); + expect( + core.prepare(`SELECT status FROM marketplace_acquisition_operations`).get() + ).toEqual({ status: "registered" }); + + tenant.exec(` + CREATE TABLE ai_workflows ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + config_json TEXT NOT NULL, + enabled INTEGER NOT NULL, + agent_id TEXT NOT NULL + ); + `); + expect(acquire(core, tenant)).toMatchObject({ ok: true, status: "completed" }); + }); + + it("isolates equal keys and imports across buyer tenants", () => { + const core = coreDb(); + const tenantA = tenantDb(); + const tenantB = tenantDb(); + + const resultA = acquire(core, tenantA); + const resultB = acquire(core, tenantB, { + buyerUserId: "buyer-b", + buyerTenantId: "tenant-b", + }); + + expect(resultA.operationId).not.toBe(resultB.operationId); + expect(core.prepare(`SELECT count(*) AS n FROM marketplace_purchases`).get()).toEqual({ + n: 2, + }); + expect(tenantA.prepare(`SELECT count(*) AS n FROM ai_workflows`).get()).toEqual({ n: 1 }); + expect(tenantB.prepare(`SELECT count(*) AS n FROM ai_workflows`).get()).toEqual({ n: 1 }); + expect( + tenantA.prepare(`SELECT DISTINCT tenant_id FROM marketplace_acquisition_audit`).all() + ).toEqual([{ tenant_id: "tenant-a" }]); + expect( + tenantB.prepare(`SELECT DISTINCT tenant_id FROM marketplace_acquisition_audit`).all() + ).toEqual([{ tenant_id: "tenant-b" }]); + }); + + it("commits audit and outbox receipts with each database-owned step", () => { + const core = coreDb(); + const tenant = tenantDb(); + acquire(core, tenant); + + expect( + core.prepare(`SELECT count(*) AS n FROM marketplace_acquisition_audit`).get() + ).toEqual({ n: 4 }); + expect( + core.prepare(`SELECT count(*) AS n FROM marketplace_acquisition_outbox`).get() + ).toEqual({ n: 4 }); + expect( + tenant.prepare(`SELECT count(*) AS n FROM marketplace_acquisition_audit`).get() + ).toEqual({ n: 1 }); + expect( + tenant.prepare(`SELECT count(*) AS n FROM marketplace_acquisition_outbox`).get() + ).toEqual({ n: 1 }); + }); +}); diff --git a/apps/bridge/src/services/ai-dataset-builder.ts b/apps/bridge/src/services/ai-dataset-builder.ts index 24a38fd..7ab1999 100644 --- a/apps/bridge/src/services/ai-dataset-builder.ts +++ b/apps/bridge/src/services/ai-dataset-builder.ts @@ -51,6 +51,12 @@ interface BuildOptions { limit?: number; } +export interface ImportDatasetOptions { + name: string; + domain?: string; + examples: DatasetExample[]; +} + const SOURCE_LABELS: Record = { chats: "Intelligence chats", workflows: "Workflow runs", @@ -286,6 +292,53 @@ export class AiDatasetBuilder { return { examples: all.slice(0, limit), total: all.length }; } + /** + * Import caller-supplied chat examples into managed JSONL storage. The + * service validates and rewrites the payload instead of registering an + * arbitrary filesystem path. + */ + importDataset(opts: ImportDatasetOptions): DatasetRow { + const name = opts.name.trim(); + if (!name) throw new Error("name required"); + if (!Array.isArray(opts.examples) || opts.examples.length === 0) { + throw new Error("examples required"); + } + const examples = opts.examples.map((example, index) => { + if (!Array.isArray(example?.messages) || example.messages.length === 0) { + throw new Error(`examples[${index}].messages required`); + } + return { + messages: example.messages.map((message, messageIndex) => { + const role = String(message?.role ?? "").trim(); + const content = String(message?.content ?? "").trim(); + if (!role || !content) { + throw new Error( + `examples[${index}].messages[${messageIndex}] requires role and content` + ); + } + return { role, content }; + }), + }; + }); + + const dir = config.ai.datasetsDir; + fs.mkdirSync(dir, { recursive: true }); + const id = uuidv4(); + const filePath = path.join(dir, `${slug(name)}-${id.slice(0, 8)}.jsonl`); + fs.writeFileSync( + filePath, + examples.map((example) => JSON.stringify(example)).join("\n") + "\n", + "utf8" + ); + this.db + .prepare( + `INSERT INTO ai_datasets (id, name, domain, path, row_count) + VALUES (?, ?, ?, ?, ?)` + ) + .run(id, name, opts.domain?.trim() || null, filePath, examples.length); + return this.db.prepare(`SELECT * FROM ai_datasets WHERE id = ?`).get(id) as DatasetRow; + } + buildDataset(opts: BuildOptions): DatasetRow { const name = opts.name.trim(); if (!name) throw new Error("name required"); diff --git a/apps/bridge/src/services/ai-tool-executor.ts b/apps/bridge/src/services/ai-tool-executor.ts index df4c591..04084e2 100644 --- a/apps/bridge/src/services/ai-tool-executor.ts +++ b/apps/bridge/src/services/ai-tool-executor.ts @@ -130,7 +130,6 @@ import { listInstalledPlugins, installedPluginIdsForTenant, } from "../plugins/plugin-install.js"; -import { activatePluginForTenant } from "../plugins/activate-plugin.js"; import { scaffoldPlugin, prepareMarketplaceSubmission, defaultPluginRoot } from "./plugin-scaffold.js"; import { buildPluginWithEsbuild } from "./plugin-build.js"; import { indexMemory, removeMemoryFromIndex } from "./embeddings/memory-embeddings.js"; @@ -214,6 +213,553 @@ function kernelOperationContext(ctx: ToolExecContext): OperationContext { }; } +type KernelToolDispatcher = typeof executeKernelTool; +let kernelToolDispatcher: KernelToolDispatcher = executeKernelTool; + +/** Test seam used to prove static mutation aliases cannot bypass the kernel. */ +export function setKernelToolDispatcherForTests( + dispatcher?: KernelToolDispatcher +): void { + kernelToolDispatcher = dispatcher ?? executeKernelTool; +} + +function dispatchKernelTool( + ctx: ToolExecContext, + name: string, + args: Record, + db: AppDatabase = ctx.db +): unknown | Promise | undefined { + return kernelToolDispatcher( + db, + name, + args, + kernelOperationContext(ctx) + ); +} + +type StaticKernelAliasResult = + | { handled: false } + | { handled: true; result: unknown }; + +function value(args: Record, ...names: string[]): unknown { + for (const name of names) { + if (args[name] !== undefined) return args[name]; + } + return undefined; +} + +function dispatchedRecordId(result: unknown): string | undefined { + if (!result || typeof result !== "object") return undefined; + const id = (result as { id?: unknown }).id; + return id == null ? undefined : String(id); +} + +/** + * Temporary semantic aliases used by persisted prompts/workflows. Every alias + * translates to canonical Record CRUD/action dispatch; none may write a + * database or call a domain service directly. + */ +async function executeStaticKernelAlias( + name: string, + args: Record, + ctx: ToolExecContext +): Promise { + const create = (objectType: string, data: Record) => + dispatchKernelTool(ctx, "create_record", { objectType, data }); + const update = ( + objectType: string, + id: unknown, + data: Record + ) => dispatchKernelTool(ctx, "update_record", { objectType, id, data }); + const action = ( + objectType: string, + id: unknown, + actionName: string, + input: Record + ) => + dispatchKernelTool(ctx, "run_record_action", { + objectType, + id: id ?? "", + action: actionName, + input, + }); + + switch (name) { + case "remember": { + const data = { + text: value(args, "text"), + category: value(args, "category"), + scope: ctx.chatId ? "chat" : "global", + chat_id: ctx.chatId ?? null, + source: "model", + }; + const created = await create("Memory", data); + let contributed = false; + if (ctx.contributeDb && ctx.contributeDb !== ctx.db) { + await dispatchKernelTool( + ctx, + "create_record", + { + objectType: "Memory", + data: { ...data, scope: "global", chat_id: null }, + }, + ctx.contributeDb + ); + contributed = true; + } + return { + handled: true, + result: + created && typeof created === "object" + ? { ...created, contributed } + : { result: created, contributed }, + }; + } + case "save_artifact": + return { + handled: true, + result: await create("Artifact", { + name: value(args, "name"), + content: value(args, "content"), + kind: value(args, "kind"), + mime_type: value(args, "mimeType", "mime_type"), + description: value(args, "description"), + source: "agent", + }), + }; + case "create_project_card": { + const created = await create("TaskCard", { + title: value(args, "title"), + description: value(args, "description"), + prompt: value(args, "prompt"), + priority: value(args, "priority"), + tags_json: value(args, "tags", "tags_json"), + assigned_agent_id: + value(args, "assignedAgentId", "assigned_agent_id") ?? + ctx.activeAgentId, + }); + const id = dispatchedRecordId(created); + const columnId = value(args, "columnId", "column_id"); + const result = + id && columnId !== undefined + ? await action("TaskCard", id, "move", { column_id: columnId }) + : created; + return { + handled: true, + result, + }; + } + case "move_project_card": + return { + handled: true, + result: await action( + "TaskCard", + value(args, "cardId", "id"), + "move", + { + column_id: value(args, "columnId", "column_id"), + ...(value(args, "sortOrder", "sort_order") !== undefined + ? { sort_order: value(args, "sortOrder", "sort_order") } + : {}), + } + ), + }; + case "set_card_priority": + return { + handled: true, + result: await update("TaskCard", value(args, "cardId", "id"), { + priority: value(args, "priority"), + }), + }; + case "create_subtask": { + const parentId = value(args, "parentCardId", "parent_card_id"); + const parent = (await dispatchKernelTool(ctx, "get_record", { + objectType: "TaskCard", + id: parentId, + })) as { data?: Record } | null | undefined; + const created = await create("TaskCard", { + title: value(args, "title"), + description: value(args, "description"), + prompt: value(args, "prompt"), + parent_card_id: parentId, + priority: parent?.data?.priority ?? 2, + status: "working", + }); + const id = dispatchedRecordId(created); + const result = id + ? await action("TaskCard", id, "move", { + column_id: + value(args, "columnId", "column_id") ?? "in_progress", + }) + : created; + return { + handled: true, + result, + }; + } + case "comment_card": + case "add_card_comment": + return { + handled: true, + result: await action( + "TaskCard", + value( + args, + "cardId", + "id", + "card_id", + "cardID", + "subtaskId", + "subtask_id", + "card" + ), + "add_comment", + { + body: value(args, "body", "comment", "note", "text", "message", "content"), + ...(value(args, "kind") !== undefined + ? { kind: value(args, "kind") } + : {}), + } + ), + }; + case "create_user_calendar_event": + return { + handled: true, + result: await create("CalendarEvent", { + title: value(args, "title"), + start_at: value(args, "start_at"), + end_at: value(args, "end_at"), + kind: value(args, "kind"), + description: value(args, "description"), + location: value(args, "location"), + all_day: value(args, "all_day"), + }), + }; + case "create_user_task": { + const created = await create("TaskCard", { + title: value(args, "title"), + description: value(args, "description"), + due_at: value(args, "dueAt", "due_at"), + priority: value(args, "priority"), + }); + const id = dispatchedRecordId(created); + const columnId = value(args, "columnId", "column_id"); + const result = + id && columnId !== undefined + ? await action("TaskCard", id, "move", { column_id: columnId }) + : created; + return { + handled: true, + result, + }; + } + case "assign_agent": + return { + handled: true, + result: await action( + "Agent", + value(args, "agentId", "id"), + "assign", + { + scope_type: value(args, "scopeType", "scope_type"), + scope_id: value(args, "scopeId", "scope_id"), + role: value(args, "role") ?? "viewer", + } + ), + }; + case "set_agent_role": { + const scopeId = value(args, "scopeId", "scope_id"); + return { + handled: true, + result: await update("AgentAssignment", scopeId, { + role: value(args, "role"), + }), + }; + } + case "update_card": { + const id = value(args, "cardId", "id"); + let result: unknown = { ok: true, unchanged: true }; + const columnId = value(args, "columnId", "column_id"); + if (columnId !== undefined) { + result = await action("TaskCard", id, "move", { + column_id: columnId, + }); + } + const data: Record = {}; + for (const [source, target] of [ + ["title", "title"], + ["description", "description"], + ["priority", "priority"], + ["assignedAgentId", "assigned_agent_id"], + ] as const) { + if (args[source] !== undefined) data[target] = args[source]; + } + if (args.status !== undefined) { + result = await action("TaskCard", id, "transition", { + status: args.status, + }); + } + if (Object.keys(data).length) result = await update("TaskCard", id, data); + return { handled: true, result }; + } + case "todo_write": { + const todos = normalizeTodoItems(args); + const agentId = ctx.activeAgentId ?? "intelligence"; + const scope = ctx.chatId ?? `agent-${agentId}`; + const cards: Array<{ id: string; status: string; parentId?: string }> = []; + const keepIds = new Set(); + const keyOf = (todo: NormalizedTodo): string => + todo.id?.trim() || + todo.content + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .slice(0, 48); + const lane = ( + status: NormalizedTodo["status"] + ): { columnId: string; cardStatus: string } => { + if (status === "in_progress") { + return { columnId: "in_progress", cardStatus: "working" }; + } + if (status === "completed") { + return { columnId: "done", cardStatus: "accepted" }; + } + if (status === "cancelled") { + return { columnId: "done", cardStatus: "cancelled" }; + } + return { columnId: "backlog", cardStatus: "pending" }; + }; + const writeTodo = async ( + todo: NormalizedTodo, + index: number, + parentId: string | null + ): Promise => { + const id = parentId + ? `${parentId}__${keyOf(todo)}` + : `todo_${scope}_${keyOf(todo)}`; + const { columnId, cardStatus } = lane(todo.status); + const existing = await dispatchKernelTool(ctx, "get_record", { + objectType: "TaskCard", + id, + }); + const data = { + title: todo.content, + status: cardStatus, + priority: todo.priority ?? 2, + parent_card_id: parentId, + linked_chat_id: ctx.chatId ?? null, + assigned_agent_id: agentId, + }; + if (existing) { + await update("TaskCard", id, data); + } else { + await create("TaskCard", { id, ...data }); + } + await action("TaskCard", id, "move", { + column_id: columnId, + sort_order: index, + }); + keepIds.add(id); + cards.push({ + id, + status: todo.status, + ...(parentId ? { parentId } : {}), + }); + for (const [childIndex, child] of (todo.subtasks ?? []).entries()) { + await writeTodo(child, childIndex, id); + } + if (!parentId && todo.subtasks?.length && todo.auto !== false) { + await update("TaskCard", id, { + tags_json: ["auto"], + context_json: { + __auto: { + autoTicks: 0, + doneSeen: 0, + noProgressTicks: 0, + maxTaskTicks: + todo.maxTaskTicks ?? + (Number.isFinite(Number(args.maxTaskTicks)) + ? Number(args.maxTaskTicks) + : 200), + }, + }, + }); + } + }; + for (const [index, todo] of todos.entries()) { + await writeTodo(todo, index, null); + } + if (args.merge !== true) { + const listed = (await dispatchKernelTool(ctx, "list_records", { + objectType: "TaskCard", + limit: 500, + })) as + | { records?: Array<{ id: string; data?: Record }> } + | undefined; + for (const row of listed?.records ?? []) { + if ( + row.id.startsWith(`todo_${scope}_`) && + !keepIds.has(row.id) && + row.data?.status !== "cancelled" + ) { + await action("TaskCard", row.id, "transition", { + status: "cancelled", + }); + } + } + } + return { + handled: true, + result: { ok: true, count: cards.length, cards }, + }; + } + case "mark_notification_read": { + if (args.markAll === true) { + return { + handled: true, + result: await action("Notification", "", "mark_all_read", {}), + }; + } + const results = []; + for (const id of Array.isArray(args.ids) ? args.ids : []) { + results.push(await action("Notification", id, "mark_read", {})); + } + return { handled: true, result: { marked: results.length, results } }; + } + case "revoke_share_grant": + return { + handled: true, + result: await action( + "ShareGrant", + value(args, "grantId", "id"), + "revoke", + {} + ), + }; + case "share_model": { + const granteeUserId = resolveGranteeUserId(getCoreDb(), args); + if (!granteeUserId) { + throw new Error("granteeUserId or granteeEmail required"); + } + const modelPath = String(value(args, "modelPath", "base_model_path") ?? ""); + const endpoint = (await create("InferenceEndpoint", { + name: + value(args, "name") ?? + modelPath.split(/[\\/]/).pop()?.replace(/\.gguf$/i, "") ?? + "Shared model", + base_model_path: modelPath, + })) as { id?: string } | undefined; + if (!endpoint?.id) throw new Error("Inference endpoint creation failed"); + return { + handled: true, + result: await action("ShareGrant", "", "grant", { + resource_kind: "model", + resource_id: endpoint.id, + grantee_user_id: granteeUserId, + role: "viewer", + }), + }; + } + case "run_workflow": { + const workflowId = value(args, "workflowId", "id"); + const triggerInput = + typeof args.input === "string" + ? args.input + : args.input == null + ? undefined + : JSON.stringify(args.input); + return { + handled: true, + result: await action("Workflow", workflowId, "run", { + ...(triggerInput !== undefined + ? { trigger_input: triggerInput } + : {}), + ...(ctx.activeTaskCardId ? { card_id: ctx.activeTaskCardId } : {}), + }), + }; + } + case "reply_support_ticket": + return { + handled: true, + result: await action( + "SupportTicket", + value(args, "ticketId", "id"), + "reply", + { body: value(args, "body") } + ), + }; + case "update_support_ticket": + return { + handled: true, + result: await action( + "SupportTicket", + value(args, "ticketId", "id"), + "set_status", + { status: value(args, "status") } + ), + }; + case "send_message": + return { + handled: true, + result: await action("DirectMessage", "", "send", { + conversation_id: value(args, "conversationId", "conversation_id"), + body_text: value(args, "body", "body_text"), + }), + }; + case "create_conversation": + return { + handled: true, + result: await action("DirectConversation", "", "start", { + kind: args.kind === "group" ? "group" : "direct", + title: value(args, "title"), + member_user_ids: value(args, "memberUserIds", "member_user_ids"), + }), + }; + case "create_holding": + return { + handled: true, + result: await action("FinanceConnection", "", "add_manual", { + category: value(args, "category"), + provider: value(args, "provider"), + label: value(args, "label"), + currency: value(args, "currency"), + balance: value(args, "balance"), + }), + }; + case "refresh_holdings": + return { + handled: true, + result: await action( + "FinanceConnection", + value(args, "connectionId", "id"), + "refresh_external", + {} + ), + }; + case "create_listing": + return { + handled: true, + result: await action("MarketplaceListing", "", "publish", { + kind: value(args, "kind"), + resource_id: value(args, "resourceId", "resource_id"), + title: value(args, "title"), + description: value(args, "description"), + price_credits: value(args, "priceCredits", "price_credits"), + delivery_mode: value(args, "deliveryMode", "delivery_mode"), + }), + }; + case "install_catalog_entry": + return { + handled: true, + result: await action("CatalogInstall", "", "install_entry", { + entry_id: value(args, "entryId", "entry_id"), + source_catalog: value(args, "sourceCatalog", "source_catalog"), + }), + }; + default: + return { handled: false }; + } +} + function toolMode(name: string): "auto" | "confirm" | null { const core = AI_TOOL_REGISTRY.find((t) => t.name === name); if (core) return core.mode; @@ -249,63 +795,6 @@ export interface NormalizedTodo { maxTaskTicks?: number; } -/** Parent tasks with nested subtasks are autonomous Kanban runs — tag + init ticks. */ -export function bootstrapAutonomousParentCard( - db: AppDatabase, - cardId: string, - opts?: { maxTaskTicks?: number; force?: boolean } -): void { - const row = db - .prepare(`SELECT tags_json, context_json FROM ai_project_cards WHERE id = ?`) - .get(cardId) as { tags_json: string | null; context_json: string | null } | undefined; - if (!row) return; - - let tags: string[] = []; - try { - tags = row.tags_json ? (JSON.parse(row.tags_json) as string[]) : []; - } catch { - tags = []; - } - const hasAuto = - tags.includes("auto") || - (row.tags_json != null && row.tags_json.includes("auto")); - if (!hasAuto || opts?.force) { - if (!tags.includes("auto")) tags.push("auto"); - db.prepare( - `UPDATE ai_project_cards SET tags_json = ?, updated_at = datetime('now') WHERE id = ?` - ).run(JSON.stringify(tags), cardId); - } - - let ctx: Record = {}; - try { - ctx = row.context_json ? JSON.parse(row.context_json) : {}; - } catch { - ctx = {}; - } - const auto = (ctx.__auto ?? {}) as Record; - const needsMeta = - auto.maxTaskTicks == null || - auto.autoTicks == null || - auto.noProgressTicks == null; - if (needsMeta || opts?.force) { - const budget = - opts?.maxTaskTicks != null && opts.maxTaskTicks > 0 - ? opts.maxTaskTicks - : Number(auto.maxTaskTicks) > 0 - ? Number(auto.maxTaskTicks) - : 200; - ctx.__auto = { - autoTicks: Number(auto.autoTicks ?? 0), - doneSeen: Number(auto.doneSeen ?? 0), - noProgressTicks: Number(auto.noProgressTicks ?? 0), - maxTaskTicks: budget, - }; - db.prepare( - `UPDATE ai_project_cards SET context_json = ?, updated_at = datetime('now') WHERE id = ?` - ).run(JSON.stringify(ctx), cardId); - } -} - /** Map a free-form status / column hint to a canonical todo status. */ function canonicalTodoStatus(raw: unknown, columnId: unknown): NormalizedTodo["status"] { const s = String(raw ?? "").toLowerCase().replace(/[\s-]+/g, "_"); @@ -654,37 +1143,21 @@ export async function executeTool( const pluginResult = await executePluginTool(name, args, pluginExecCtx(ctx)); if (pluginResult !== undefined) return pluginResult; } - switch (name) { - case "remember": { - const text = String(args.text ?? "").trim(); - if (!text) throw new Error("text required"); - const id = uuidv4(); - const category = args.category ? String(args.category) : null; - const agentId = ctx.activeAgentId ?? "intelligence"; - ctx.db - .prepare( - `INSERT INTO ai_memories (id, scope, chat_id, agent_id, text, category, source) - VALUES (?, ?, ?, ?, ?, ?, 'model')` - ) - .run(id, ctx.chatId ? "chat" : "global", ctx.chatId ?? null, agentId, text, category); - indexMemory(ctx.db, ctx.embedder, id, text); - // Contribute back to the agent owner's engine DB when enabled. The chat - // itself lives in the actor's work DB, so the mirrored copy is stored as a - // global (non-chat-scoped) memory the owner's agent can reuse. - let contributed = false; - if (ctx.contributeDb && ctx.contributeDb !== ctx.db) { - const mirrorId = uuidv4(); - ctx.contributeDb - .prepare( - `INSERT INTO ai_memories (id, scope, chat_id, agent_id, text, category, source) - VALUES (?, 'global', NULL, ?, ?, ?, 'model')` - ) - .run(mirrorId, agentId, text, category); - indexMemory(ctx.contributeDb, ctx.embedder, mirrorId, text); - contributed = true; - } - return { ok: true, id, contributed }; + // Canonical generated tools always dispatch before legacy switch aliases. + // This is the static-tool cutover: an identically named old implementation + // can no longer shadow kernel CRUD/action dispatch. + if (isKernelToolName(name)) { + try { + const result = await dispatchKernelTool(ctx, name, args); + if (result !== undefined) return result; + } catch (err) { + if (err instanceof KernelError) throw new Error(err.message); + throw err; } + } + const staticAlias = await executeStaticKernelAlias(name, args, ctx); + if (staticAlias.handled) return staticAlias.result; + switch (name) { case "use_skill": { // The model is inconsistent about the arg name; accept every alias it has // emitted (skillId/id/skill/name) so a correct call never dead-ends on a @@ -838,62 +1311,6 @@ export async function executeTool( const ok = deleteArtifact(ctx.db, ctx.activeAgentId ?? "intelligence", id); return { ok }; } - case "create_project_card": { - const projectId = String(args.projectId ?? "default"); - const columnId = String(args.columnId ?? "backlog"); - const title = String(args.title ?? "Untitled"); - const id = uuidv4(); - const maxOrder = ctx.db - .prepare( - `SELECT COALESCE(MAX(sort_order), -1) as m FROM ai_project_cards WHERE column_id = ?` - ) - .get(columnId) as { m: number }; - // Priority (1=high,2=med,3=low) and tags are optional; tags accept an - // array or comma string. Tagging a card "auto" opts it into the - // autonomous executor's Task queue. - const priority = Number.isFinite(Number(args.priority)) - ? Number(args.priority) - : 2; - const tagsJson = - args.tags != null - ? JSON.stringify( - Array.isArray(args.tags) - ? args.tags - : String(args.tags) - .split(",") - .map((t) => t.trim()) - .filter(Boolean) - ) - : null; - ctx.db - .prepare( - `INSERT INTO ai_project_cards (id, project_id, column_id, title, description, priority, tags_json, prompt, assigned_agent_id, sort_order) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` - ) - .run( - id, - projectId, - columnId, - title, - args.description ? String(args.description) : null, - priority, - tagsJson, - args.prompt ? String(args.prompt) : null, - args.assignedAgentId ? String(args.assignedAgentId) : ctx.activeAgentId ?? null, - maxOrder.m + 1 - ); - return { ok: true, id }; - } - case "move_project_card": { - const cardId = String(args.cardId ?? ""); - const columnId = String(args.columnId ?? ""); - ctx.db - .prepare( - `UPDATE ai_project_cards SET column_id = ?, updated_at = datetime('now') WHERE id = ?` - ) - .run(columnId, cardId); - return { ok: true }; - } case "list_project_cards": { const clauses: string[] = ["1=1"]; const params: unknown[] = []; @@ -936,57 +1353,6 @@ export async function executeTool( const rows = ctx.db.prepare(sql).all(...params); return rows; } - case "set_card_priority": { - const cardId = String(args.cardId ?? ""); - const priority = Number(args.priority); - if (!cardId || !Number.isFinite(priority)) { - throw new Error("cardId and numeric priority required"); - } - ctx.db - .prepare( - `UPDATE ai_project_cards SET priority = ?, updated_at = datetime('now') WHERE id = ?` - ) - .run(priority, cardId); - return { ok: true }; - } - case "create_subtask": { - const parentCardId = String(args.parentCardId ?? ""); - if (!parentCardId) throw new Error("parentCardId required"); - const parent = ctx.db - .prepare( - `SELECT project_id, column_id, priority FROM ai_project_cards WHERE id = ?` - ) - .get(parentCardId) as - | { project_id: string; column_id: string; priority: number } - | undefined; - if (!parent) throw new Error(`Parent card not found: ${parentCardId}`); - const id = uuidv4(); - const columnId = args.columnId ? String(args.columnId) : "in_progress"; - const maxOrder = ctx.db - .prepare( - `SELECT COALESCE(MAX(sort_order), -1) as m FROM ai_project_cards WHERE column_id = ?` - ) - .get(columnId) as { m: number }; - ctx.db - .prepare( - `INSERT INTO ai_project_cards - (id, project_id, column_id, title, description, prompt, parent_card_id, priority, status, sort_order) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` - ) - .run( - id, - parent.project_id, - columnId, - String(args.title ?? "Subtask"), - args.description ? String(args.description) : null, - args.prompt ? String(args.prompt) : null, - parentCardId, - parent.priority ?? 2, - "working", - maxOrder.m + 1 - ); - return { ok: true, id }; - } case "list_subtasks": { const parentCardId = String(args.parentCardId ?? ""); if (!parentCardId) throw new Error("parentCardId required"); @@ -1002,62 +1368,6 @@ export async function executeTool( ).length; return { subtasks: rows, total, done, open: total - done }; } - case "comment_card": - case "add_card_comment": { - const cardId = String( - args.cardId ?? - args.id ?? - args.card_id ?? - args.cardID ?? - args.subtaskId ?? - args.subtask_id ?? - args.card ?? - "" - ); - const body = String( - args.body ?? - args.comment ?? - args.note ?? - args.text ?? - args.message ?? - args.content ?? - "" - ).trim(); - if (!cardId) throw new Error("cardId required (the card/subtask id to comment on)"); - if (!body) - throw new Error( - "body required — pass the note text in `body` (a non-empty sentence describing what you did/the result). A card id with only a `kind` is not enough." - ); - const author = args.author === "user" ? "user" : "agent"; - // Audit-log category for the entry (note | action | result | issue). - const kind = - args.kind != null && String(args.kind).trim() - ? String(args.kind).trim() - : null; - const id = uuidv4(); - ctx.db - .prepare( - `INSERT INTO ai_card_comments (id, card_id, author, body, kind) VALUES (?, ?, ?, ?, ?)` - ) - .run(id, cardId, author, body, kind); - if (author === "agent" && kind === "result") { - advanceSubtaskOnResultComment(ctx.db, cardId, ctx.tenantId); - } else if (author === "agent") { - const row = ctx.db - .prepare(`SELECT parent_card_id FROM ai_project_cards WHERE id = ?`) - .get(cardId) as { parent_card_id: string | null } | undefined; - if (row?.parent_card_id) { - reconcileParentProgress(ctx.db, row.parent_card_id, ctx.tenantId); - } - } - broadcastCardActivity(ctx.tenantId, { - cardId, - agentId: ctx.activeAgentId ?? null, - chatId: ctx.chatId ?? null, - reason: "comment", - }); - return { ok: true, id }; - } case "list_card_comments": { const cardId = String(args.cardId ?? ""); if (!cardId) throw new Error("cardId required"); @@ -1094,34 +1404,6 @@ export async function executeTool( .all(...params); return rows; } - case "create_user_calendar_event": { - const userId = ctx.userId; - if (!userId) throw new Error("Authenticated user required"); - const title = String(args.title ?? "").trim(); - const startAt = String(args.start_at ?? "").trim(); - if (!title || !startAt) throw new Error("title and start_at required"); - const db = getUserOwnerTenantDb(userId); - const id = uuidv4(); - const kind = ["event", "task", "appointment"].includes(String(args.kind)) - ? String(args.kind) - : "event"; - db.prepare( - `INSERT INTO ai_calendar_events - (id, agent_id, user_id, kind, title, description, start_at, end_at, all_day, location, status) - VALUES (?, '', ?, ?, ?, ?, ?, ?, ?, ?, 'scheduled')` - ).run( - id, - userId, - kind, - title, - args.description ? String(args.description) : null, - startAt, - args.end_at ? String(args.end_at) : null, - args.all_day ? 1 : 0, - args.location ? String(args.location) : null - ); - return { ok: true, id }; - } case "list_user_tasks": { const userId = ctx.userId; if (!userId) throw new Error("Authenticated user required"); @@ -1147,72 +1429,6 @@ export async function executeTool( .all(...params); return rows; } - case "create_user_task": { - const userId = ctx.userId; - if (!userId) throw new Error("Authenticated user required"); - const title = String(args.title ?? "").trim(); - if (!title) throw new Error("title required"); - const db = getUserOwnerTenantDb(userId); - const pid = ensureUserProject(userId, db); - const columnId = args.columnId ? String(args.columnId) : "backlog"; - const maxOrder = db - .prepare( - `SELECT COALESCE(MAX(sort_order), -1) as m FROM ai_project_cards WHERE column_id = ? AND project_id = ?` - ) - .get(columnId, pid) as { m: number }; - const id = uuidv4(); - db.prepare( - `INSERT INTO ai_project_cards (id, project_id, column_id, title, description, due_at, priority, sort_order) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)` - ).run( - id, - pid, - columnId, - title, - args.description ? String(args.description) : null, - args.dueAt ? String(args.dueAt) : null, - args.priority != null ? Number(args.priority) : 2, - maxOrder.m + 1 - ); - return { ok: true, id }; - } - case "update_card": { - const cardId = String(args.cardId ?? ""); - if (!cardId) throw new Error("cardId required"); - const sets: string[] = []; - const params: unknown[] = []; - if (args.columnId != null) { - sets.push("column_id = ?"); - params.push(String(args.columnId)); - } - if (args.status != null) { - sets.push("status = ?"); - params.push(String(args.status)); - } - if (args.title != null) { - sets.push("title = ?"); - params.push(String(args.title)); - } - if (args.description != null) { - sets.push("description = ?"); - params.push(String(args.description)); - } - if (args.priority != null) { - sets.push("priority = ?"); - params.push(Number(args.priority)); - } - if (args.assignedAgentId != null) { - sets.push("assigned_agent_id = ?"); - params.push(String(args.assignedAgentId)); - } - if (!sets.length) return { ok: true, unchanged: true }; - sets.push("updated_at = datetime('now')"); - params.push(cardId); - ctx.db - .prepare(`UPDATE ai_project_cards SET ${sets.join(", ")} WHERE id = ?`) - .run(...params); - return { ok: true }; - } case "create_skill": { const name = String(args.name ?? "").trim(); const description = String(args.description ?? "").trim(); @@ -1297,112 +1513,6 @@ export async function executeTool( }); return { agentId, answer }; } - case "todo_write": { - // Todos are the source of truth on the agent's Kanban board: each item is - // persisted as a card so progress survives the chat. The chat still - // renders a live checklist from this tool's args (the route turns it into - // a 'todos' part). Re-running upserts by deterministic id so the same list - // updates existing cards instead of duplicating. - const todos = normalizeTodoItems(args); - const agentId = ctx.activeAgentId ?? "intelligence"; - const projectId = ensureAgentProject(agentId, ctx.db); - const scope = ctx.chatId ?? `agent-${agentId}`; - const defaultMaxTaskTicks = Number.isFinite(Number(args.maxTaskTicks)) - ? Number(args.maxTaskTicks) - : undefined; - const lane = (status: string): { columnId: string; cardStatus: string } => { - switch (status) { - case "in_progress": - return { columnId: "in_progress", cardStatus: "working" }; - case "completed": - return { columnId: "done", cardStatus: "accepted" }; - case "cancelled": - return { columnId: "done", cardStatus: "cancelled" }; - default: - return { columnId: "backlog", cardStatus: "pending" }; - } - }; - const upsert = ctx.db.prepare( - `INSERT INTO ai_project_cards - (id, project_id, column_id, title, status, priority, sort_order, - linked_chat_id, assigned_agent_id, parent_card_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET - column_id = excluded.column_id, - title = excluded.title, - status = excluded.status, - priority = excluded.priority, - sort_order = excluded.sort_order, - parent_card_id = excluded.parent_card_id, - updated_at = datetime('now')` - ); - const cards: Array<{ id: string; status: string; parentId?: string }> = []; - const keepIds: string[] = []; - const keyOf = (todo: NormalizedTodo): string => - todo.id != null && String(todo.id).trim() - ? String(todo.id).trim() - : String(todo.content ?? "") - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .slice(0, 48); - const writeCard = ( - todo: NormalizedTodo, - index: number, - parentCardId: string | null - ): void => { - const content = String(todo.content ?? "").trim(); - if (!content) return; - const status = String(todo.status ?? "pending"); - const base = `todo_${scope}_${keyOf(todo)}`; - const cardId = parentCardId ? `${parentCardId}__${keyOf(todo)}` : base; - const { columnId, cardStatus } = lane(status); - // Subtasks inherit the parent's priority; top-level todos default to med. - const priority = Number.isFinite(Number(todo.priority)) - ? Number(todo.priority) - : 2; - upsert.run( - cardId, - projectId, - columnId, - content, - cardStatus, - priority, - index, - ctx.chatId ?? null, - agentId, - parentCardId - ); - cards.push({ id: cardId, status, ...(parentCardId ? { parentId: parentCardId } : {}) }); - keepIds.push(cardId); - const childTodos = todo.subtasks ?? []; - childTodos.forEach((child, childIndex) => writeCard(child, childIndex, cardId)); - if (!parentCardId && childTodos.length > 0 && todo.auto !== false) { - bootstrapAutonomousParentCard(ctx.db, cardId, { - maxTaskTicks: todo.maxTaskTicks ?? defaultMaxTaskTicks, - }); - } - }; - todos.forEach((todo, index) => writeCard(todo, index, null)); - // Full-list semantics: when not merging, retire previously-tracked todo - // cards for this scope that dropped out of the latest list. - if (args.merge !== true) { - const placeholders = keepIds.map(() => "?").join(","); - const notIn = keepIds.length ? ` AND id NOT IN (${placeholders})` : ""; - ctx.db - .prepare( - `UPDATE ai_project_cards - SET column_id = 'done', status = 'cancelled', updated_at = datetime('now') - WHERE project_id = ? AND id LIKE ? AND status != 'cancelled'${notIn}` - ) - .run(projectId, `todo_${scope}_%`, ...keepIds); - } - broadcastCardActivity(ctx.tenantId, { - agentId, - chatId: ctx.chatId ?? null, - reason: "todo_write", - }); - return { ok: true, projectId, count: cards.length, cards }; - } case "ask_cursor_agent": { const prompt = String(args.prompt ?? "").trim(); if (!prompt) throw new Error("prompt required"); @@ -1832,22 +1942,6 @@ export async function executeTool( }); return { jobId, workflowId, status: "enqueued" }; } - case "create_workflow": { - const name = String(args.name ?? "").trim(); - if (!name) throw new Error("name required"); - const agentId = String(args.agentId ?? ctx.activeAgentId ?? "intelligence"); - const wf = createWorkflow(ctx.db, { - name, - config: (args.config as WorkflowGraph | undefined) ?? undefined, - enabled: args.enabled === false ? false : true, - }); - ctx.db - .prepare(`UPDATE ai_workflows SET agent_id = ?, updated_at = datetime('now') WHERE id = ?`) - .run(agentId, wf.id); - reloadAiSchedules(); - scheduleCapabilityRebuild(ctx.db, agentId); - return { ...wf, agentId }; - } case "update_workflow": { const id = String(args.id ?? ""); if (!id) throw new Error("id required"); @@ -2452,40 +2546,6 @@ export async function executeTool( return { listings: rows }; } - case "create_listing": { - if (!ctx.userId || !ctx.tenantId) throw new Error("user and tenant required"); - const kind = String(args.kind ?? ""); - const resourceId = args.resourceId ? String(args.resourceId) : undefined; - const delivery = String(args.deliveryMode ?? "clone"); - let bundleJson = "{}"; - let title = String(args.title ?? kind); - if (delivery === "clone" && resourceId) { - const bundle = exportEntity(ctx.db, kind as MarketplaceListingKind, resourceId); - title = String(args.title ?? bundle.title); - bundleJson = JSON.stringify(bundle); - } - const id = uuidv4(); - getCoreDb() - .prepare( - `INSERT INTO marketplace_listings - (id, seller_user_id, seller_tenant_id, kind, resource_id, title, description, - price_credits, bundle_json, visibility, status, delivery_mode, pricing_model) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'public', 'active', ?, 'one_time')` - ) - .run( - id, - ctx.userId, - ctx.tenantId, - kind, - resourceId ?? id, - title, - args.description ? String(args.description) : null, - Number(args.priceCredits ?? 0), - bundleJson, - delivery - ); - return { id }; - } case "install_catalog_entry": { if (!ctx.userId || !ctx.tenantId || !ctx.db) throw new Error("user, tenant, and db required"); @@ -2526,13 +2586,17 @@ export async function executeTool( typeof args.pluginRoot === "string" && args.pluginRoot.trim() ? path.resolve(args.pluginRoot.trim()) : defaultPluginRoot(pluginId, { tenantId: ctx.tenantId }); - const result = await activatePluginForTenant( - getCoreDb(), - ctx.tenantId, - pluginRoot, - { buildIfNeeded: true, installForTenant: true } - ); - return { ok: true, ...result }; + const result = await dispatchKernelTool(ctx, "run_record_action", { + objectType: "CatalogInstall", + id: "", + action: "activate_plugin_path", + input: { + path: pluginRoot, + build_if_needed: true, + install_for_tenant: true, + }, + }); + return { ok: true, result }; } case "build_plugin": { diff --git a/apps/bridge/src/services/ai-tools-registry.ts b/apps/bridge/src/services/ai-tools-registry.ts index fecbf0f..ab1c4a1 100644 --- a/apps/bridge/src/services/ai-tools-registry.ts +++ b/apps/bridge/src/services/ai-tools-registry.ts @@ -36,6 +36,8 @@ export interface AiToolDef { write?: boolean; } +const supersededStaticName = (name: string): string => name; + /** Registry of platform tools exposed to the model (schemas for inspect UI). */ export const AI_TOOL_REGISTRY: AiToolDef[] = [ { @@ -118,7 +120,7 @@ export const AI_TOOL_REGISTRY: AiToolDef[] = [ }, }, { - name: "list_artifacts", + name: supersededStaticName("list_artifacts"), description: "List this agent's saved artifacts (id, name, size, description).", mode: "auto", parameters: { @@ -127,7 +129,7 @@ export const AI_TOOL_REGISTRY: AiToolDef[] = [ }, }, { - name: "delete_artifact", + name: supersededStaticName("delete_artifact"), description: "Delete one of this agent's artifacts by id or name. Requires confirmation.", mode: "confirm", parameters: { @@ -262,7 +264,7 @@ export const AI_TOOL_REGISTRY: AiToolDef[] = [ }, }, { - name: "list_card_comments", + name: supersededStaticName("list_card_comments"), description: "List the comment thread for a card, oldest first.", mode: "auto", parameters: { @@ -456,7 +458,7 @@ export const AI_TOOL_REGISTRY: AiToolDef[] = [ }, }, { - name: "create_skill", + name: supersededStaticName("create_skill"), description: "Draft a playbook skill (named steps: when X, do Y). Pending approval. Rejected if too short or a near-duplicate.", mode: "confirm", @@ -473,7 +475,7 @@ export const AI_TOOL_REGISTRY: AiToolDef[] = [ }, }, { - name: "create_rule", + name: supersededStaticName("create_rule"), description: "Draft a new file-backed rule (.mdc guardrail) for this agent. Created in 'pending' status awaiting user approval before it is applied.", mode: "confirm", @@ -555,7 +557,7 @@ export const AI_TOOL_REGISTRY: AiToolDef[] = [ }, }, { - name: "update_structure_node", + name: supersededStaticName("update_structure_node"), description: "Update a department, division, or page (label/icon/segment/rightSidebar/kind). Requires editor. Requires confirmation.", mode: "confirm", @@ -576,7 +578,7 @@ export const AI_TOOL_REGISTRY: AiToolDef[] = [ }, }, { - name: "delete_structure_node", + name: supersededStaticName("delete_structure_node"), description: "Delete a non-built-in department, division, or page. Requires owner. Requires confirmation.", mode: "confirm", @@ -623,7 +625,7 @@ export const AI_TOOL_REGISTRY: AiToolDef[] = [ }, }, { - name: "create_agent", + name: supersededStaticName("create_agent"), description: "Create a new subagent (page-owner or specialist). Intelligence-only platform action. Requires confirmation.", mode: "confirm", @@ -659,13 +661,13 @@ export const AI_TOOL_REGISTRY: AiToolDef[] = [ /* -------------------- Shares & collaboration --------------------------- */ { - name: "list_share_grants", + name: supersededStaticName("list_share_grants"), description: "List share grants owned by or granted to the current user (includes shared sidebar tree).", mode: "auto", }, { - name: "create_share_grant", + name: supersededStaticName("create_share_grant"), description: "Share a department, division, page, agent, or other resource with another user or tenant. Requires confirmation.", mode: "confirm", @@ -724,7 +726,7 @@ export const AI_TOOL_REGISTRY: AiToolDef[] = [ /* -------------------- Automations / workflows -------------------------- */ { - name: "list_workflows", + name: supersededStaticName("list_workflows"), description: "List automation workflows for the active agent.", mode: "auto", parameters: { @@ -751,7 +753,7 @@ export const AI_TOOL_REGISTRY: AiToolDef[] = [ }, }, { - name: "create_workflow", + name: supersededStaticName("create_workflow"), description: "Create an automation workflow (directed graph of trigger/prompt/tool/agent nodes). Requires confirmation.", mode: "confirm", @@ -767,7 +769,7 @@ export const AI_TOOL_REGISTRY: AiToolDef[] = [ }, }, { - name: "update_workflow", + name: supersededStaticName("update_workflow"), description: "Update a workflow name, graph config, or enabled flag. Requires confirmation.", mode: "confirm", parameters: { @@ -782,12 +784,12 @@ export const AI_TOOL_REGISTRY: AiToolDef[] = [ }, }, { - name: "list_schedules", + name: supersededStaticName("list_schedules"), description: "List cron schedules for automation workflows.", mode: "auto", }, { - name: "create_schedule", + name: supersededStaticName("create_schedule"), description: "Create a cron schedule to run a workflow on a timer. Requires confirmation.", mode: "confirm", @@ -1000,7 +1002,7 @@ export const AI_TOOL_REGISTRY: AiToolDef[] = [ }, // --- Notifications --- { - name: "list_notifications", + name: supersededStaticName("list_notifications"), description: "List notifications for the current user or active agent.", mode: "auto", parameters: { @@ -1012,7 +1014,7 @@ export const AI_TOOL_REGISTRY: AiToolDef[] = [ }, }, { - name: "create_notification", + name: supersededStaticName("create_notification"), description: "Send a notification/alert/message to a user or agent — e.g. a summary, review, or status update they should see. This is the CORRECT tool whenever the user asks you to 'create a notification', 'notify', or 'send a message'. Provide a real `title` AND a non-empty `body` (blank notifications are rejected). Not a task card.", mode: "confirm", @@ -1045,7 +1047,7 @@ export const AI_TOOL_REGISTRY: AiToolDef[] = [ }, // --- Support --- { - name: "create_support_ticket", + name: supersededStaticName("create_support_ticket"), description: "Submit a support ticket to platform admins.", mode: "confirm", write: true, @@ -1061,7 +1063,7 @@ export const AI_TOOL_REGISTRY: AiToolDef[] = [ }, }, { - name: "list_support_tickets", + name: supersededStaticName("list_support_tickets"), description: "List support tickets for the requester or all tickets (admin).", mode: "auto", parameters: { @@ -1102,7 +1104,7 @@ export const AI_TOOL_REGISTRY: AiToolDef[] = [ }, // --- Wiki --- { - name: "list_wiki_pages", + name: supersededStaticName("list_wiki_pages"), description: "List wiki pages visible to the current user.", mode: "auto", parameters: { @@ -1127,7 +1129,7 @@ export const AI_TOOL_REGISTRY: AiToolDef[] = [ }, }, { - name: "create_wiki_page", + name: supersededStaticName("create_wiki_page"), description: "Create a wiki page. Requires confirmation.", mode: "confirm", write: true, @@ -1144,7 +1146,7 @@ export const AI_TOOL_REGISTRY: AiToolDef[] = [ }, }, { - name: "update_wiki_page", + name: supersededStaticName("update_wiki_page"), description: "Update a wiki page. Requires confirmation.", mode: "confirm", write: true, @@ -1161,7 +1163,7 @@ export const AI_TOOL_REGISTRY: AiToolDef[] = [ }, }, { - name: "delete_wiki_page", + name: supersededStaticName("delete_wiki_page"), description: "Delete a wiki page. Requires confirmation.", mode: "confirm", write: true, @@ -1227,13 +1229,13 @@ export const AI_TOOL_REGISTRY: AiToolDef[] = [ }, // --- Hooks / events --- { - name: "list_hooks", + name: supersededStaticName("list_hooks"), description: "List automation hooks owned by the user or their agents.", mode: "auto", parameters: { type: "object", properties: {} }, }, { - name: "create_hook", + name: supersededStaticName("create_hook"), description: "Create an automation hook so you can KEEP WORKING across turns (self-loop). For a recurring timer loop set triggerKind:'schedule' WITH scheduleCron (cron, e.g. '*/5 * * * *') and actionKind:'run_agent' WITH actionConfigJson = a JSON STRING '{\"agentId\":\"\",\"prompt\":\"\"}'. ownerKind defaults to 'agent' and ownerId to your agent id. A schedule hook MUST include scheduleCron; an event hook (triggerKind:'event') MUST include eventType. Requires confirmation.", mode: "confirm", @@ -1270,7 +1272,7 @@ export const AI_TOOL_REGISTRY: AiToolDef[] = [ }, }, { - name: "update_hook", + name: supersededStaticName("update_hook"), description: "Update an automation hook. Requires confirmation.", mode: "confirm", write: true, @@ -1288,7 +1290,7 @@ export const AI_TOOL_REGISTRY: AiToolDef[] = [ }, }, { - name: "delete_hook", + name: supersededStaticName("delete_hook"), description: "Delete an automation hook. Requires confirmation.", mode: "confirm", write: true, @@ -1299,7 +1301,7 @@ export const AI_TOOL_REGISTRY: AiToolDef[] = [ }, }, { - name: "list_hook_runs", + name: supersededStaticName("list_hook_runs"), description: "List recent runs for a hook.", mode: "auto", parameters: { @@ -1543,13 +1545,57 @@ export const AI_TOOL_REGISTRY: AiToolDef[] = [ }, }, { - name: "list_inference_endpoints", + name: supersededStaticName("list_inference_endpoints"), description: "List inference endpoints owned by the current user.", mode: "auto", parameters: { type: "object", properties: {} }, }, ]; +/** + * Static definitions with names now generated from core ObjectTypes. Keeping + * both made the legacy switch win over kernel dispatch and exposed divergent + * schemas. The legacy definitions remain above only as migration reference; + * callers see the generated CRUD/action definition. + */ +export const STATIC_GENERATED_COLLISION_NAMES = new Set([ + "create_agent", + "create_hook", + "create_notification", + "create_rule", + "create_schedule", + "create_share_grant", + "create_skill", + "create_support_ticket", + "create_wiki_page", + "create_workflow", + "delete_artifact", + "delete_hook", + "delete_structure_node", + "delete_wiki_page", + "list_artifacts", + "list_card_comments", + "list_hook_runs", + "list_hooks", + "list_inference_endpoints", + "list_notifications", + "list_schedules", + "list_share_grants", + "list_support_tickets", + "list_wiki_pages", + "list_workflows", + "update_hook", + "update_structure_node", + "update_wiki_page", + "update_workflow", +]); + +function effectiveStaticTools(): AiToolDef[] { + return AI_TOOL_REGISTRY.filter( + (tool) => !STATIC_GENERATED_COLLISION_NAMES.has(tool.name) + ); +} + /** Native coding/terminal tools gated by agent codeAccess. */ export const CODING_TOOL_NAMES = new Set([ "read_file", @@ -1651,7 +1697,7 @@ for (const t of AI_TOOL_REGISTRY) { * and arrive via departmentToolNames("trading"). */ export function platformStructureToolNames(): string[] { - return AI_TOOL_REGISTRY.filter( + return effectiveStaticTools().filter( (t) => t.category === "platform" && !t.departments?.length ).map((t) => t.name); } @@ -1662,7 +1708,7 @@ export function platformStructureToolNames(): string[] { * department explicitly so non-platform contexts don't receive them implicitly. */ export function generalToolNames(): string[] { - return AI_TOOL_REGISTRY.filter( + return effectiveStaticTools().filter( (t) => !t.departments?.length && t.category !== "platform" && t.category !== "coding" ).map((t) => t.name); @@ -1670,7 +1716,7 @@ export function generalToolNames(): string[] { /** Names of tools scoped to a specific department. */ export function departmentToolNames(departmentId: string): string[] { - const core = AI_TOOL_REGISTRY.filter((t) => + const core = effectiveStaticTools().filter((t) => t.departments?.includes(departmentId) ).map((t) => t.name); const plugin = pluginToolsAsAiDefs() @@ -1693,7 +1739,8 @@ export const PERSONAL_DIGITAL_YOU_TOOL_NAMES = [ ] as const; function allRegisteredTools(): AiToolDef[] { - const coreNames = new Set(AI_TOOL_REGISTRY.map((t) => t.name)); + const staticTools = effectiveStaticTools(); + const coreNames = new Set(staticTools.map((t) => t.name)); const autoOt = objectTypeAutoToolDefs(coreNames).map( (t): AiToolDef => ({ name: t.name, @@ -1704,7 +1751,7 @@ function allRegisteredTools(): AiToolDef[] { write: t.write, }) ); - return [...AI_TOOL_REGISTRY, ...autoOt, ...pluginToolsAsAiDefs()]; + return [...staticTools, ...autoOt, ...pluginToolsAsAiDefs()]; } /** Default tool allowlist for Intelligence on personal (non-operator) tenants. */ diff --git a/apps/bridge/src/services/ai-workflows.ts b/apps/bridge/src/services/ai-workflows.ts index 35e27ab..71aed59 100644 --- a/apps/bridge/src/services/ai-workflows.ts +++ b/apps/bridge/src/services/ai-workflows.ts @@ -170,6 +170,73 @@ export function deleteWorkflow(db: AppDatabase, id: string): boolean { return db.prepare(`DELETE FROM ai_workflows WHERE id = ?`).run(id).changes > 0; } +export interface WorkflowComment { + id: string; + workflow_id: string; + author: "user" | "agent"; + body: string; + created_at: string; +} + +export function listWorkflowComments( + db: AppDatabase, + workflowId?: string +): WorkflowComment[] { + return (workflowId + ? db + .prepare( + `SELECT id, workflow_id, author, body, created_at + FROM ai_workflow_comments WHERE workflow_id = ? + ORDER BY created_at ASC` + ) + .all(workflowId) + : db + .prepare( + `SELECT id, workflow_id, author, body, created_at + FROM ai_workflow_comments ORDER BY created_at DESC` + ) + .all()) as WorkflowComment[]; +} + +export function getWorkflowComment( + db: AppDatabase, + id: string +): WorkflowComment | null { + return ( + (db + .prepare( + `SELECT id, workflow_id, author, body, created_at + FROM ai_workflow_comments WHERE id = ?` + ) + .get(id) as WorkflowComment | undefined) ?? null + ); +} + +export function createWorkflowComment( + db: AppDatabase, + input: { + workflowId: string; + author?: "user" | "agent"; + body: string; + } +): WorkflowComment { + if (!getWorkflow(db, input.workflowId)) { + throw Object.assign(new Error("Workflow not found"), { status: 404 }); + } + const id = uuidv4(); + db.prepare( + `INSERT INTO ai_workflow_comments (id, workflow_id, author, body) + VALUES (?, ?, ?, ?)` + ).run(id, input.workflowId, input.author ?? "user", input.body); + return getWorkflowComment(db, id)!; +} + +export function deleteWorkflowComment(db: AppDatabase, id: string): boolean { + return db + .prepare(`DELETE FROM ai_workflow_comments WHERE id = ?`) + .run(id).changes > 0; +} + export interface WorkflowRunResult { ok: boolean; output: string; diff --git a/apps/bridge/src/services/bridge-connections.ts b/apps/bridge/src/services/bridge-connections.ts index 727409f..3b5103b 100644 --- a/apps/bridge/src/services/bridge-connections.ts +++ b/apps/bridge/src/services/bridge-connections.ts @@ -82,6 +82,38 @@ export function touchBridgeConnection(core: CoreDatabase, id: string): void { ).run(id); } +export async function probeBridgeConnection( + core: CoreDatabase, + connection: CoreBridgeConnection, + signal?: AbortSignal +): Promise<{ ok: boolean; status: number; detail?: unknown }> { + if (connection.mode === "local") { + touchBridgeConnection(core, connection.id); + return { ok: true, status: 200, detail: { mode: "local" } }; + } + if (!connection.remote_bridge_url) { + throw new Error("Remote bridge URL is not configured"); + } + const url = `${connection.remote_bridge_url.replace(/\/$/, "")}/api/health`; + const headers: Record = { Accept: "application/json" }; + if (connection.remote_bridge_token) { + headers.Authorization = `Bearer ${connection.remote_bridge_token}`; + } + const response = await fetch(url, { headers, signal }); + const body = await response.text(); + let detail: unknown = body; + try { + detail = body ? JSON.parse(body) : null; + } catch {} + core.prepare( + `UPDATE bridge_connections + SET status=?, last_seen_at=CASE WHEN ? THEN datetime('now') ELSE last_seen_at END, + updated_at=datetime('now') + WHERE id=?` + ).run(response.ok ? "active" : "error", response.ok ? 1 : 0, connection.id); + return { ok: response.ok, status: response.status, detail }; +} + export function deleteBridgeConnection(core: CoreDatabase, id: string): boolean { return ( core.prepare(`DELETE FROM bridge_connections WHERE id = ?`).run(id).changes > 0 diff --git a/apps/bridge/src/services/capability-index.ts b/apps/bridge/src/services/capability-index.ts index 0d03d94..c0e9b56 100644 --- a/apps/bridge/src/services/capability-index.ts +++ b/apps/bridge/src/services/capability-index.ts @@ -1,6 +1,10 @@ import { createHash } from "node:crypto"; import type { AppDatabase } from "../db.js"; -import { AI_TOOL_REGISTRY, isToolVisibleForAgent } from "./ai-tools-registry.js"; +import { + AI_TOOL_REGISTRY, + isToolVisibleForAgent, + listVisibleTools, +} from "./ai-tools-registry.js"; import { listAiSkills } from "./ai-skills.js"; import { getWorkflow } from "./ai-workflows.js"; import { listObjectTypes } from "../kernel/registry.js"; @@ -129,7 +133,10 @@ function toolVisible(db: AppDatabase, agentId: string, toolName: string): boolea export function buildCapabilityDocs(db: AppDatabase, agentId: string): CapabilityDoc[] { const docs: CapabilityDoc[] = []; - for (const tool of AI_TOOL_REGISTRY) { + // Index the effective registry, including generated ObjectType tools. The + // previous static-only loop omitted canonical CRUD/action tools after the + // cutover and kept advertising definitions that could shadow them. + for (const tool of listVisibleTools(db, agentId)) { if (!toolVisible(db, agentId, tool.name)) continue; const meta = TOOL_WHEN_TO_USE[tool.name]; const whenToUse = meta?.when ?? tool.description; diff --git a/apps/bridge/src/services/connection-resolver.ts b/apps/bridge/src/services/connection-resolver.ts index 925c6e0..f64ece4 100644 --- a/apps/bridge/src/services/connection-resolver.ts +++ b/apps/bridge/src/services/connection-resolver.ts @@ -10,6 +10,8 @@ export type ResolvedConnection = ownerTenantId: string; remoteUrl: string; remoteToken: string; + resourceKind: MarketplaceListingKind; + resourceId: string; } | { mode: "offline"; ownerTenantId: string; reason: string }; @@ -84,6 +86,8 @@ export function resolveConnectionForResource( ownerTenantId: access.ownerTenantId, remoteUrl, remoteToken, + resourceKind: opts.resourceKind, + resourceId: opts.resourceId, }; } diff --git a/apps/bridge/src/services/data-management-migration.ts b/apps/bridge/src/services/data-management-migration.ts index 09b7c0a..9cb63a3 100644 --- a/apps/bridge/src/services/data-management-migration.ts +++ b/apps/bridge/src/services/data-management-migration.ts @@ -1,5 +1,5 @@ import type Database from "better-sqlite3"; -import { registerMigration, addCol } from "./db-migrations.js"; +import { registerMigration, addCol, tableExists } from "./db-migrations.js"; import { encryptSecret, decryptSecret } from "./holdings/crypto-box.js"; const MIGRATION_VERSION = 1; @@ -16,7 +16,8 @@ function migratePluginKnowledgeV2(db: Database.Database): void { function migrateV1(db: Database.Database): void { // --- Hot-path indexes --- - db.exec(` + if (tableExists(db, "ai_project_cards")) { + db.exec(` CREATE INDEX IF NOT EXISTS ai_project_cards_by_project ON ai_project_cards(project_id, sort_order); CREATE INDEX IF NOT EXISTS ai_project_cards_by_column @@ -25,7 +26,8 @@ function migrateV1(db: Database.Database): void { ON ai_project_cards(parent_card_id, sort_order); CREATE INDEX IF NOT EXISTS ai_project_cards_by_agent_due ON ai_project_cards(assigned_agent_id, due_at); - `); + `); + } const hasPmSignals = db .prepare(`SELECT 1 FROM sqlite_master WHERE type='table' AND name='pm_signals'`) @@ -104,10 +106,14 @@ function migrateV1(db: Database.Database): void { CREATE INDEX IF NOT EXISTS ai_prompts_agent_kind ON ai_prompts(agent_id, kind); `); - addCol(db, "ai_artifacts", "content", "TEXT"); - addCol(db, "ai_memories", "embedding_model", "TEXT"); - addCol(db, "ai_memories", "valid_from", "TEXT"); - addCol(db, "ai_memories", "valid_until", "TEXT"); + if (tableExists(db, "ai_artifacts")) { + addCol(db, "ai_artifacts", "content", "TEXT"); + } + if (tableExists(db, "ai_memories")) { + addCol(db, "ai_memories", "embedding_model", "TEXT"); + addCol(db, "ai_memories", "valid_from", "TEXT"); + addCol(db, "ai_memories", "valid_until", "TEXT"); + } // FTS5 for hybrid RAG keyword leg db.exec(` @@ -162,6 +168,7 @@ export function ensureCapabilityTables(db: Database.Database): void { } function migrateEncryptSecrets(db: Database.Database): void { + if (!tableExists(db, "ai_secrets")) return; const rows = db .prepare(`SELECT id, value FROM ai_secrets`) .all() as Array<{ id: string; value: string }>; diff --git a/apps/bridge/src/services/db-migrations.ts b/apps/bridge/src/services/db-migrations.ts index 3d51fe7..3de73e0 100644 --- a/apps/bridge/src/services/db-migrations.ts +++ b/apps/bridge/src/services/db-migrations.ts @@ -2,18 +2,32 @@ import type Database from "better-sqlite3"; export type MigrationFn = (db: Database.Database) => void; -const migrations: Array<{ version: number; name: string; up: MigrationFn }> = []; +export interface Migration { + version: number; + name: string; + up: MigrationFn; + foreignKeysOff?: boolean; +} + +const migrations: Migration[] = []; export function registerMigration( version: number, name: string, up: MigrationFn ): void { - // Registration is idempotent: the register* wrappers run once per - // migrateTenantDb() call, so without this guard a second tenant migration - // in the same process would duplicate entries and replay already-applied - // versions (UNIQUE constraint on schema_version.version). - if (migrations.some((m) => m.version === version)) return; + if (!Number.isSafeInteger(version) || version <= 0) { + throw new Error(`Migration version must be a positive integer: ${version}`); + } + const existing = migrations.find((m) => m.version === version); + if (existing) { + if (existing.name !== name || existing.up !== up) { + throw new Error( + `Migration version ${version} is already registered as "${existing.name}"` + ); + } + return; + } migrations.push({ version, name, up }); migrations.sort((a, b) => a.version - b.version); } @@ -29,43 +43,104 @@ export function ensureSchemaVersionTable(db: Database.Database): void { } export function getCurrentSchemaVersion(db: Database.Database): number { + const applied = getAppliedSchemaVersions(db); + let contiguousVersion = 0; + while (applied.has(contiguousVersion + 1)) contiguousVersion += 1; + return contiguousVersion; +} + +export function getAppliedSchemaVersions(db: Database.Database): Set { ensureSchemaVersionTable(db); - const row = db - .prepare(`SELECT MAX(version) AS v FROM schema_version`) - .get() as { v: number | null }; - return row?.v ?? 0; + const rows = db.prepare(`SELECT version FROM schema_version`).all() as Array<{ + version: number; + }>; + return new Set(rows.map((row) => row.version)); } -export function runPendingMigrations(db: Database.Database): void { +export function runMigrations( + db: Database.Database, + availableMigrations: readonly Migration[] +): void { ensureSchemaVersionTable(db); - const current = getCurrentSchemaVersion(db); + const applied = getAppliedSchemaVersions(db); const insert = db.prepare( `INSERT INTO schema_version (version, name) VALUES (?, ?)` ); - for (const m of migrations) { - if (m.version <= current) continue; + for (const m of [...availableMigrations].sort((a, b) => a.version - b.version)) { + if (applied.has(m.version)) continue; + const restoreForeignKeys = + m.foreignKeysOff && db.pragma("foreign_keys", { simple: true }) === 1; try { - m.up(db); - insert.run(m.version, m.name); + if (restoreForeignKeys) db.pragma("foreign_keys = OFF"); + db.transaction(() => { + m.up(db); + if (m.foreignKeysOff) { + const violations = db.prepare("PRAGMA foreign_key_check").all(); + if (violations.length > 0) { + throw new Error( + `Migration ${m.version} introduced ${violations.length} foreign key violation(s)` + ); + } + } + insert.run(m.version, m.name); + })(); + applied.add(m.version); console.log(`[db] migration ${m.version}: ${m.name}`); } catch (err) { console.error(`[db] migration ${m.version} (${m.name}) failed`, err); throw err; + } finally { + if (restoreForeignKeys) db.pragma("foreign_keys = ON"); } } } -/** Idempotent ADD COLUMN helper used by legacy inline migrations. */ +export function runPendingMigrations(db: Database.Database): void { + runMigrations(db, migrations); +} + +const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/; + +function quoteIdentifier(identifier: string): string { + if (!IDENTIFIER.test(identifier)) { + throw new Error(`Invalid SQLite identifier: ${identifier}`); + } + return `"${identifier}"`; +} + +export function tableExists(db: Database.Database, table: string): boolean { + return Boolean( + db + .prepare(`SELECT 1 FROM sqlite_master WHERE type='table' AND name=?`) + .get(table) + ); +} + +export function columnExists( + db: Database.Database, + table: string, + column: string +): boolean { + if (!tableExists(db, table)) return false; + const rows = db + .prepare(`PRAGMA table_info(${quoteIdentifier(table)})`) + .all() as Array<{ name: string }>; + return rows.some((row) => row.name === column); +} + +/** Idempotent ADD COLUMN helper with explicit existence checks. */ export function addCol( db: Database.Database, table: string, col: string, def: string ): void { - try { - db.exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${def}`); - } catch { - /* column exists */ + if (!tableExists(db, table)) { + throw new Error(`Cannot add column ${col}: table ${table} does not exist`); } + if (columnExists(db, table, col)) return; + db.exec( + `ALTER TABLE ${quoteIdentifier(table)} ADD COLUMN ${quoteIdentifier(col)} ${def}` + ); } diff --git a/apps/bridge/src/services/events-relay.ts b/apps/bridge/src/services/events-relay.ts index a2b5eae..4079a72 100644 --- a/apps/bridge/src/services/events-relay.ts +++ b/apps/bridge/src/services/events-relay.ts @@ -1,4 +1,5 @@ import type { EventEmitter } from "node:events"; +import { randomUUID } from "node:crypto"; import type { AppDatabase } from "../db.js"; import { listUndispatchedEvents, @@ -16,20 +17,29 @@ export function durableEventConsumer unknown>( return listener; } -export function startEventsRelay(db: AppDatabase, bus: EventEmitter): () => void { +export async function relayEventsOnce( + db: AppDatabase, + bus: EventEmitter, + relayId = randomUUID() +): Promise { db.exec(` CREATE TABLE IF NOT EXISTS event_consumer_receipts ( event_id TEXT NOT NULL, consumer_id TEXT NOT NULL, processed_at TEXT NOT NULL DEFAULT (datetime('now')), PRIMARY KEY (event_id, consumer_id) - ) + ); + CREATE TABLE IF NOT EXISTS event_relay_leases ( + event_id TEXT PRIMARY KEY, + lease_owner TEXT NOT NULL, + lease_expires_at TEXT NOT NULL + ); `); - const dispatch = ( + const dispatch = async ( eventId: string, channel: string, value: unknown - ): boolean => { + ): Promise => { const listeners = bus.listeners(channel); for (const [index, listener] of listeners.entries()) { const consumerId = String( @@ -43,8 +53,16 @@ export function startEventsRelay(db: AppDatabase, bus: EventEmitter): () => void ) .get(eventId, consumerId); if (complete) continue; + const heartbeat = setInterval(() => { + db.prepare( + `UPDATE event_relay_leases + SET lease_expires_at=datetime('now', '+60 seconds') + WHERE event_id=? AND lease_owner=?` + ).run(eventId, relayId); + }, 20_000); + heartbeat.unref?.(); try { - listener(value); + await Promise.resolve(listener(value)); db.prepare( `INSERT OR IGNORE INTO event_consumer_receipts (event_id, consumer_id) VALUES (?, ?)` @@ -55,46 +73,116 @@ export function startEventsRelay(db: AppDatabase, bus: EventEmitter): () => void error instanceof Error ? error.message : error ); return false; + } finally { + clearInterval(heartbeat); } } return true; }; - const tick = () => { + const claim = (eventId: string): boolean => + db.transaction(() => { + db.prepare( + `DELETE FROM event_relay_leases + WHERE event_id=? AND lease_expires_at <= datetime('now')` + ).run(eventId); + return ( + db.prepare( + `INSERT OR IGNORE INTO event_relay_leases + (event_id, lease_owner, lease_expires_at) + VALUES (?, ?, datetime('now', '+60 seconds'))` + ).run(eventId, relayId).changes === 1 + ); + })(); + const release = (eventId: string): void => { + db.prepare( + `DELETE FROM event_relay_leases WHERE event_id=? AND lease_owner=?` + ).run(eventId, relayId); + }; + const batch = listUndispatchedEvents(db, 200); + const ids: string[] = []; + for (const evt of batch) { + if (!claim(evt.id)) continue; + let payload: unknown = {}; try { - const batch = listUndispatchedEvents(db, 200); - if (batch.length === 0) return; - const ids: string[] = []; - for (const evt of batch) { - let payload: unknown = {}; - try { - payload = JSON.parse(evt.payload_json); - } catch { - /* ignore */ - } - const envelope = { - id: evt.id, - seq: evt.seq, - ts: evt.ts, - type: evt.type, - actorAgentId: evt.actor_agent_id, - subject: evt.subject, - payload, - }; - if ( - dispatch(evt.id, "platform.event", envelope) && - dispatch(evt.id, evt.type, payload) - ) { - ids.push(evt.id); - } + payload = JSON.parse(evt.payload_json); + } catch { + /* ignore */ + } + const envelope = { + id: evt.id, + seq: evt.seq, + ts: evt.ts, + type: evt.type, + actorAgentId: evt.actor_agent_id, + subject: evt.subject, + payload, + }; + try { + if ( + (await dispatch(evt.id, "platform.event", envelope)) && + (await dispatch(evt.id, evt.type, payload)) + ) { + ids.push(evt.id); } - markEventsDispatched(db, ids); + } finally { + release(evt.id); + } + } + markEventsDispatched(db, ids); + return ids.length; +} + +export function startEventsRelay(db: AppDatabase, bus: EventEmitter): () => void { + let running = false; + const relayId = randomUUID(); + const tick = async () => { + if (running) return; + running = true; + try { + await relayEventsOnce(db, bus, relayId); } catch (err) { console.warn("[events] relay failed:", err instanceof Error ? err.message : err); + } finally { + running = false; } }; + void tick(); + const timer = setInterval(() => void tick(), RELAY_INTERVAL_MS); + timer.unref?.(); + return () => clearInterval(timer); +} - tick(); - const timer = setInterval(tick, RELAY_INTERVAL_MS); +export function startTenantEventsRelay( + databases: () => Array<{ tenantId: string; db: AppDatabase }>, + bus: EventEmitter +): () => void { + let running = false; + const relayId = randomUUID(); + const tick = async () => { + if (running) return; + running = true; + try { + for (const database of databases()) { + try { + await relayEventsOnce(database.db, bus, relayId); + } catch (error) { + console.warn( + `[events] tenant ${database.tenantId} relay failed:`, + error instanceof Error ? error.message : error + ); + } + } + } catch (error) { + console.warn( + "[events] tenant relay failed:", + error instanceof Error ? error.message : error + ); + } finally { + running = false; + } + }; + void tick(); + const timer = setInterval(() => void tick(), RELAY_INTERVAL_MS); timer.unref?.(); return () => clearInterval(timer); } diff --git a/apps/bridge/src/services/federation-client.ts b/apps/bridge/src/services/federation-client.ts index 5987f06..0b8bd14 100644 --- a/apps/bridge/src/services/federation-client.ts +++ b/apps/bridge/src/services/federation-client.ts @@ -13,6 +13,12 @@ export async function federationExecuteRemote( remoteUrl: string, remoteToken: string, line: string, + binding: { + resourceKind: string; + resourceId: string; + ownerTenantId: string; + targetTenantId: string; + }, chartbookKey?: string ): Promise { const base = remoteUrl.replace(/\/$/, ""); @@ -22,7 +28,7 @@ export async function federationExecuteRemote( Authorization: `Bearer ${remoteToken}`, "Content-Type": "application/json", }, - body: JSON.stringify({ line, chartbookKey }), + body: JSON.stringify({ line, chartbookKey, ...binding }), }); const body = (await res.json().catch(() => ({}))) as { ok?: boolean; @@ -64,6 +70,7 @@ export async function federationHealthRemote( export type ScDispatchContext = { resolved: ResolvedConnection; line: string; + targetTenantId: string; chartbookKey?: string; localEnqueue: (line: string, chartbookKey?: string) => string; }; @@ -75,6 +82,12 @@ export async function dispatchScLine(ctx: ScDispatchContext): Promise): LlmSettings { + updateSettings( + patch: Record, + db: AppDatabase = this.db + ): LlmSettings { const numKeys = [ "ctxSize", "gpuLayers", @@ -299,38 +302,38 @@ export class LlmManager { ]; for (const key of numKeys) { if (patch[key] != null && Number.isFinite(Number(patch[key]))) { - writeSetting(this.db, key, String(Number(patch[key]))); + writeSetting(db, key, String(Number(patch[key]))); } } if (patch.flashAttn != null) - writeSetting(this.db, "flashAttn", String(patch.flashAttn)); + writeSetting(db, "flashAttn", String(patch.flashAttn)); if (patch.jinja != null) - writeSetting(this.db, "jinja", patch.jinja ? "true" : "false"); + writeSetting(db, "jinja", patch.jinja ? "true" : "false"); if (patch.extraArgs != null) - writeSetting(this.db, "extraArgs", String(patch.extraArgs)); + writeSetting(db, "extraArgs", String(patch.extraArgs)); if (patch.autoStart != null) - writeSetting(this.db, "autoStart", patch.autoStart ? "true" : "false"); + writeSetting(db, "autoStart", patch.autoStart ? "true" : "false"); if (patch.activeModelPath != null) - writeSetting(this.db, "activeModelPath", String(patch.activeModelPath)); + writeSetting(db, "activeModelPath", String(patch.activeModelPath)); if (patch.systemPrompt != null) - writeSetting(this.db, "systemPrompt", String(patch.systemPrompt)); + writeSetting(db, "systemPrompt", String(patch.systemPrompt)); if (patch.enableThinking != null) - writeSetting(this.db, "enableThinking", patch.enableThinking ? "true" : "false"); + writeSetting(db, "enableThinking", patch.enableThinking ? "true" : "false"); if (patch.thinkingEfficiency != null) writeSetting( - this.db, + db, "thinkingEfficiency", patch.thinkingEfficiency === "low" ? "low" : "normal" ); if (patch.nativeTools != null) - writeSetting(this.db, "nativeTools", patch.nativeTools ? "true" : "false"); + writeSetting(db, "nativeTools", patch.nativeTools ? "true" : "false"); if (patch.memoryMode != null) writeSetting( - this.db, + db, "memoryMode", patch.memoryMode === "auto" ? "auto" : "approval" ); - return this.getSettings(); + return this.getSettings(db); } /** Default base prompt, so the UI can offer a "reset to default". */ diff --git a/apps/bridge/src/services/marketplace-catalog.ts b/apps/bridge/src/services/marketplace-catalog.ts index e046d44..1977202 100644 --- a/apps/bridge/src/services/marketplace-catalog.ts +++ b/apps/bridge/src/services/marketplace-catalog.ts @@ -7,16 +7,18 @@ import { getCoreDb, type CoreDatabase } from "../core-db.js"; import type { AppDatabase } from "../db.js"; import { importEntity, type PortableBundle } from "./portability.js"; import { - installPluginForTenant, listAvailablePlugins, listInstalledPlugins, - uninstallPluginForTenant, } from "../plugins/plugin-install.js"; -import { appendPluginPath } from "../plugins/activate-plugin.js"; -import { loadPluginFromRoot } from "../plugins/loader.js"; import { pluginRuntime } from "../plugins/runtime.js"; import { bridgeEntryExists, ensurePluginBuilt } from "./plugin-build.js"; import { readGodmodePluginManifest } from "@godmode/plugin-api"; +import { + activatePluginForTenant, + installPluginForTenant, + persistPluginPath, + uninstallPluginForTenant, +} from "./plugin-lifecycle.js"; export type CatalogInstallType = "clone" | "plugin"; @@ -345,12 +347,12 @@ export async function registerLocalPluginFolder( await ensurePluginBuilt(pluginRoot); } - appendPluginPath(core, pluginRoot); - await loadPluginFromRoot(pluginRoot); - let installed = false; if (opts?.installForTenant !== false) { - await installPluginForTenant(core, tenantId, manifest.id, pluginRoot); + await activatePluginForTenant(core, tenantId, pluginRoot, { + buildIfNeeded: false, + installForTenant: true, + }); installed = true; const installId = uuidv4(); @@ -366,7 +368,7 @@ export async function registerLocalPluginFolder( "plugin", `local://${pluginRoot}` ); - } + } else persistPluginPath(core, pluginRoot); return { pluginId: manifest.id, @@ -407,7 +409,11 @@ export async function installDiscoveredPlugin( ); } if (!pluginRuntime.hasPlugin(pluginId)) { - await loadPluginFromRoot(available.pluginRoot); + await activatePluginForTenant(core, tenantId, available.pluginRoot, { + buildIfNeeded: false, + installForTenant: true, + }); + return; } await installPluginForTenant(core, tenantId, pluginId, available.pluginRoot); } @@ -466,10 +472,16 @@ async function installPluginEntry( } } - appendPluginPath(core, target); - const loadResult = await loadPluginFromRoot(target); - await installPluginForTenant(core, tenantId, loadResult.pluginId, target); - return { pluginId: loadResult.pluginId, pluginRoot: target, restartRequired: false, built }; + const activation = await activatePluginForTenant(core, tenantId, target, { + buildIfNeeded: false, + installForTenant: true, + }); + return { + pluginId: activation.pluginId, + pluginRoot: target, + restartRequired: false, + built, + }; } function authenticatedGitCloneUrl(repo: string): string { diff --git a/apps/bridge/src/services/marketplace-listings.ts b/apps/bridge/src/services/marketplace-listings.ts new file mode 100644 index 0000000..a1c56fc --- /dev/null +++ b/apps/bridge/src/services/marketplace-listings.ts @@ -0,0 +1,478 @@ +import { v4 as uuidv4 } from "uuid"; +import type { + CoreDatabase, + DeliveryMode, + MarketplaceListingKind, + PricingModel, +} from "../core-db.js"; +import type { AppDatabase } from "../db.js"; +import { exportEntity, importEntity, type PortableBundle } from "./portability.js"; + +export interface PublishMarketplaceListingInput { + sellerUserId: string; + sellerTenantId: string; + kind: MarketplaceListingKind; + resourceId?: string; + title?: string; + description?: string; + priceCredits?: number; + deliveryMode?: DeliveryMode; + pricingModel?: PricingModel; + pricePeriod?: string; + meterUnit?: string; + meterRate?: number; + license?: string; + inferenceEndpointId?: string; + bundleChildren?: PortableBundle[]; +} + +export function publishMarketplaceListing( + core: CoreDatabase, + tenantDb: AppDatabase, + input: PublishMarketplaceListingInput +): Record { + const delivery = input.deliveryMode ?? "clone"; + const pricing = input.pricingModel ?? "one_time"; + let bundleJson = "{}"; + let title = input.title ?? input.kind; + let endpointId = input.inferenceEndpointId ?? null; + + if (input.kind === "inference") { + endpointId ??= input.resourceId ?? null; + if (!endpointId) throw new Error("inferenceEndpointId required for inference listings"); + const endpoint = core + .prepare( + `SELECT name FROM inference_endpoints + WHERE id=? AND owner_user_id=? AND owner_tenant_id=? AND status='active'` + ) + .get(endpointId, input.sellerUserId, input.sellerTenantId) as + | { name: string } + | undefined; + if (!endpoint) throw Object.assign(new Error("Inference endpoint not found"), { status: 404 }); + title = input.title ?? endpoint.name; + } else if (input.kind === "bundle") { + if (!input.bundleChildren?.length) throw new Error("bundleChildren required for bundle listings"); + bundleJson = JSON.stringify({ title, children: input.bundleChildren }); + } else if (delivery === "clone") { + if (!input.resourceId) throw new Error("resourceId required for clone listings"); + const bundle = exportEntity(tenantDb, input.kind, input.resourceId); + title = input.title ?? bundle.title; + bundleJson = JSON.stringify(bundle); + } else if (!input.resourceId) { + throw new Error("resourceId required for live listings"); + } + + const id = uuidv4(); + core.prepare( + `INSERT INTO marketplace_listings + (id, seller_user_id, seller_tenant_id, kind, resource_id, title, description, + price_credits, bundle_json, visibility, status, delivery_mode, pricing_model, + price_period, meter_unit, meter_rate, license, inference_endpoint_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'public', 'active', ?, ?, ?, ?, ?, ?, ?)` + ).run( + id, + input.sellerUserId, + input.sellerTenantId, + input.kind, + input.resourceId ?? endpointId ?? id, + title, + input.description ?? null, + Number(input.priceCredits ?? 0), + bundleJson, + delivery, + pricing, + input.pricePeriod ?? null, + input.meterUnit ?? null, + input.meterRate ?? null, + input.license ?? null, + endpointId + ); + return core.prepare("SELECT * FROM marketplace_listings WHERE id=?").get(id) as Record< + string, + unknown + >; +} + +export function archiveMarketplaceListing( + core: CoreDatabase, + input: { listingId: string; sellerUserId: string; sellerTenantId: string } +): Record { + const changed = core + .prepare( + `UPDATE marketplace_listings + SET status='archived', updated_at=datetime('now') + WHERE id=? AND seller_user_id=? AND seller_tenant_id=? AND status='active'` + ) + .run(input.listingId, input.sellerUserId, input.sellerTenantId); + if (!changed.changes) { + throw Object.assign(new Error("Listing not found"), { status: 404 }); + } + return core.prepare("SELECT * FROM marketplace_listings WHERE id=?").get(input.listingId) as Record< + string, + unknown + >; +} + +export function acquireCloneListing( + databases: { core: CoreDatabase; buyerTenant: AppDatabase }, + input: { + listingId: string; + buyerUserId: string; + buyerTenantId: string; + idempotencyKey: string; + }, + options: CloneAcquisitionOptions = {} +): Record { + const { core, buyerTenant } = databases; + if (!input.idempotencyKey.trim()) throw new Error("idempotencyKey required"); + ensureCloneAcquisitionStorage(core, buyerTenant); + + options.fail?.("before_operation_registered"); + let operation = core + .prepare( + `SELECT * FROM marketplace_acquisition_operations + WHERE buyer_tenant_id=? AND buyer_user_id=? AND idempotency_key=?` + ) + .get(input.buyerTenantId, input.buyerUserId, input.idempotencyKey) as + | CloneAcquisitionOperation + | undefined; + + if (operation) { + if (operation.listing_id !== input.listingId) { + throw Object.assign(new Error("Idempotency key reused for a different listing"), { + status: 409, + }); + } + } else { + const listing = core + .prepare( + `SELECT * FROM marketplace_listings + WHERE id=? AND status='active' AND visibility='public'` + ) + .get(input.listingId) as Record | undefined; + if (!listing) throw Object.assign(new Error("Listing not found"), { status: 404 }); + if (String(listing.delivery_mode ?? "clone") === "live") { + throw new Error("Live listings must be acquired as entitlements"); + } + const operationId = uuidv4(); + core.transaction(() => { + core.prepare( + `INSERT INTO marketplace_acquisition_operations + (id, idempotency_key, listing_id, buyer_user_id, buyer_tenant_id, + listing_bundle_json, listing_title, status) + VALUES (?, ?, ?, ?, ?, ?, ?, 'registered')` + ).run( + operationId, + input.idempotencyKey, + input.listingId, + input.buyerUserId, + input.buyerTenantId, + String(listing.bundle_json), + String(listing.title) + ); + recordCoreAcquisitionStep(core, operationId, "operation_registered", { + listingId: input.listingId, + }); + })(); + operation = getCloneAcquisitionOperation(core, operationId); + } + options.fail?.("after_operation_registered"); + + let imported = buyerTenant + .prepare( + `SELECT imported_kind, imported_id FROM marketplace_acquisition_imports + WHERE operation_id=? AND buyer_tenant_id=?` + ) + .get(operation.id, input.buyerTenantId) as + | { imported_kind: string; imported_id: string } + | undefined; + if (!imported) { + options.fail?.("before_tenant_imported"); + const bundle = parseListingBundle( + operation.listing_bundle_json, + operation.listing_id, + operation.listing_title + ); + const result = buyerTenant.transaction(() => { + const importedEntity = importEntity(buyerTenant, bundle); + buyerTenant.prepare( + `INSERT INTO marketplace_acquisition_imports + (operation_id, buyer_tenant_id, listing_id, imported_kind, imported_id) + VALUES (?, ?, ?, ?, ?)` + ).run( + operation!.id, + input.buyerTenantId, + input.listingId, + importedEntity.kind, + importedEntity.newId + ); + recordTenantAcquisitionStep(buyerTenant, operation!.id, input.buyerTenantId, { + kind: importedEntity.kind, + newId: importedEntity.newId, + }); + return { + imported_kind: importedEntity.kind, + imported_id: importedEntity.newId, + }; + })(); + imported = result; + } + options.fail?.("after_tenant_imported"); + + if (operation.status === "registered") { + core.transaction(() => { + core.prepare( + `UPDATE marketplace_acquisition_operations + SET status='tenant_imported', imported_kind=?, imported_id=?, + updated_at=datetime('now') + WHERE id=? AND status='registered'` + ).run(imported!.imported_kind, imported!.imported_id, operation!.id); + recordCoreAcquisitionStep(core, operation!.id, "tenant_imported", { + kind: imported!.imported_kind, + newId: imported!.imported_id, + }); + })(); + operation = getCloneAcquisitionOperation(core, operation.id); + } + + options.fail?.("before_purchase_recorded"); + if (operation.status === "tenant_imported") { + const purchaseId = operation.id; + core.transaction(() => { + core.prepare( + `INSERT OR IGNORE INTO marketplace_purchases + (id, listing_id, buyer_user_id, buyer_tenant_id, price_credits) + VALUES (?, ?, ?, ?, 0)` + ).run( + purchaseId, + operation!.listing_id, + operation!.buyer_user_id, + operation!.buyer_tenant_id + ); + core.prepare( + `UPDATE marketplace_acquisition_operations + SET status='purchase_recorded', purchase_id=?, updated_at=datetime('now') + WHERE id=? AND status='tenant_imported'` + ).run(purchaseId, operation!.id); + recordCoreAcquisitionStep(core, operation!.id, "purchase_recorded", { + purchaseId, + }); + })(); + operation = getCloneAcquisitionOperation(core, operation.id); + } + options.fail?.("after_purchase_recorded"); + + options.fail?.("before_completed"); + if (operation.status === "purchase_recorded") { + core.transaction(() => { + core.prepare( + `UPDATE marketplace_acquisition_operations + SET status='completed', completed_at=datetime('now'), updated_at=datetime('now') + WHERE id=? AND status='purchase_recorded'` + ).run(operation!.id); + recordCoreAcquisitionStep(core, operation!.id, "completed", { + purchaseId: operation!.purchase_id, + }); + })(); + operation = getCloneAcquisitionOperation(core, operation.id); + } + options.fail?.("after_completed"); + + return { + ok: true, + mode: "clone", + operationId: operation.id, + status: operation.status, + purchaseId: operation.purchase_id, + import: { + kind: imported.imported_kind, + newId: imported.imported_id, + }, + }; +} + +export type CloneAcquisitionFailurePoint = + | "before_operation_registered" + | "after_operation_registered" + | "before_tenant_imported" + | "after_tenant_imported" + | "before_purchase_recorded" + | "after_purchase_recorded" + | "before_completed" + | "after_completed"; + +export interface CloneAcquisitionOptions { + /** Test-only crash boundary hook; production callers leave this unset. */ + fail?: (point: CloneAcquisitionFailurePoint) => void; +} + +interface CloneAcquisitionOperation { + id: string; + idempotency_key: string; + listing_id: string; + buyer_user_id: string; + buyer_tenant_id: string; + listing_bundle_json: string; + listing_title: string; + status: "registered" | "tenant_imported" | "purchase_recorded" | "completed"; + imported_kind: string | null; + imported_id: string | null; + purchase_id: string | null; +} + +function ensureCloneAcquisitionStorage( + core: CoreDatabase, + buyerTenant: AppDatabase +): void { + core.exec(` + CREATE TABLE IF NOT EXISTS marketplace_acquisition_operations ( + id TEXT PRIMARY KEY, + idempotency_key TEXT NOT NULL, + listing_id TEXT NOT NULL, + buyer_user_id TEXT NOT NULL, + buyer_tenant_id TEXT NOT NULL, + listing_bundle_json TEXT NOT NULL, + listing_title TEXT NOT NULL, + status TEXT NOT NULL, + imported_kind TEXT, + imported_id TEXT, + purchase_id TEXT, + last_error TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + completed_at TEXT, + UNIQUE (buyer_tenant_id, buyer_user_id, idempotency_key) + ); + CREATE TABLE IF NOT EXISTS marketplace_acquisition_steps ( + operation_id TEXT NOT NULL, + step_name TEXT NOT NULL, + payload_json TEXT NOT NULL, + completed_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (operation_id, step_name) + ); + CREATE TABLE IF NOT EXISTS marketplace_acquisition_audit ( + id TEXT PRIMARY KEY, + operation_id TEXT NOT NULL, + owner_database TEXT NOT NULL, + tenant_id TEXT NOT NULL, + action TEXT NOT NULL, + payload_json TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS marketplace_acquisition_outbox ( + id TEXT PRIMARY KEY, + operation_id TEXT NOT NULL, + tenant_id TEXT NOT NULL, + event_type TEXT NOT NULL, + payload_json TEXT NOT NULL, + published_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + `); + buyerTenant.exec(` + CREATE TABLE IF NOT EXISTS marketplace_acquisition_imports ( + operation_id TEXT PRIMARY KEY, + buyer_tenant_id TEXT NOT NULL, + listing_id TEXT NOT NULL, + imported_kind TEXT NOT NULL, + imported_id TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS marketplace_acquisition_audit ( + id TEXT PRIMARY KEY, + operation_id TEXT NOT NULL, + owner_database TEXT NOT NULL, + tenant_id TEXT NOT NULL, + action TEXT NOT NULL, + payload_json TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS marketplace_acquisition_outbox ( + id TEXT PRIMARY KEY, + operation_id TEXT NOT NULL, + tenant_id TEXT NOT NULL, + event_type TEXT NOT NULL, + payload_json TEXT NOT NULL, + published_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + `); +} + +function getCloneAcquisitionOperation( + core: CoreDatabase, + operationId: string +): CloneAcquisitionOperation { + const operation = core + .prepare(`SELECT * FROM marketplace_acquisition_operations WHERE id=?`) + .get(operationId) as CloneAcquisitionOperation | undefined; + if (!operation) throw new Error(`Acquisition operation not found: ${operationId}`); + return operation; +} + +function parseListingBundle( + bundleJson: string, + listingId: string, + listingTitle: string +): PortableBundle { + const parsed = JSON.parse(bundleJson) as PortableBundle | { children?: PortableBundle[] }; + return "version" in parsed && parsed.version === 1 + ? parsed + : { + version: 1, + kind: "bundle", + exportedAt: new Date().toISOString(), + sourceId: listingId, + title: listingTitle, + data: { children: (parsed as { children?: PortableBundle[] }).children ?? [] }, + }; +} + +function recordCoreAcquisitionStep( + core: CoreDatabase, + operationId: string, + step: string, + payload: Record +): void { + const operation = getCloneAcquisitionOperation(core, operationId); + const payloadJson = JSON.stringify(payload); + core.prepare( + `INSERT OR IGNORE INTO marketplace_acquisition_steps + (operation_id, step_name, payload_json) VALUES (?, ?, ?)` + ).run(operationId, step, payloadJson); + core.prepare( + `INSERT OR IGNORE INTO marketplace_acquisition_audit + (id, operation_id, owner_database, tenant_id, action, payload_json) + VALUES (?, ?, 'core', ?, ?, ?)` + ).run(`${operationId}:core:${step}`, operationId, operation.buyer_tenant_id, step, payloadJson); + core.prepare( + `INSERT OR IGNORE INTO marketplace_acquisition_outbox + (id, operation_id, tenant_id, event_type, payload_json) + VALUES (?, ?, ?, ?, ?)` + ).run( + `${operationId}:core:${step}`, + operationId, + operation.buyer_tenant_id, + `marketplace.acquisition.${step}`, + payloadJson + ); +} + +function recordTenantAcquisitionStep( + buyerTenant: AppDatabase, + operationId: string, + buyerTenantId: string, + payload: Record +): void { + const payloadJson = JSON.stringify(payload); + buyerTenant.prepare( + `INSERT OR IGNORE INTO marketplace_acquisition_audit + (id, operation_id, owner_database, tenant_id, action, payload_json) + VALUES (?, ?, 'tenant', ?, 'tenant_imported', ?)` + ).run(`${operationId}:tenant:tenant_imported`, operationId, buyerTenantId, payloadJson); + buyerTenant.prepare( + `INSERT OR IGNORE INTO marketplace_acquisition_outbox + (id, operation_id, tenant_id, event_type, payload_json) + VALUES (?, ?, ?, 'marketplace.acquisition.tenant_imported', ?)` + ).run(`${operationId}:tenant:tenant_imported`, operationId, buyerTenantId, payloadJson); +} diff --git a/apps/bridge/src/services/plugin-lifecycle.ts b/apps/bridge/src/services/plugin-lifecycle.ts new file mode 100644 index 0000000..e1c00b1 --- /dev/null +++ b/apps/bridge/src/services/plugin-lifecycle.ts @@ -0,0 +1,375 @@ +import fs from "node:fs"; +import path from "node:path"; +import { createRequire } from "node:module"; +import type { CoreDatabase } from "../core-db.js"; +import type { AppDatabase } from "../db.js"; +import { readGodmodePluginManifest } from "@godmode/plugin-api"; +import { ensurePluginBuilt } from "./plugin-build.js"; +import { + discoverPluginRoots, + loadPluginFromRoot, + type LoadPluginsResult, +} from "../plugins/loader.js"; +import { pluginRuntime } from "../plugins/runtime.js"; +import { + importPluginKnowledgeFromRoot, + removePluginKnowledge, +} from "./knowledge-store.js"; +import { getTenantDb } from "../tenant-registry.js"; +import { + applyPluginObjectTypeSeeds, + registerPluginObjectTypes, +} from "../kernel/plugin-object-types.js"; +import { unregisterObjectTypesByPlugin } from "../kernel/registry.js"; +import { listInstalledPlugins } from "../plugins/plugin-install.js"; + +const hostRequire = createRequire(import.meta.url); +const HOST_LINKED_PACKAGES = ["plugin-api", "plugin-host"] as const; +const PLUGIN_PATHS_KEY = "marketplace.plugin_paths"; + +export interface ActivatePluginResult { + pluginId: string; + pluginRoot: string; + installed: boolean; + built: boolean; + reloaded: boolean; +} + +export function ensureTenantPluginsStorage(core: CoreDatabase): void { + core.transaction(() => { + core.exec(` + CREATE TABLE IF NOT EXISTS tenant_plugins ( + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + plugin_id TEXT NOT NULL, + version TEXT NOT NULL, + installed_at TEXT NOT NULL DEFAULT (datetime('now')), + plugin_root TEXT, + state TEXT NOT NULL DEFAULT 'active', + desired_state TEXT NOT NULL DEFAULT 'active', + last_error TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (tenant_id, plugin_id) + ); + CREATE INDEX IF NOT EXISTS tenant_plugins_tenant_idx ON tenant_plugins(tenant_id); + `); + const columns = new Set( + (core.prepare(`PRAGMA table_info(tenant_plugins)`).all() as Array<{ name: string }>).map( + (column) => column.name + ) + ); + if (!columns.has("state")) { + core.exec(`ALTER TABLE tenant_plugins ADD COLUMN state TEXT NOT NULL DEFAULT 'active'`); + } + if (!columns.has("last_error")) { + core.exec(`ALTER TABLE tenant_plugins ADD COLUMN last_error TEXT`); + } + if (!columns.has("desired_state")) { + core.exec( + `ALTER TABLE tenant_plugins ADD COLUMN desired_state TEXT NOT NULL DEFAULT 'active'` + ); + } + if (!columns.has("updated_at")) { + core.exec(`ALTER TABLE tenant_plugins ADD COLUMN updated_at TEXT`); + core.exec(`UPDATE tenant_plugins SET updated_at=COALESCE(updated_at, installed_at)`); + } + })(); +} + +export function persistPluginPath(core: CoreDatabase, pluginRoot: string): void { + const existing = core.prepare(`SELECT value FROM platform_meta WHERE key=?`).get( + PLUGIN_PATHS_KEY + ) as { value: string } | undefined; + const paths: string[] = existing?.value ? JSON.parse(existing.value) : []; + const resolved = path.resolve(pluginRoot); + if (!paths.some((candidate) => path.resolve(candidate) === resolved)) paths.push(resolved); + core + .prepare( + `INSERT INTO platform_meta (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value=excluded.value` + ) + .run(PLUGIN_PATHS_KEY, JSON.stringify(paths)); +} + +/** + * Prepare only host-owned links below a validated plugin root. Link deletion + * cannot escape node_modules/@godmode, and targets must resolve from Bridge. + */ +export function prepareHostPackageLinks(pluginRoot: string): void { + const root = path.resolve(pluginRoot); + readGodmodePluginManifest(root); + const packageDir = path.join(root, "node_modules", "@godmode"); + fs.mkdirSync(packageDir, { recursive: true }); + + for (const name of HOST_LINKED_PACKAGES) { + let resolvedPackage: string; + try { + resolvedPackage = path.dirname(hostRequire.resolve(`@godmode/${name}/package.json`)); + } catch { + console.warn(`[plugins] host package @godmode/${name} not resolvable; skip link for ${root}`); + continue; + } + if (!fs.existsSync(path.join(resolvedPackage, "dist", "index.js"))) continue; + + const linkPath = path.resolve(packageDir, name); + if (!linkPath.startsWith(packageDir + path.sep)) { + throw new Error(`Unsafe plugin host link path: ${linkPath}`); + } + try { + fs.lstatSync(linkPath); + if (fs.realpathSync(linkPath) === fs.realpathSync(resolvedPackage)) continue; + fs.rmSync(linkPath, { recursive: true, force: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + fs.rmSync(linkPath, { recursive: true, force: true }); + } + } + + const type = process.platform === "win32" ? "junction" : "dir"; + fs.symlinkSync(resolvedPackage, linkPath, type); + } +} + +function syncPluginKnowledgeForTenant( + tenantId: string, + pluginId: string, + pluginRoot: string +): void { + const { rules, skills } = importPluginKnowledgeFromRoot( + getTenantDb(tenantId), + pluginRoot, + pluginId + ); + if (rules > 0 || skills > 0) { + console.log( + `[plugins] imported knowledge for ${pluginId} on tenant ${tenantId}: ${rules} rules, ${skills} skills` + ); + } +} + +export async function installPluginForTenant( + core: CoreDatabase, + tenantId: string, + pluginId: string, + pluginRoot?: string +): Promise { + const loaded = pluginRuntime.getPlugin(pluginId); + if (!loaded) throw new Error(`Plugin not loaded: ${pluginId}`); + const root = path.resolve(pluginRoot ?? loaded.pluginRoot); + registerPluginObjectTypes(loaded.manifest); + ensureTenantPluginsStorage(core); + core + .prepare( + `INSERT INTO tenant_plugins + (tenant_id, plugin_id, version, plugin_root, state, desired_state, last_error, updated_at) + VALUES (?, ?, ?, ?, 'installing', 'active', NULL, datetime('now')) + ON CONFLICT(tenant_id, plugin_id) DO UPDATE SET + version=excluded.version, plugin_root=excluded.plugin_root, + state='installing', desired_state='active', last_error=NULL, + installed_at=datetime('now'), + updated_at=datetime('now')` + ) + .run(tenantId, pluginId, loaded.manifest.version, root); + try { + applyPluginObjectTypeSeeds(getTenantDb(tenantId), loaded.manifest); + await pluginRuntime.installPluginForTenant(pluginId, tenantId); + syncPluginKnowledgeForTenant(tenantId, pluginId, root); + core + .prepare( + `UPDATE tenant_plugins SET state='active', last_error=NULL, + updated_at=datetime('now') WHERE tenant_id=? AND plugin_id=?` + ) + .run(tenantId, pluginId); + } catch (error) { + try { + removePluginKnowledge(getTenantDb(tenantId), pluginId); + await pluginRuntime.uninstallPluginForTenant(pluginId, tenantId); + } catch { + // Restart reconciliation retries compensation from the failed durable state. + } + core + .prepare( + `UPDATE tenant_plugins SET state='failed', last_error=?, + updated_at=datetime('now') WHERE tenant_id=? AND plugin_id=?` + ) + .run(error instanceof Error ? error.message : String(error), tenantId, pluginId); + throw error; + } +} + +export async function uninstallPluginForTenant( + core: CoreDatabase, + tenantId: string, + pluginId: string +): Promise { + const existing = core + .prepare(`SELECT 1 FROM tenant_plugins WHERE tenant_id=? AND plugin_id=?`) + .get(tenantId, pluginId); + if (!existing) throw Object.assign(new Error("Plugin installation not found"), { status: 404 }); + core + .prepare( + `UPDATE tenant_plugins SET state='uninstalling', desired_state='absent', last_error=NULL, + updated_at=datetime('now') WHERE tenant_id=? AND plugin_id=?` + ) + .run(tenantId, pluginId); + try { + removePluginKnowledge(getTenantDb(tenantId), pluginId); + await pluginRuntime.uninstallPluginForTenant(pluginId, tenantId); + core.prepare(`DELETE FROM tenant_plugins WHERE tenant_id=? AND plugin_id=?`).run( + tenantId, + pluginId + ); + } catch (error) { + core + .prepare( + `UPDATE tenant_plugins SET state='failed', last_error=?, + updated_at=datetime('now') WHERE tenant_id=? AND plugin_id=?` + ) + .run(error instanceof Error ? error.message : String(error), tenantId, pluginId); + throw error; + } + if (!core.prepare(`SELECT 1 FROM tenant_plugins WHERE plugin_id=? LIMIT 1`).get(pluginId)) { + unregisterObjectTypesByPlugin(pluginId); + } +} + +export async function activatePluginForTenant( + core: CoreDatabase, + tenantId: string, + pluginRoot: string, + opts: { buildIfNeeded?: boolean; installForTenant?: boolean; reload?: boolean } = {} +): Promise { + const resolved = path.resolve(pluginRoot); + readGodmodePluginManifest(resolved); + const built = opts.buildIfNeeded === false ? false : await ensurePluginBuilt(resolved); + prepareHostPackageLinks(resolved); + const load = await loadPluginFromRoot(resolved, { reload: opts.reload }); + persistPluginPath(core, resolved); + if (opts.installForTenant !== false) { + await installPluginForTenant(core, tenantId, load.pluginId, resolved); + } + return { + pluginId: load.pluginId, + pluginRoot: resolved, + installed: opts.installForTenant !== false, + built, + reloaded: load.reloaded, + }; +} + +export async function loadPluginsForBoot(): Promise { + const loaded: string[] = []; + const errors: Array<{ path: string; error: string }> = []; + for (const pluginRoot of discoverPluginRoots()) { + try { + prepareHostPackageLinks(pluginRoot); + const result = await loadPluginFromRoot(pluginRoot, { reload: false }); + loaded.push(result.pluginId); + } catch (error) { + errors.push({ + path: pluginRoot, + error: error instanceof Error ? error.message : String(error), + }); + } + } + return { loaded, errors }; +} + +export async function reconcilePluginLifecycle( + core: CoreDatabase, + operatorTenantId: string, + operatorDb: AppDatabase +): Promise { + ensureTenantPluginsStorage(core); + for (const plugin of pluginRuntime.loaded) { + const row = core + .prepare( + `SELECT state, desired_state FROM tenant_plugins WHERE tenant_id=? AND plugin_id=?` + ) + .get(operatorTenantId, plugin.manifest.id) as + | { state: string; desired_state: string } + | undefined; + if (!row) { + const hasExistingStructure = (plugin.manifest.departments ?? []).some((departmentId) => + Boolean(operatorDb.prepare(`SELECT 1 FROM structure_nodes WHERE id=?`).get(departmentId)) + ); + if (hasExistingStructure) { + core + .prepare( + `INSERT INTO tenant_plugins + (tenant_id, plugin_id, version, plugin_root, state, desired_state, updated_at) + VALUES (?, ?, ?, ?, 'active', 'active', datetime('now'))` + ) + .run( + operatorTenantId, + plugin.manifest.id, + plugin.manifest.version, + plugin.pluginRoot + ); + } else { + await installPluginForTenant( + core, + operatorTenantId, + plugin.manifest.id, + plugin.pluginRoot + ); + } + } else if (row.state !== "active" && row.desired_state !== "absent") { + await installPluginForTenant( + core, + operatorTenantId, + plugin.manifest.id, + plugin.pluginRoot + ); + } + } + + const interrupted = core + .prepare( + `SELECT tenant_id, plugin_id, desired_state FROM tenant_plugins + WHERE state<>'active' ORDER BY tenant_id, plugin_id` + ) + .all() as Array<{ + tenant_id: string; + plugin_id: string; + desired_state: "active" | "absent"; + }>; + for (const row of interrupted) { + try { + if (row.desired_state === "absent") { + await uninstallPluginForTenant(core, row.tenant_id, row.plugin_id); + } else if (pluginRuntime.hasPlugin(row.plugin_id)) { + await installPluginForTenant(core, row.tenant_id, row.plugin_id); + } + } catch (error) { + console.warn( + `[plugins] lifecycle reconciliation failed for ${row.tenant_id}/${row.plugin_id}:`, + error instanceof Error ? error.message : error + ); + } + } + + const rows = core + .prepare( + `SELECT tenant_id, plugin_id, plugin_root FROM tenant_plugins + WHERE state='active' ORDER BY tenant_id, plugin_id` + ) + .all() as Array<{ tenant_id: string; plugin_id: string; plugin_root: string | null }>; + for (const row of rows) { + const loaded = pluginRuntime.getPlugin(row.plugin_id); + if (!loaded) continue; + registerPluginObjectTypes(loaded.manifest); + applyPluginObjectTypeSeeds(getTenantDb(row.tenant_id), loaded.manifest); + syncPluginKnowledgeForTenant( + row.tenant_id, + row.plugin_id, + row.plugin_root ?? loaded.pluginRoot + ); + } +} + +export function syncInstalledPluginKnowledge(core: CoreDatabase, tenantId: string): void { + for (const row of listInstalledPlugins(core, tenantId)) { + const root = row.plugin_root ?? pluginRuntime.getPlugin(row.plugin_id)?.pluginRoot; + if (root) syncPluginKnowledgeForTenant(tenantId, row.plugin_id, root); + } +} diff --git a/apps/bridge/src/services/sc-levels-migration.ts b/apps/bridge/src/services/sc-levels-migration.ts new file mode 100644 index 0000000..2a3d450 --- /dev/null +++ b/apps/bridge/src/services/sc-levels-migration.ts @@ -0,0 +1,61 @@ +import type Database from "better-sqlite3"; +import { + addCol, + columnExists, + registerMigration, + tableExists, +} from "./db-migrations.js"; + +const MIGRATION_VERSION = 5; + +export function registerScLevelsMigration(): void { + registerMigration(MIGRATION_VERSION, "sc_levels_source_key_v1", migrateScLevelsSchema); +} + +/** + * Upgrades the legacy (symbol, label) sc_levels key without discarding rows. + * The legacy label remains the stable source key until the owning feed rewrites it. + */ +export function migrateScLevelsSchema(db: Database.Database): void { + if (!tableExists(db, "sc_levels")) return; + + if (!columnExists(db, "sc_levels", "source_key")) { + db.transaction(() => { + db.exec(` + DROP TABLE IF EXISTS sc_levels__upgrade; + CREATE TABLE sc_levels__upgrade ( + symbol TEXT NOT NULL, + source_key TEXT NOT NULL, + label TEXT NOT NULL, + price REAL NOT NULL, + kind TEXT, + chart_number INTEGER, + study_id INTEGER, + subgraph_index INTEGER, + color TEXT, + line_width INTEGER, + ts TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (symbol, source_key) + ); + INSERT INTO sc_levels__upgrade ( + symbol, source_key, label, price, kind, chart_number, study_id, + subgraph_index, color, line_width, ts, updated_at + ) + SELECT symbol, label, label, price, kind, chart_number, study_id, + subgraph_index, NULL, NULL, ts, updated_at + FROM sc_levels; + DROP TABLE sc_levels; + ALTER TABLE sc_levels__upgrade RENAME TO sc_levels; + CREATE INDEX sc_levels_by_symbol ON sc_levels(symbol, price DESC); + `); + })(); + return; + } + + addCol(db, "sc_levels", "color", "TEXT"); + addCol(db, "sc_levels", "line_width", "INTEGER"); + db.exec( + `CREATE INDEX IF NOT EXISTS sc_levels_by_symbol ON sc_levels(symbol, price DESC)` + ); +} diff --git a/apps/bridge/src/services/share-service.ts b/apps/bridge/src/services/share-service.ts index 6bb73d5..bdab9b0 100644 --- a/apps/bridge/src/services/share-service.ts +++ b/apps/bridge/src/services/share-service.ts @@ -1,10 +1,18 @@ import { v4 as uuidv4 } from "uuid"; -import type { CoreDatabase, MarketplaceListingKind, ShareGrantRole } from "../core-db.js"; +import { + createSharedChatSession, + type CoreDatabase, + type MarketplaceListingKind, + type ShareGrantRole, +} from "../core-db.js"; import { getLocalConnection } from "./bridge-connections.js"; import { config } from "../config.js"; import { getTenantDb } from "../tenant-registry.js"; import type { AppDatabase } from "../db.js"; import { emitEvent } from "./event-bus.js"; +import { getAgent } from "./agents/agents-db.js"; +import { getShareBroker } from "../ws-broker.js"; +import { exportEntity } from "./portability.js"; const ROLE_RANK: Record = { viewer: 1, @@ -20,6 +28,139 @@ export class ShareError extends Error { } } +/** + * Promote an owned chat to the collaborative session registry used by the + * protocol read path. This is the single policy-enforcing command for both + * kernel actions and any future transport-specific carriers. + */ +export function shareChatSession( + core: CoreDatabase, + input: { + db: AppDatabase; + chatId: string; + agentId: string; + tenantId?: string; + userId?: string; + } +): { + ok: true; + session: { + id: string; + chatId: string; + agentId: string; + homeTenantId: string; + createdByUserId: string; + createdAt: string; + isHome: true; + }; +} { + const { db, chatId, agentId, tenantId, userId } = input; + if (!tenantId || !userId) { + throw new ShareError(401, "Authenticated tenant and user required"); + } + const chat = db + .prepare( + `SELECT id FROM ai_chats + WHERE id = ? AND (user_id IS NULL OR user_id = ?)` + ) + .get(chatId, userId); + if (!chat) { + throw new ShareError(404, "Chat not found in your workspace"); + } + const canUseAgent = + Boolean(getAgent(db, agentId)) || + Boolean( + resolveShareAccess(core, { + userId, + tenantId, + resourceKind: "agent", + resourceId: agentId, + minRole: "viewer", + }) + ); + if (!canUseAgent) throw new ShareError(404, "Agent not found"); + + const session = createSharedChatSession(core, { + id: uuidv4(), + chatId, + homeTenantId: tenantId, + agentId, + createdByUserId: userId, + }); + const payload = { + type: "chat_session_shared", + data: { chatId, agentId, homeTenantId: session.home_tenant_id }, + timestamp: Date.now(), + }; + getShareBroker().broadcastResource("agent", agentId, payload); + getShareBroker().broadcastTenant(tenantId, payload); + return { + ok: true, + session: { + id: session.id, + chatId: session.chat_id, + agentId: session.agent_id, + homeTenantId: session.home_tenant_id, + createdByUserId: session.created_by_user_id, + createdAt: session.created_at, + isHome: true, + }, + }; +} + +function assertGrantAuthority( + core: CoreDatabase, + opts: { + ownerTenantId: string; + ownerUserId: string; + resourceKind: MarketplaceListingKind; + resourceId: string; + } +): void { + const membership = core + .prepare( + `SELECT role FROM tenant_memberships + WHERE tenant_id=? AND user_id=?` + ) + .get(opts.ownerTenantId, opts.ownerUserId) as + | { role: "viewer" | "editor" | "owner" } + | undefined; + if (!membership || membership.role === "viewer") { + throw new ShareError(403, "Caller cannot share resources from this tenant"); + } + + if ( + opts.resourceKind === "user_calendar" || + opts.resourceKind === "user_tasks" + ) { + if (opts.resourceId !== opts.ownerUserId) { + throw new ShareError(403, "Caller does not own this user resource"); + } + return; + } + + if (opts.resourceKind === "model" || opts.resourceKind === "inference") { + const endpoint = core + .prepare( + `SELECT 1 FROM inference_endpoints + WHERE id=? AND owner_tenant_id=? AND owner_user_id=? AND status='active'` + ) + .get(opts.resourceId, opts.ownerTenantId, opts.ownerUserId); + if (!endpoint) throw new ShareError(404, "Share resource not found"); + return; + } + + try { + exportEntity( + getTenantDb(opts.ownerTenantId), + opts.resourceKind, + opts.resourceId + ); + } catch { + throw new ShareError(404, "Share resource not found"); + } +} + export function createShareGrant( core: CoreDatabase, opts: { @@ -34,20 +175,37 @@ export function createShareGrant( bridgeUrl?: string | null; /** Federation token the grantee presents to the owner's Bridge. */ federationToken?: string | null; + expiresAt?: string | null; } ): string { if (!opts.granteeUserId && !opts.granteeTenantId) { throw new ShareError(400, "grantee user or tenant required"); } + if ( + opts.role !== undefined && + !Object.prototype.hasOwnProperty.call(ROLE_RANK, opts.role) + ) { + throw new ShareError(400, "invalid share role"); + } + assertGrantAuthority(core, opts); const id = uuidv4(); - const bridgeUrl = opts.bridgeUrl ?? config.auth.publicUrl; - const federationToken = opts.federationToken ?? uuidv4(); + const federationToken = + opts.federationToken === undefined ? null : opts.federationToken; + const bridgeUrl = + federationToken && opts.bridgeUrl === undefined + ? config.auth.publicUrl + : (opts.bridgeUrl ?? null); + const expiresAt = federationToken + ? (opts.expiresAt ?? + new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString()) + : (opts.expiresAt ?? null); void getLocalConnection(core, opts.ownerTenantId); core.prepare( `INSERT INTO share_grants (id, owner_tenant_id, owner_user_id, resource_kind, resource_id, - grantee_user_id, grantee_tenant_id, role, bridge_url, federation_token) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + grantee_user_id, grantee_tenant_id, role, bridge_url, federation_token, + expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ).run( id, opts.ownerTenantId, @@ -58,7 +216,8 @@ export function createShareGrant( opts.granteeTenantId ?? null, opts.role ?? "viewer", bridgeUrl, - federationToken + federationToken, + expiresAt ); emitEvent( { @@ -129,10 +288,16 @@ export function resolveShareAccess( minRole?: ShareGrantRole; } ): { ownerTenantId: string; role: ShareGrantRole; db: AppDatabase } | null { + const hasExpiresAt = ( + core.prepare(`PRAGMA table_info(share_grants)`).all() as Array<{ + name: string; + }> + ).some((column) => column.name === "expires_at"); const grants = core .prepare( `SELECT * FROM share_grants WHERE resource_kind=? AND resource_id=? + ${hasExpiresAt ? "AND (expires_at IS NULL OR expires_at > datetime('now'))" : ""} AND (grantee_user_id=? OR grantee_tenant_id=?)` ) .all(opts.resourceKind, opts.resourceId, opts.userId, opts.tenantId) as Array<{ diff --git a/apps/bridge/src/services/structure-nodes-migration.ts b/apps/bridge/src/services/structure-nodes-migration.ts index 2a93e46..73bd52b 100644 --- a/apps/bridge/src/services/structure-nodes-migration.ts +++ b/apps/bridge/src/services/structure-nodes-migration.ts @@ -44,7 +44,12 @@ export function cleanupProvisionedStructureAgents(db: Database.Database): void { db.transaction(() => { if (hasAssignments) { - db.prepare(`DELETE FROM ai_agent_assignments`).run(); + db.prepare( + `DELETE FROM ai_agent_assignments + WHERE agent_id LIKE 'dept-%' + OR agent_id LIKE 'div-%' + OR agent_id LIKE 'page-%'` + ).run(); } db.prepare(`DELETE FROM ai_agents WHERE id LIKE 'dept-%'`).run(); db.prepare(`DELETE FROM ai_agents WHERE id LIKE 'div-%'`).run(); @@ -56,7 +61,7 @@ export function cleanupProvisionedStructureAgents(db: Database.Database): void { ); } -function migrateStructureNodes(db: Database.Database): void { +export function migrateStructureNodes(db: Database.Database): void { db.exec(` CREATE TABLE IF NOT EXISTS structure_nodes ( id TEXT PRIMARY KEY, @@ -76,11 +81,6 @@ function migrateStructureNodes(db: Database.Database): void { ON structure_nodes(parent_id, sort_order); `); - const existing = ( - db.prepare(`SELECT COUNT(*) AS c FROM structure_nodes`).get() as { c: number } - ).c; - if (existing > 0) return; - const hasLegacy = db .prepare( `SELECT name FROM sqlite_master WHERE type='table' AND name='departments'` @@ -98,6 +98,11 @@ function migrateStructureNodes(db: Database.Database): void { (id, parent_id, label, icon, segment, kind, right_sidebar, agent_id, built_in, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?) `); + const nodeExists = db.prepare(`SELECT 1 FROM structure_nodes WHERE id=?`); + const insertNodeIfMissing = (...values: Parameters): void => { + if (nodeExists.get(values[0])) return; + insertNode.run(...values); + }; const depts = db .prepare( @@ -155,7 +160,7 @@ function migrateStructureNodes(db: Database.Database): void { db.transaction(() => { for (const dept of depts) { const segment = dept.base_path.replace(/^\//, "") || dept.id; - insertNode.run( + insertNodeIfMissing( dept.id, null, dept.label, @@ -173,7 +178,7 @@ function migrateStructureNodes(db: Database.Database): void { const indexPage = divPages.find((p) => p.segment === ""); const kind = indexPage?.page_kind ?? "placeholder"; - insertNode.run( + insertNodeIfMissing( divNodeId, dept.id, div.label, @@ -187,7 +192,7 @@ function migrateStructureNodes(db: Database.Database): void { for (const page of divPages.filter((p) => p.segment !== "")) { const pageNodeId = `${dept.id}-${div.id}-${page.id}`; - insertNode.run( + insertNodeIfMissing( pageNodeId, divNodeId, page.label, @@ -202,9 +207,26 @@ function migrateStructureNodes(db: Database.Database): void { } } - db.prepare(`DELETE FROM ai_agent_assignments`).run(); - db.prepare(`DELETE FROM ai_agents WHERE id LIKE 'dept-%'`).run(); - db.prepare(`DELETE FROM ai_agents WHERE id LIKE 'div-%'`).run(); - db.prepare(`DELETE FROM ai_agents WHERE id LIKE 'page-%'`).run(); + const hasAssignments = db + .prepare( + `SELECT 1 FROM sqlite_master WHERE type='table' AND name='ai_agent_assignments'` + ) + .get(); + if (hasAssignments) { + db.prepare( + `DELETE FROM ai_agent_assignments + WHERE agent_id LIKE 'dept-%' + OR agent_id LIKE 'div-%' + OR agent_id LIKE 'page-%'` + ).run(); + } + const hasAgents = db + .prepare(`SELECT 1 FROM sqlite_master WHERE type='table' AND name='ai_agents'`) + .get(); + if (hasAgents) { + db.prepare(`DELETE FROM ai_agents WHERE id LIKE 'dept-%'`).run(); + db.prepare(`DELETE FROM ai_agents WHERE id LIKE 'div-%'`).run(); + db.prepare(`DELETE FROM ai_agents WHERE id LIKE 'page-%'`).run(); + } })(); } diff --git a/apps/connector/src/index.ts b/apps/connector/src/index.ts index 5ec902b..33fc076 100644 --- a/apps/connector/src/index.ts +++ b/apps/connector/src/index.ts @@ -8,6 +8,10 @@ const port = Number(process.env.CONNECTOR_PORT ?? 3950); const cfg: ConnectorConfig = { bridgeUrl, federationToken: token, + resourceKind: process.env.FEDERATION_RESOURCE_KIND ?? "", + resourceId: process.env.FEDERATION_RESOURCE_ID ?? "", + ownerTenantId: process.env.FEDERATION_OWNER_TENANT_ID ?? "", + targetTenantId: process.env.FEDERATION_TARGET_TENANT_ID ?? "", manifest: { id: process.env.CONNECTOR_ID ?? "generic", title: process.env.CONNECTOR_TITLE ?? "GodMode Local Connector", diff --git a/apps/connector/src/local-connector.ts b/apps/connector/src/local-connector.ts index ab6ed41..0488e1a 100644 --- a/apps/connector/src/local-connector.ts +++ b/apps/connector/src/local-connector.ts @@ -23,6 +23,10 @@ export interface LocalConnector { export interface ConnectorConfig { bridgeUrl: string; federationToken: string; + resourceKind: string; + resourceId: string; + ownerTenantId: string; + targetTenantId: string; manifest: LocalConnectorManifest; } @@ -50,7 +54,14 @@ export class FederationConnectorClient implements LocalConnector { Authorization: `Bearer ${this.cfg.federationToken}`, "Content-Type": "application/json", }, - body: JSON.stringify({ line, chartbookKey }), + body: JSON.stringify({ + line, + chartbookKey, + resourceKind: this.cfg.resourceKind, + resourceId: this.cfg.resourceId, + ownerTenantId: this.cfg.ownerTenantId, + targetTenantId: this.cfg.targetTenantId, + }), }); if (!res.ok) { return { ok: false, detail: `HTTP ${res.status}` }; diff --git a/apps/web/src/__tests__/consumer-cutover.test.ts b/apps/web/src/__tests__/consumer-cutover.test.ts new file mode 100644 index 0000000..fb34476 --- /dev/null +++ b/apps/web/src/__tests__/consumer-cutover.test.ts @@ -0,0 +1,239 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + acceptFederatedShareInvite, + applyCursorToIntelligence, + cloneAiAgent, + connectCursorApiKey, + createAiMemory, + createHook, + deleteProjectCard, + disconnectCursorApiKey, + saveStructureGraphLayout, + selectIntelligenceModel, + uninstallWorkspacePlugin, + updateAiWorkflow, +} from "../api"; + +describe("durable web mutation cutover", () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + localStorage.clear(); + fetchMock.mockReset(); + vi.stubGlobal("fetch", fetchMock); + }); + + it("uses typed record CRUD and restores domain DTOs", async () => { + fetchMock + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: "memory-1", + objectType: "Memory", + data: { text: "Remember this", scope: "global", enabled: true }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ) + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: "workflow-1", + objectType: "Workflow", + data: { name: "Review", enabled: true }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ) + ); + + const memory = await createAiMemory({ text: "Remember this" }); + const workflow = await updateAiWorkflow("workflow-1", { enabled: true }); + + expect(memory).toMatchObject({ id: "memory-1", text: "Remember this" }); + expect(workflow).toMatchObject({ id: "workflow-1", name: "Review" }); + expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([ + "/api/records/Memory", + "/api/records/Workflow/workflow-1", + ]); + }); + + it("uses declared kernel actions without legacy route literals", async () => { + fetchMock + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + result: { + id: "agent-copy", + objectType: "Agent", + data: { name: "Copy", backend: "local" }, + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ) + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: "agent-copy", + name: "Copy", + backend: "local", + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ) + ); + + const agent = await cloneAiAgent("agent-1", "Copy"); + + expect(agent).toMatchObject({ id: "agent-copy", name: "Copy" }); + expect(fetchMock.mock.calls[0][0]).toBe( + "/api/records/Agent/agent-1/actions/clone" + ); + }); + + it("maps hook DTO fields to kernel storage names", async () => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + id: "hook-1", + objectType: "Hook", + data: { name: "Daily", trigger_kind: "schedule" }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ) + ); + + await createHook({ + name: "Daily", + triggerKind: "schedule", + scheduleCron: "0 9 * * *", + actionKind: "notify", + }); + + const request = fetchMock.mock.calls[0][1] as RequestInit; + expect(fetchMock.mock.calls[0][0]).toBe("/api/records/Hook"); + expect(JSON.parse(String(request.body))).toMatchObject({ + data: { + trigger_kind: "schedule", + schedule_cron: "0 9 * * *", + action_kind: "notify", + }, + }); + }); + + it("uses kernel contracts for the formerly unmapped core operations", async () => { + fetchMock + .mockResolvedValueOnce( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ result: { ok: true } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ result: { ok: true, pluginId: "demo" } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + result: { + ok: true, + active: { id: "local:C:/models/demo.gguf", source: "local" }, + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ) + ); + + await deleteProjectCard("card-1"); + await saveStructureGraphLayout({ + version: 1, + positions: {}, + collapsed: [], + }); + await uninstallWorkspacePlugin("demo"); + await selectIntelligenceModel({ + source: "local", + path: "C:/models/demo.gguf", + }); + + expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([ + "/api/records/TaskCard/card-1", + "/api/records/StructureNode/actions/save_layout", + "/api/records/CatalogInstall/actions/uninstall_plugin", + "/api/records/ModelRuntime/runtime/actions/select_model", + ]); + }); + + it("routes Cursor credentials and federation acceptance through kernel contracts", async () => { + fetchMock + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: "cursor-api-key", + objectType: "ProviderCredential", + data: { provider: "cursor", masked_token: "abcd…wxyz" }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ connected: true, source: "vault", masked: "abcd…wxyz" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ connected: false, source: "none" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ result: { ok: true } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ result: { grantId: "grant-1" } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + + expect((await connectCursorApiKey("cursor-secret")).status.connected).toBe(true); + expect((await disconnectCursorApiKey()).status.connected).toBe(false); + await applyCursorToIntelligence("composer-2.5"); + await acceptFederatedShareInvite("invite-secret", "https://owner.example"); + + expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([ + "/api/records/ProviderCredential", + "/api/ai/cursor/status", + "/api/records/ProviderCredential/cursor-api-key", + "/api/ai/cursor/status", + "/api/records/ModelRuntime/runtime/actions/select_model", + "/api/records/FederatedShareInvite/actions/accept", + ]); + expect(JSON.parse(String((fetchMock.mock.calls[4][1] as RequestInit).body))).toMatchObject({ + model_id: "cursor:composer-2.5", + }); + expect(JSON.parse(String((fetchMock.mock.calls[5][1] as RequestInit).body))).toMatchObject({ + invite_token: "invite-secret", + }); + }); +}); diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 5a663ee..f87d32f 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -6,6 +6,14 @@ import { writeSessionToken, writeTenantId, } from "./lib/storage-keys"; +import { + createRecordApi, + deleteRecordApi, + runRecordActionApi, + updateRecordApi, + waitForOperationRun, + type RecordRowClient, +} from "./lib/object-types-api"; const API_BASE = "/api"; @@ -50,234 +58,7 @@ export class ApiError extends Error { } } -interface ApiRewrite { - path: string; - options: RequestInit; - transform?: (payload: unknown) => unknown; -} - -function jsonBody(options?: RequestInit): Record { - if (typeof options?.body !== "string") return {}; - try { - const value = JSON.parse(options.body); - return value && typeof value === "object" - ? (value as Record) - : {}; - } catch { - return {}; - } -} - -function structureNodeData( - body: Record, - parentId?: string | null -): Record { - return { - id: body.id, - parent_id: - parentId !== undefined - ? parentId - : body.parentId === undefined - ? undefined - : body.parentId, - label: body.label, - icon: body.icon, - segment: body.segment, - kind: body.kind ?? body.pageKind, - object_type: body.objectType, - right_sidebar: body.rightSidebar, - }; -} - -function kernelizeStructureMutation( - path: string, - options?: RequestInit -): ApiRewrite { - const method = (options?.method ?? "GET").toUpperCase(); - if (!["POST", "PUT", "DELETE"].includes(method)) { - return { path, options: options ?? {} }; - } - const body = jsonBody(options); - const request = (nextPath: string, nextMethod: string, data?: unknown) => ({ - path: nextPath, - options: { - ...options, - method: nextMethod, - body: data === undefined ? undefined : JSON.stringify(data), - }, - }); - let match: RegExpMatchArray | null; - - if (method === "POST" && path === "/departments") { - return { - ...request("/records/StructureNode", "POST", { - data: structureNodeData(body, null), - }), - transform: (payload) => ({ - id: body.id, - label: body.label, - icon: body.icon, - ...((payload as { data?: object })?.data ?? {}), - }), - }; - } - if ((match = path.match(/^\/departments\/([^/]+)$/))) { - const id = encodeURIComponent(match[1]!); - if (method === "DELETE") { - return request(`/records/StructureNode/${id}`, "DELETE"); - } - return { - ...request(`/records/StructureNode/${id}`, "PUT", { - data: structureNodeData(body), - }), - transform: () => ({ ok: true }), - }; - } - if ( - method === "POST" && - (match = path.match(/^\/departments\/([^/]+)\/divisions$/)) - ) { - const parent = decodeURIComponent(match[1]!); - return { - ...request("/records/StructureNode", "POST", { - data: structureNodeData(body, parent), - }), - transform: (payload) => ({ - id: body.id, - label: body.label, - icon: body.icon, - ...((payload as { data?: object })?.data ?? {}), - }), - }; - } - if ((match = path.match(/^\/divisions\/([^/]+)\/([^/]+)$/))) { - const id = encodeURIComponent( - `${decodeURIComponent(match[1]!)}-${decodeURIComponent(match[2]!)}` - ); - if (method === "DELETE") { - return request(`/records/StructureNode/${id}`, "DELETE"); - } - return { - ...request(`/records/StructureNode/${id}`, "PUT", { - data: structureNodeData(body), - }), - transform: () => ({ ok: true }), - }; - } - if ( - method === "POST" && - (match = path.match(/^\/divisions\/([^/]+)\/([^/]+)\/pages$/)) - ) { - const parent = `${decodeURIComponent(match[1]!)}-${decodeURIComponent( - match[2]! - )}`; - return { - ...request("/records/StructureNode", "POST", { - data: structureNodeData(body, parent), - }), - transform: (payload) => ({ - id: body.id, - ...((payload as { data?: object })?.data ?? {}), - }), - }; - } - if ((match = path.match(/^\/pages\/([^/]+)\/([^/]+)\/([^/]+)$/))) { - const id = encodeURIComponent( - `${decodeURIComponent(match[1]!)}-${decodeURIComponent( - match[2]! - )}-${decodeURIComponent(match[3]!)}` - ); - if (method === "DELETE") { - return request(`/records/StructureNode/${id}`, "DELETE"); - } - return { - ...request(`/records/StructureNode/${id}`, "PUT", { - data: structureNodeData(body), - }), - transform: () => ({ ok: true }), - }; - } - if (method === "POST" && path === "/nodes") { - return { - ...request("/records/StructureNode", "POST", { - data: structureNodeData(body), - }), - transform: (payload) => { - const row = payload as { id?: string; data?: Record }; - return { - id: row.id, - parentId: row.data?.parent_id ?? null, - label: row.data?.label, - icon: row.data?.icon, - segment: row.data?.segment, - kind: row.data?.kind, - objectType: row.data?.object_type ?? null, - rightSidebar: row.data?.right_sidebar ?? null, - agentId: row.data?.agent_id ?? null, - builtIn: row.data?.built_in, - sortOrder: row.data?.sort_order, - tabs: row.data?.tabs_json, - path: row.data?.path, - }; - }, - }; - } - if ((match = path.match(/^\/nodes\/([^/]+)$/))) { - const id = encodeURIComponent(decodeURIComponent(match[1]!)); - if (method === "DELETE") { - return request(`/records/StructureNode/${id}`, "DELETE"); - } - return { - ...request(`/records/StructureNode/${id}`, "PUT", { - data: structureNodeData(body), - }), - transform: () => ({ ok: true }), - }; - } - if ((match = path.match(/^\/nodes\/([^/]+)\/agent$/))) { - const id = encodeURIComponent(decodeURIComponent(match[1]!)); - return { - ...request( - `/records/StructureNode/${id}/actions/set_agent`, - "POST", - { - agent_id: method === "DELETE" ? null : (body.agentId ?? null), - } - ), - transform: () => ({ ok: true }), - }; - } - if (method === "POST" && path === "/structure/reorder") { - let parentId = body.parentId; - let orderedIds = Array.isArray(body.orderedIds) ? body.orderedIds : []; - if (parentId === undefined) { - parentId = - body.kind === "department" - ? null - : body.kind === "division" - ? body.departmentId - : `${body.departmentId}-${body.divisionId}`; - if (typeof parentId === "string" && body.kind !== "department") { - orderedIds = orderedIds.map((id) => - String(id).startsWith(`${parentId}-`) ? id : `${parentId}-${id}` - ); - } - } - return { - ...request("/records/StructureNode/actions/reorder", "POST", { - parent_id: parentId, - ordered_ids: orderedIds, - }), - transform: () => ({ ok: true }), - }; - } - return { path, options: options ?? {} }; -} - export async function api(path: string, options?: RequestInit): Promise { - const rewritten = kernelizeStructureMutation(path, options); - path = rewritten.path; - options = rewritten.options; const tenantId = getActiveTenantId(); const { headers: callerHeaders, ...rest } = options ?? {}; const headers = new Headers(callerHeaders); @@ -322,10 +103,62 @@ export async function api(path: string, options?: RequestInit): Promise { typeof error === "string" ? error : res.statusText ); } - const payload = await res.json(); - return (rewritten.transform - ? rewritten.transform(payload) - : payload) as T; + return (await res.json()) as T; +} + +function rowDto(row: RecordRowClient): T { + return { id: row.id, ...row.data } as T; +} + +async function createDto( + objectType: string, + data: Record +): Promise { + return rowDto(await createRecordApi(objectType, data)); +} + +async function updateDto( + objectType: string, + id: string, + data: Record +): Promise { + return rowDto(await updateRecordApi(objectType, id, data)); +} + +async function deleteDto(objectType: string, id: string): Promise<{ ok: boolean }> { + await deleteRecordApi(objectType, id); + return { ok: true }; +} + +async function actionDto( + objectType: string, + action: string, + input: Record, + id?: string, + confirmed = false +): Promise { + const result = await runRecordActionApi(objectType, action, input, { + id, + confirmed, + idempotencyKey: crypto.randomUUID(), + }); + if ( + result && + typeof result === "object" && + "status" in result && + result.status === "accepted" && + "operationRunId" in result && + typeof result.operationRunId === "string" + ) { + const run = await waitForOperationRun(result.operationRunId); + if (run.status === "failed") { + throw new ApiError(500, run.errorMessage ?? "Kernel action failed", { + code: run.errorCode, + }); + } + return run.result as T; + } + return result as T; } export interface SessionStatus { @@ -496,47 +329,9 @@ export const fetchScCharts = () => chartbooks?: Array<{ chartbookKey: string; name: string; path: string }>; backtestChartbookKey?: string; }>("/sc-charts"); -export const refreshScCharts = () => - api<{ ok: boolean; requestId: string }>("/sc-charts/refresh", { method: "POST" }); -export const selectBacktestCharts = (charts: number[]) => - api<{ ok: boolean; configured: number[] }>("/sc-charts/select", { - method: "POST", - body: JSON.stringify({ charts }), - }); - export const fetchBacktests = () => api("/backtests"); export const fetchBacktestDetail = (id: string) => api<{ run: BacktestRun; trades: BacktestTrade[] }>(`/backtests/${id}`); -export const startBacktest = (body: { - playbookId: string; - simOnly?: boolean; - paramsOverride?: Record; - daysToLoad?: number; - startDate?: string; - endDate?: string; - baseline?: boolean; - chartUpdateIntervalMs?: number; - useContinuousContract?: boolean; - replayMode?: ReplayModeOption; - chartsToReplay?: ChartsToReplayOption; - processingStepSeconds?: number; - replaySpeed?: number; - tradeAccount?: string; -}) => - api<{ runId: string; status: string }>("/backtests", { - method: "POST", - body: JSON.stringify(body), - }); -export const cancelBacktest = (id: string) => - api<{ ok: boolean }>(`/backtests/${id}/cancel`, { method: "POST" }); -export const startBacktestSweep = (body: { - playbookId: string; - axes: SweepParamAxis[]; -}) => - api<{ sweepId: string; runIds: string[] }>("/backtests/sweep", { - method: "POST", - body: JSON.stringify(body), - }); export const fetchSweepRuns = (sweepId: string) => api(`/backtests/sweep/${sweepId}`); @@ -581,15 +376,6 @@ export const fetchOrderLifecycle = (playbookId?: string, limit = 100) => `/order-lifecycle?limit=${limit}${playbookId ? `&playbookId=${encodeURIComponent(playbookId)}` : ""}` ); -export const updateStudySettings = (body: { - playbookId: string; - settings: Array<{ inputIdx: number; inputName?: string; value: number }>; -}) => - api<{ ok: boolean; reloadRequired: boolean }>("/study-settings", { - method: "PUT", - body: JSON.stringify(body), - }); - /* ------------------------------- Intelligence ------------------------------- */ export interface AiModel { @@ -850,10 +636,11 @@ export const fetchAiPromptFlow = (agentId?: string) => { }; export const updateAiPromptFlow = (config: AiPromptFlowConfig, agentId?: string) => - api<{ config: AiPromptFlowConfig; assembled: AiAssembledPrompt }>("/ai/prompt-flow", { - method: "PUT", - body: JSON.stringify({ config, agentId }), - }); + updateDto<{ config: AiPromptFlowConfig; assembled: AiAssembledPrompt }>( + "PromptFlow", + "default", + { config, agent_id: agentId ?? "intelligence" } + ); export const fetchAiMemories = ( chatId?: string, @@ -869,7 +656,7 @@ export const fetchAiMemories = ( }; export const approveAiMemory = (id: string) => - api(`/ai/memories/${id}/approve`, { method: "POST" }); + actionDto("Memory", "approve", {}, id, true).then(rowDto); export const createAiMemory = (body: { text: string; @@ -878,16 +665,22 @@ export const createAiMemory = (body: { category?: string; agentId?: string; }) => - api("/ai/memories", { method: "POST", body: JSON.stringify(body) }); + createDto("Memory", { + text: body.text, + scope: body.scope, + chat_id: body.chatId, + category: body.category, + agent_id: body.agentId, + }); export const updateAiMemory = ( id: string, patch: { text?: string; enabled?: boolean; category?: string } ) => - api(`/ai/memories/${id}`, { method: "PUT", body: JSON.stringify(patch) }); + updateDto("Memory", id, patch); export const deleteAiMemory = (id: string) => - api<{ ok: boolean }>(`/ai/memories/${id}`, { method: "DELETE" }); + deleteDto("Memory", id); export const fetchAiRules = (agentId?: string) => api<{ rules: AiRule[] }>( @@ -898,19 +691,20 @@ export const updateAiRuleState = ( id: string, patch: { enabled?: boolean; priorityOverride?: number | null; agentId?: string } ) => - api<{ rules: AiRule[] }>(`/ai/rules/${id}`, { - method: "PUT", - body: JSON.stringify(patch), - }); + updateDto("Rule", id, { + enabled: patch.enabled, + priority: patch.priorityOverride, + agent_id: patch.agentId, + }).then((rule) => ({ rules: [rule] })); export const approveAiRule = (id: string, agentId?: string) => { - const qs = agentId ? `?agentId=${encodeURIComponent(agentId)}` : ""; - return api<{ rules: AiRule[] }>(`/ai/rules/${id}/approve${qs}`, { method: "POST" }); + return actionDto("Rule", "approve", { agent_id: agentId }, id, true) + .then((row) => ({ rules: [rowDto(row)] })); }; export const rejectAiRule = (id: string, agentId?: string) => { - const qs = agentId ? `?agentId=${encodeURIComponent(agentId)}` : ""; - return api<{ ok: boolean; rules: AiRule[] }>(`/ai/rules/${id}${qs}`, { method: "DELETE" }); + return actionDto<{ ok: boolean }>("Rule", "reject", { agent_id: agentId }, id, true) + .then(() => ({ ok: true, rules: [] })); }; export const fetchAiSkills = (includeBody?: boolean, agentId?: string) => { @@ -926,19 +720,17 @@ export const updateAiSkillState = ( enabled: boolean, agentId?: string ) => - api<{ skills: AiSkill[] }>(`/ai/skills/${id}`, { - method: "PUT", - body: JSON.stringify({ enabled, agentId }), - }); + updateDto("Skill", id, { enabled, agent_id: agentId }) + .then((skill) => ({ skills: [skill] })); export const approveAiSkill = (id: string, agentId?: string) => { - const qs = agentId ? `?agentId=${encodeURIComponent(agentId)}` : ""; - return api<{ skills: AiSkill[] }>(`/ai/skills/${id}/approve${qs}`, { method: "POST" }); + return actionDto("Skill", "approve", { agent_id: agentId }, id, true) + .then((row) => ({ skills: [rowDto(row)] })); }; export const rejectAiSkill = (id: string, agentId?: string) => { - const qs = agentId ? `?agentId=${encodeURIComponent(agentId)}` : ""; - return api<{ ok: boolean; skills: AiSkill[] }>(`/ai/skills/${id}${qs}`, { method: "DELETE" }); + return actionDto<{ ok: boolean }>("Skill", "reject", { agent_id: agentId }, id, true) + .then(() => ({ ok: true, skills: [] })); }; export const fetchAiArtifacts = (agentId?: string, limit?: number) => { @@ -967,36 +759,48 @@ export const createAiArtifact = (body: { mimeType?: string; description?: string; }) => - api("/ai/artifacts", { method: "POST", body: JSON.stringify(body) }); - -export const deleteAiArtifact = (id: string, agentId?: string) => - api<{ ok: boolean }>( - agentId - ? `/ai/artifacts/${id}?agentId=${encodeURIComponent(agentId)}` - : `/ai/artifacts/${id}`, - { method: "DELETE" } - ); + createDto("Artifact", { + name: body.name, + content: body.content, + agent_id: body.agentId, + kind: body.kind, + mime_type: body.mimeType, + description: body.description, + }); + +export const deleteAiArtifact = (id: string, agentId?: string) => { + void agentId; + return deleteDto("Artifact", id); +}; export const fetchAiCommands = () => api<{ commands: AiChatCommand[] }>("/ai/commands"); export const fetchAiToolsRegistry = (agentId = "intelligence") => api<{ tools: AiToolDef[] }>(`/ai/tools?agentId=${encodeURIComponent(agentId)}`); export const updateAiSettings = (patch: Partial) => - api("/ai/settings", { - method: "PUT", - body: JSON.stringify(patch), - }); + updateDto("IntelligenceSettings", "default", patch); export const startAiModel = (modelPath?: string) => - api("/ai/start", { - method: "POST", - body: JSON.stringify({ modelPath }), - }); -export const stopAiModel = () => api("/ai/stop", { method: "POST" }); + modelPath + ? actionDto( + "ModelRuntime", + "select_model", + { model_id: `local:${modelPath}` }, + "runtime", + true + ).then(fetchAiStatus) + : actionDto("ModelRuntime", "start", {}, "runtime", true); +export const stopAiModel = () => + actionDto("ModelRuntime", "stop", {}, "runtime", true); export const restartAiModel = (modelPath?: string) => - api("/ai/restart", { - method: "POST", - body: JSON.stringify({ modelPath }), - }); + modelPath + ? actionDto( + "ModelRuntime", + "select_model", + { model_id: `local:${modelPath}` }, + "runtime", + true + ).then(fetchAiStatus) + : actionDto("ModelRuntime", "restart", {}, "runtime", true); /* --------------------------- EMBEDDING ENGINE --------------------------- */ @@ -1049,20 +853,23 @@ export const fetchEmbeddingStatus = () => export const fetchEmbeddingActivity = () => api("/ai/embeddings/activity"); export const setEmbeddingEnabled = (enabled: boolean) => - api("/ai/embeddings/enabled", { - method: "POST", - body: JSON.stringify({ enabled }), - }); + actionDto( + "EmbeddingRuntime", + "set_enabled", + { enabled }, + "runtime", + true + ); export const startEmbeddingEngine = () => - api("/ai/embeddings/start", { method: "POST" }); + actionDto("EmbeddingRuntime", "start", {}, "runtime", true); export const stopEmbeddingEngine = () => - api("/ai/embeddings/stop", { method: "POST" }); + actionDto("EmbeddingRuntime", "stop", {}, "runtime", true); export const fetchAiChats = () => api("/ai/chats"); export const createAiChat = (title?: string) => - api("/ai/chats", { method: "POST", body: JSON.stringify({ title }) }); + createDto("ChatSession", { title }); export const deleteAiChat = (id: string) => - api<{ ok: boolean }>(`/ai/chats/${id}`, { method: "DELETE" }); + deleteDto("ChatSession", id); export const fetchAiMessages = (chatId: string) => api(`/ai/chats/${chatId}/messages`); @@ -1084,10 +891,13 @@ export const fetchChatSession = (chatId: string) => /** Promote a chat to a shared session so collaborators can join it live. */ export const startSharedChatSession = (chatId: string, agentId: string) => - api<{ ok: boolean; session: SharedChatSession }>(`/ai/chats/${chatId}/share`, { - method: "POST", - body: JSON.stringify({ agentId }), - }); + actionDto<{ ok: boolean; session: SharedChatSession }>( + "ChatSession", + "share", + { agent_id: agentId }, + chatId, + true + ); export interface AiChatHistoryTurn { role: string; @@ -1371,10 +1181,17 @@ export function runInference(body: { messages: Array<{ role: string; content: string }>; sampling?: Record; }) { - return api("/inference/run", { - method: "POST", - body: JSON.stringify(body), - }); + return actionDto( + "InferenceRuntime", + "run_inference", + { + endpoint_id: body.endpointId, + messages: body.messages, + sampling: body.sampling, + }, + undefined, + true + ); } export interface AiAgent { @@ -1469,10 +1286,11 @@ export interface AiLoraAdapter { } export const confirmAiTool = (toolCallId: string, approved: boolean) => - api<{ ok: boolean }>("/ai/chat/confirm-tool", { - method: "POST", - body: JSON.stringify({ toolCallId, approved }), - }); + actionDto<{ ok: boolean }>( + "ChatSession", + "confirm_tool", + { tool_call_id: toolCallId, approved } + ); export const fetchAiAdapters = () => api<{ adapters: AiAdapter[] }>("/ai/adapters"); export const createAiAdapter = (body: { @@ -1481,13 +1299,23 @@ export const createAiAdapter = (body: { description?: string; domain?: string; defaultScale?: number; -}) => api("/ai/adapters", { method: "POST", body: JSON.stringify(body) }); +}) => createDto("ModelAdapter", { + name: body.name, + path: body.path, + description: body.description, + domain: body.domain, + default_scale: body.defaultScale, +}); export const updateAiAdapter = ( id: string, patch: { enabled?: boolean; defaultScale?: number; description?: string } -) => api(`/ai/adapters/${id}`, { method: "PUT", body: JSON.stringify(patch) }); +) => updateDto("ModelAdapter", id, { + enabled: patch.enabled, + default_scale: patch.defaultScale, + description: patch.description, +}); export const deleteAiAdapter = (id: string) => - api<{ ok: boolean }>(`/ai/adapters/${id}`, { method: "DELETE" }); + deleteDto("ModelAdapter", id); export interface AiDataset { id: string; @@ -1527,7 +1355,12 @@ export const createAiDataset = (body: { domain?: string; path: string; rowCount?: number; -}) => api("/ai/datasets", { method: "POST", body: JSON.stringify(body) }); +}) => actionDto("Dataset", "import_dataset", { + name: body.name, + domain: body.domain, + path: body.path, + row_count: body.rowCount, +}, undefined, true); export interface AiDatasetSource { source: string; @@ -1565,7 +1398,13 @@ export const buildAiDataset = (body: { source: string; chatIds?: string[]; limit?: number; -}) => api("/ai/datasets/build", { method: "POST", body: JSON.stringify(body) }); +}) => actionDto("Dataset", "build_dataset", { + name: body.name, + domain: body.domain, + source: body.source, + chat_ids: body.chatIds, + limit: body.limit, +}, undefined, true); export const fetchAiTrainingJobs = () => api<{ jobs: AiTrainingJob[] }>("/ai/training/jobs"); @@ -1582,25 +1421,26 @@ export const createAiTrainingJob = (body: { learningRate?: number; loraRank?: number; }) => - api<{ id: string; job: AiTrainingJob }>("/ai/training/jobs", { - method: "POST", - body: JSON.stringify(body), - }); + actionDto<{ id: string; job: AiTrainingJob }>("TrainingJob", "enqueue", { + adapter_name: body.adapterName, + domain: body.domain, + description: body.description, + dataset_path: body.datasetPath, + dataset_id: body.datasetId, + base_model: body.baseModel, + epochs: body.epochs, + learning_rate: body.learningRate, + lora_rank: body.loraRank, + }, undefined, true); export const cancelAiTrainingJob = (id: string) => - api<{ ok: boolean }>(`/ai/training/jobs/${id}/cancel`, { method: "POST" }); + actionDto<{ ok: boolean }>("TrainingJob", "cancel", {}, id, true); export const fetchAiLoraAdapters = () => api("/ai/lora-adapters"); -export const updateAiLoraAdapters = (adapters: Array<{ id: number; scale: number }>) => - api("/ai/lora-adapters", { - method: "POST", - body: JSON.stringify(adapters), - }); - export const fetchAiQueue = () => api<{ jobs: AiQueueJob[] }>("/ai/queue"); export const fetchAiAgents = () => api<{ agents: AiAgent[] }>("/ai/agents"); export const fetchAiAgent = (id: string) => api(`/ai/agents/${id}`); -export const createAiAgent = (body: { +export const createAiAgent = async (body: { name: string; description?: string; icon?: string; @@ -1615,16 +1455,54 @@ export const createAiAgent = (body: { modelPath?: string | null; adapterIds?: string[]; config?: Record; -}) => api("/ai/agents", { method: "POST", body: JSON.stringify(body) }); +}) => { + if (body.cloneFromId) { + return cloneAiAgent(body.cloneFromId, body.name); + } + const row = await actionDto("Agent", "create_configured", { + name: body.name, + description: body.description, + icon: body.icon, + backend: body.backend, + system_prompt: body.systemPrompt, + sampling: body.sampling, + thinking: body.thinking, + tool_allow: body.toolAllow, + auto_approve: body.autoApprove, + model_path: body.modelPath, + adapter_ids: body.adapterIds, + config: body.config, + parent_id: body.parentId, + }); + return fetchAiAgent(row.id); +}; export const cloneAiAgent = (id: string, name: string) => - api(`/ai/agents/${id}/clone`, { - method: "POST", - body: JSON.stringify({ name }), - }); -export const updateAiAgent = (id: string, patch: Partial & Record) => - api(`/ai/agents/${id}`, { method: "PUT", body: JSON.stringify(patch) }); + actionDto("Agent", "clone", { name }, id) + .then((row) => fetchAiAgent(row.id)); +export const updateAiAgent = ( + id: string, + patch: Partial & Record +) => { + return actionDto("Agent", "update_config", { + name: patch.name, + description: patch.description, + icon: patch.icon, + backend: patch.backend, + enabled: patch.enabled, + system_prompt: patch.systemPrompt, + sampling: patch.sampling, + thinking: patch.thinking, + tool_allow: patch.toolAllow, + auto_approve: patch.autoApprove, + model_path: patch.modelPath, + adapter_ids: patch.adapterIds, + config: patch.config, + parent_id: patch.parentId, + team: patch.team, + }, id).then(() => fetchAiAgent(id)); +}; export const deleteAiAgent = (id: string) => - api<{ ok: boolean }>(`/ai/agents/${id}`, { method: "DELETE" }); + deleteDto("Agent", id); export interface AgentTypedProfile { // Agent-specific fields @@ -1671,15 +1549,17 @@ export const createAgentApiKeyAccount = ( agentId: string, body: { provider: string; apiKey: string; label?: string } ) => - api<{ account: AiAgentAccount }>(`/ai/agents/${agentId}/accounts/apikey`, { - method: "POST", - body: JSON.stringify(body), - }); + createDto("ProviderCredential", { + agent_id: agentId, + provider: body.provider, + api_key: body.apiKey, + label: body.label, + }).then((account) => ({ account })); -export const revokeAgentAccount = (agentId: string, accountId: string) => - api<{ ok: boolean }>(`/ai/agents/${agentId}/accounts/${accountId}`, { - method: "DELETE", - }); +export const revokeAgentAccount = (agentId: string, accountId: string) => { + void agentId; + return deleteDto("ProviderCredential", accountId); +}; export interface AgentReflectionConfig { enabled: boolean; @@ -1710,15 +1590,22 @@ export const patchAgentReflection = ( agentId: string, patch: Partial ) => - api<{ reflection: AgentReflectionConfig }>(`/ai/agents/${agentId}/reflection`, { - method: "PATCH", - body: JSON.stringify(patch), - }); + actionDto<{ reflection: AgentReflectionConfig }>( + "Agent", + "configure_reflection", + patch as Record, + agentId, + true + ); export const runAgentReflection = (agentId: string) => - api<{ ok: boolean; jobId: string }>(`/ai/agents/${agentId}/reflection/run`, { - method: "POST", - }); + actionDto<{ ok: boolean; jobId: string }>( + "Agent", + "run_reflection", + {}, + agentId, + true + ); export const fetchReflectionProposals = ( agentId: string, @@ -1729,10 +1616,10 @@ export const fetchReflectionProposals = ( ); export const approveReflectionProposal = (id: string) => - api<{ ok: boolean }>(`/ai/reflection/proposals/${id}/approve`, { method: "POST" }); + actionDto<{ ok: boolean }>("ReflectionProposal", "approve", {}, id, true); export const rejectReflectionProposal = (id: string) => - api<{ ok: boolean }>(`/ai/reflection/proposals/${id}/reject`, { method: "POST" }); + actionDto<{ ok: boolean }>("ReflectionProposal", "reject", {}, id, true); export type AiAssignmentRole = "viewer" | "editor" | "owner"; export interface AiAgentAssignment { @@ -1753,14 +1640,24 @@ export const setAgentAssignment = ( scopeId: string, agentId: string | null, role?: AiAssignmentRole -) => - api<{ ok: boolean; assignment: AiAgentAssignment | null }>( - "/ai/agents/assignments", - { - method: "PUT", - body: JSON.stringify({ scopeType, scopeId, agentId, role }), - } - ); +) => { + if (!agentId) { + return deleteRecordApi("AgentAssignment", scopeId).then(() => ({ + ok: true, + assignment: null, + })); + } + return actionDto( + "Agent", + "assign", + { scope_type: scopeType, scope_id: scopeId, role }, + agentId, + true + ).then((row) => ({ + ok: true, + assignment: rowDto(row), + })); +}; export interface PlatformActionLogRow { id: number; @@ -1787,9 +1684,9 @@ export const resolveAgentForPage = (loc: { }; export const fetchAiSecrets = () => api<{ secrets: AiSecret[] }>("/ai/secrets"); export const createAiSecret = (name: string, value: string) => - api("/ai/secrets", { method: "POST", body: JSON.stringify({ name, value }) }); + createDto("VaultSecret", { name, value }); export const deleteAiSecret = (id: string) => - api<{ ok: boolean }>(`/ai/secrets/${id}`, { method: "DELETE" }); + deleteDto("VaultSecret", id); export interface CursorAuthStatus { connected: boolean; @@ -1806,19 +1703,28 @@ export interface CursorModelOption { export const fetchCursorStatus = () => api("/ai/cursor/status"); export const connectCursorApiKey = (apiKey: string) => - api<{ ok: boolean; status: CursorAuthStatus }>("/ai/cursor/api-key", { - method: "POST", - body: JSON.stringify({ apiKey }), - }); + createRecordApi("ProviderCredential", { + agent_id: "intelligence", + provider: "cursor", + label: "Cursor subscription", + api_key: apiKey, + }) + .then(fetchCursorStatus) + .then((status) => ({ ok: true, status })); export const disconnectCursorApiKey = () => - api<{ ok: boolean; status: CursorAuthStatus }>("/ai/cursor/api-key", { method: "DELETE" }); + deleteRecordApi("ProviderCredential", "cursor-api-key") + .then(fetchCursorStatus) + .then((status) => ({ ok: true, status })); export const fetchCursorModels = () => api<{ models: CursorModelOption[] }>("/ai/cursor/models"); export const applyCursorToIntelligence = (model = "auto") => - api<{ ok: boolean }>("/ai/cursor/use-for-intelligence", { - method: "POST", - body: JSON.stringify({ model }), - }); + actionDto<{ ok: boolean }>( + "ModelRuntime", + "select_model", + { model_id: `cursor:${model}` }, + "runtime", + true + ); export type CatalogModelSource = "local" | "cursor" | "provider" | "remote"; @@ -1845,21 +1751,36 @@ export const selectIntelligenceModel = (body: { endpointId?: string; apiKeyRef?: string; }) => - api<{ ok: true; active: CatalogModel }>("/ai/select-model", { - method: "POST", - body: JSON.stringify(body), - }); + actionDto<{ ok: true; active: CatalogModel }>( + "ModelRuntime", + "select_model", + { + model_id: + body.source === "local" + ? `local:${body.path}` + : body.source === "remote" + ? `remote:${body.endpointId}` + : body.source === "cursor" + ? `cursor:${body.model}` + : `provider:${body.provider ?? "openai"}:${body.model}`, + }, + "runtime", + true + ); export const truncateAiChat = (chatId: string, afterMessageId: string) => - api<{ deleted: number }>(`/ai/chats/${chatId}/truncate`, { - method: "POST", - body: JSON.stringify({ afterMessageId }), - }); + actionDto<{ deleted: number }>( + "ChatSession", + "truncate", + { after_message_id: afterMessageId }, + chatId, + true + ); -export const deleteAiChatMessage = (chatId: string, messageId: string) => - api<{ ok: boolean }>(`/ai/chats/${chatId}/messages/${messageId}`, { - method: "DELETE", - }); +export const deleteAiChatMessage = (chatId: string, messageId: string) => { + void chatId; + return deleteDto("ChatMessage", messageId); +}; export interface AiRepoMentionPath { path: string; @@ -1877,19 +1798,30 @@ export const enqueueAiJob = (body: { priority?: number; context?: Record; adapterIds?: string[]; -}) => api("/ai/queue", { method: "POST", body: JSON.stringify(body) }); +}) => actionDto("PromptQueueJob", "enqueue", { + prompt: body.prompt, + workflow_id: body.workflowId, + priority: body.priority, + context: body.context, + adapter_ids: body.adapterIds, +}, undefined, true); export const cancelAiQueueJob = (id: string) => - api<{ ok: boolean }>(`/ai/queue/${id}/cancel`, { method: "POST" }); + actionDto<{ ok: boolean }>("PromptQueueJob", "cancel", {}, id, true); export const fetchAiWorkflows = (agentId = "intelligence") => api<{ workflows: AiWorkflow[] }>( `/ai/workflows?agentId=${encodeURIComponent(agentId)}` ); export const updateAiWorkflow = (id: string, patch: { name?: string; config?: unknown; enabled?: boolean }) => - api(`/ai/workflows/${id}`, { method: "PUT", body: JSON.stringify(patch) }); + updateDto("Workflow", id, { + name: patch.name, + config_json: patch.config, + enabled: patch.enabled, + }); export const createAiWorkflow = (name: string, config?: unknown, agentId = "intelligence") => - api("/ai/workflows", { - method: "POST", - body: JSON.stringify({ name, config: config ?? { nodes: [], edges: [], triggers: [] }, agentId }), + createDto("Workflow", { + name, + config_json: config ?? { nodes: [], edges: [], triggers: [] }, + agent_id: agentId, }); export const fetchActiveAgents = () => @@ -1922,12 +1854,15 @@ export const resumeWorkflowRun = ( decision: "approve" | "request_changes", comments?: string ) => - api<{ ok: boolean }>(`/ai/workflows/runs/${id}/resume`, { - method: "POST", - body: JSON.stringify({ decision, comments }), - }); + actionDto<{ ok: boolean }>( + "WorkflowRun", + "resume", + { decision, comments }, + id, + true + ); export const cancelWorkflowRun = (id: string) => - api<{ ok: boolean }>(`/ai/workflows/runs/${id}/cancel`, { method: "POST" }); + actionDto<{ ok: boolean }>("WorkflowRun", "cancel", {}, id, true); export const fetchAiSchedules = () => api<{ schedules: AiSchedule[] }>("/ai/schedules"); export const createAiSchedule = (body: { @@ -1935,7 +1870,12 @@ export const createAiSchedule = (body: { cronExpr: string; timezone?: string; enabled?: boolean; -}) => api("/ai/schedules", { method: "POST", body: JSON.stringify(body) }); +}) => createDto("Schedule", { + workflow_id: body.workflowId, + cron_expr: body.cronExpr, + timezone: body.timezone, + enabled: body.enabled, +}); export const fetchAiProjects = (agentId = "intelligence") => api(`/ai/projects?agentId=${encodeURIComponent(agentId)}`); export const createProjectCard = (body: { @@ -1953,12 +1893,28 @@ export const createProjectCard = (body: { status?: string; assignedAgentId?: string; }) => - api("/ai/projects/cards", { method: "POST", body: JSON.stringify(body) }); -export const moveProjectCard = (id: string, columnId: string, sortOrder?: number) => - api(`/ai/projects/cards/${id}`, { - method: "PATCH", - body: JSON.stringify({ columnId, sortOrder }), + createDto("TaskCard", { + project_id: body.projectId, + agent_id: body.agentId, + column_id: body.columnId, + title: body.title, + description: body.description, + prompt: body.prompt, + context_json: body.contextJson, + tags_json: body.tags, + due_at: body.dueAt, + priority: body.priority, + parent_card_id: body.parentCardId, + status: body.status, + assigned_agent_id: body.assignedAgentId, }); +export const moveProjectCard = (id: string, columnId: string, sortOrder?: number) => + actionDto( + "TaskCard", + "move", + { column_id: columnId, sort_order: sortOrder }, + id + ).then(rowDto); export const updateProjectCard = ( id: string, patch: { @@ -1976,27 +1932,58 @@ export const updateProjectCard = ( assignedAgentId?: string | null; } ) => - api(`/ai/projects/cards/${id}`, { - method: "PATCH", - body: JSON.stringify(patch), + updateDto("TaskCard", id, { + title: patch.title, + description: patch.description, + prompt: patch.prompt, + context_json: patch.contextJson, + tags_json: patch.tags, + due_at: patch.dueAt, + priority: patch.priority, + parent_card_id: patch.parentCardId, + assigned_agent_id: patch.assignedAgentId, + }).then(async (card) => { + if (patch.columnId) { + card = rowDto( + await actionDto( + "TaskCard", + "move", + { column_id: patch.columnId, sort_order: patch.sortOrder }, + id + ) + ); + } + if (patch.status) { + card = rowDto( + await actionDto( + "TaskCard", + "transition", + { status: patch.status }, + id + ) + ); + } + return card; }); export const deleteProjectCard = (id: string) => - api<{ ok: boolean }>(`/ai/projects/cards/${id}`, { method: "DELETE" }); + deleteDto("TaskCard", id); export const fetchCardSubtasks = (id: string) => api(`/ai/projects/cards/${id}/subtasks`); export const fetchCardComments = (id: string) => api<{ comments: AiCardComment[] }>(`/ai/projects/cards/${id}/comments`); export const addCardComment = (id: string, body: string, author: "user" | "agent" = "user") => - api(`/ai/projects/cards/${id}/comments`, { - method: "POST", - body: JSON.stringify({ body, author }), - }); + actionDto("CardComment", "add_comment", { + card_id: id, + body, + author, + }).then(rowDto); export const fetchWorkflowComments = (id: string) => api<{ comments: AiWorkflowComment[] }>(`/ai/workflows/${id}/comments`); export const addWorkflowComment = (id: string, body: string, author: "user" | "agent" = "user") => - api(`/ai/workflows/${id}/comments`, { - method: "POST", - body: JSON.stringify({ body, author }), + createDto("WorkflowComment", { + workflow_id: id, + body, + author, }); /* ------------------------------ AI CALENDAR ----------------------------- */ @@ -2043,9 +2030,18 @@ export const createCalendarEvent = (body: { linked_run_id?: string; status?: string; }) => - api("/ai/calendar/events", { - method: "POST", - body: JSON.stringify(body), + createDto("CalendarEvent", { + agent_id: body.agentId, + kind: body.kind, + title: body.title, + description: body.description, + start_at: body.start_at, + end_at: body.end_at, + all_day: body.all_day, + location: body.location, + linked_card_id: body.linked_card_id, + linked_run_id: body.linked_run_id, + status: body.status, }); export const updateCalendarEvent = ( @@ -2061,13 +2057,10 @@ export const updateCalendarEvent = ( status?: string; } ) => - api(`/ai/calendar/events/${id}`, { - method: "PATCH", - body: JSON.stringify(patch), - }); + updateDto("CalendarEvent", id, patch); export const deleteCalendarEvent = (id: string) => - api<{ ok: boolean }>(`/ai/calendar/events/${id}`, { method: "DELETE" }); + deleteDto("CalendarEvent", id); export const fetchCalendarActivity = ( agentId = "intelligence", @@ -2111,10 +2104,7 @@ export const createUserCalendarEvent = (body: { linked_run_id?: string; status?: string; }) => - api("/user/calendar/events", { - method: "POST", - body: JSON.stringify(body), - }); + createDto("CalendarEvent", body); export const updateUserCalendarEvent = ( id: string, @@ -2129,13 +2119,10 @@ export const updateUserCalendarEvent = ( status?: string; } ) => - api(`/user/calendar/events/${id}`, { - method: "PATCH", - body: JSON.stringify(patch), - }); + updateDto("CalendarEvent", id, patch); export const deleteUserCalendarEvent = (id: string) => - api<{ ok: boolean }>(`/user/calendar/events/${id}`, { method: "DELETE" }); + deleteDto("CalendarEvent", id); export const fetchUserCalendarActivity = ( range?: { from?: string; to?: string }, @@ -2174,16 +2161,27 @@ export const createUserProjectCard = (body: { status?: string; assignedAgentId?: string; }) => - api("/user/projects/cards", { - method: "POST", - body: JSON.stringify(body), + createDto("TaskCard", { + column_id: body.columnId, + title: body.title, + description: body.description, + prompt: body.prompt, + context_json: body.contextJson, + tags_json: body.tags, + due_at: body.dueAt, + priority: body.priority, + parent_card_id: body.parentCardId, + status: body.status, + assigned_agent_id: body.assignedAgentId, }); export const moveUserProjectCard = (id: string, columnId: string, sortOrder?: number) => - api(`/user/projects/cards/${id}`, { - method: "PATCH", - body: JSON.stringify({ columnId, sortOrder }), - }); + actionDto( + "TaskCard", + "move", + { column_id: columnId, sort_order: sortOrder }, + id + ).then(rowDto); export const updateUserProjectCard = ( id: string, @@ -2202,13 +2200,10 @@ export const updateUserProjectCard = ( assignedAgentId?: string | null; } ) => - api(`/user/projects/cards/${id}`, { - method: "PATCH", - body: JSON.stringify(patch), - }); + updateProjectCard(id, patch); export const deleteUserProjectCard = (id: string) => - api<{ ok: boolean }>(`/user/projects/cards/${id}`, { method: "DELETE" }); + deleteDto("TaskCard", id); export const fetchUserCardSubtasks = (id: string, userId?: string) => { const q = userId ? `?userId=${encodeURIComponent(userId)}` : ""; @@ -2226,11 +2221,8 @@ export const addUserCardComment = ( author: "user" | "agent" = "user", userId?: string ) => { - const q = userId ? `?userId=${encodeURIComponent(userId)}` : ""; - return api(`/user/projects/cards/${id}/comments${q}`, { - method: "POST", - body: JSON.stringify({ body, author }), - }); + void userId; + return addCardComment(id, body, author); }; export function slugifyStructureId(raw: string): string { @@ -2251,9 +2243,12 @@ export async function createStructureDepartment(body: { label: string; icon?: string; }) { - return api<{ id: string; label: string; icon: string }>("/departments", { - method: "POST", - body: JSON.stringify(body), + return createDto<{ id: string; label: string; icon: string }>("StructureNode", { + id: body.id, + parent_id: null, + label: body.label, + icon: body.icon, + kind: "department", }); } @@ -2261,23 +2256,25 @@ export async function updateStructureDepartment( id: string, patch: { label?: string; icon?: string } ) { - return api<{ ok: boolean }>(`/departments/${id}`, { - method: "PUT", - body: JSON.stringify(patch), - }); + await updateRecordApi("StructureNode", id, patch); + return { ok: true }; } export async function deleteStructureDepartment(id: string) { - return api<{ ok: boolean }>(`/departments/${id}`, { method: "DELETE" }); + return deleteDto("StructureNode", id); } export async function createStructureDivision( departmentId: string, body: { id: string; label: string; icon?: string; rightSidebar?: string | null } ) { - return api<{ id: string }>(`/departments/${departmentId}/divisions`, { - method: "POST", - body: JSON.stringify(body), + return createDto<{ id: string }>("StructureNode", { + id: body.id, + parent_id: departmentId, + label: body.label, + icon: body.icon, + kind: "division", + right_sidebar: body.rightSidebar, }); } @@ -2286,16 +2283,16 @@ export async function updateStructureDivision( divisionId: string, patch: { label?: string; icon?: string; rightSidebar?: string | null } ) { - return api<{ ok: boolean }>(`/divisions/${departmentId}/${divisionId}`, { - method: "PUT", - body: JSON.stringify(patch), + await updateRecordApi("StructureNode", `${departmentId}-${divisionId}`, { + label: patch.label, + icon: patch.icon, + right_sidebar: patch.rightSidebar, }); + return { ok: true }; } export async function deleteStructureDivision(departmentId: string, divisionId: string) { - return api<{ ok: boolean }>(`/divisions/${departmentId}/${divisionId}`, { - method: "DELETE", - }); + return deleteDto("StructureNode", `${departmentId}-${divisionId}`); } export async function createStructurePage( @@ -2303,9 +2300,13 @@ export async function createStructurePage( divisionId: string, body: { id: string; label: string; icon?: string; segment?: string } ) { - return api<{ id: string }>(`/divisions/${departmentId}/${divisionId}/pages`, { - method: "POST", - body: JSON.stringify(body), + return createDto<{ id: string }>("StructureNode", { + id: body.id, + parent_id: `${departmentId}-${divisionId}`, + label: body.label, + icon: body.icon, + segment: body.segment, + kind: "page", }); } @@ -2315,10 +2316,12 @@ export async function updateStructurePage( pageId: string, patch: { label?: string; icon?: string; segment?: string } ) { - return api<{ ok: boolean }>(`/pages/${departmentId}/${divisionId}/${pageId}`, { - method: "PUT", - body: JSON.stringify(patch), - }); + await updateRecordApi( + "StructureNode", + `${departmentId}-${divisionId}-${pageId}`, + patch + ); + return { ok: true }; } export async function deleteStructurePage( @@ -2326,9 +2329,7 @@ export async function deleteStructurePage( divisionId: string, pageId: string ) { - return api<{ ok: boolean }>(`/pages/${departmentId}/${divisionId}/${pageId}`, { - method: "DELETE", - }); + return deleteDto("StructureNode", `${departmentId}-${divisionId}-${pageId}`); } /* --------------------- Flattened structure node helpers --------------------- */ @@ -2359,23 +2360,15 @@ export async function createStructureNode(body: { rightSidebar?: string | null; objectType?: string | null; }) { - const row = await api<{ - id: string; - data: Record; - }>("/records/StructureNode", { - method: "POST", - body: JSON.stringify({ - data: { - id: body.id, - parent_id: body.parentId ?? null, - label: body.label, - icon: body.icon ?? "folder", - segment: body.segment, - kind: body.kind, - right_sidebar: body.rightSidebar, - object_type: body.objectType, - }, - }), + const row = await createRecordApi("StructureNode", { + id: body.id, + parent_id: body.parentId ?? null, + label: body.label, + icon: body.icon ?? "folder", + segment: body.segment, + kind: body.kind, + right_sidebar: body.rightSidebar, + object_type: body.objectType, }); return { id: row.id, @@ -2406,31 +2399,23 @@ export async function updateStructureNode( objectType?: string | null; } ) { - return api(`/records/StructureNode/${encodeURIComponent(id)}`, { - method: "PUT", - body: JSON.stringify({ - data: { - ...(patch.label !== undefined ? { label: patch.label } : {}), - ...(patch.icon !== undefined ? { icon: patch.icon } : {}), - ...(patch.segment !== undefined ? { segment: patch.segment } : {}), - ...(patch.kind !== undefined ? { kind: patch.kind } : {}), - ...(patch.rightSidebar !== undefined - ? { right_sidebar: patch.rightSidebar } - : {}), - ...(patch.parentId !== undefined ? { parent_id: patch.parentId } : {}), - ...(patch.objectType !== undefined - ? { object_type: patch.objectType } - : {}), - }, - }), + return updateRecordApi("StructureNode", id, { + ...(patch.label !== undefined ? { label: patch.label } : {}), + ...(patch.icon !== undefined ? { icon: patch.icon } : {}), + ...(patch.segment !== undefined ? { segment: patch.segment } : {}), + ...(patch.kind !== undefined ? { kind: patch.kind } : {}), + ...(patch.rightSidebar !== undefined + ? { right_sidebar: patch.rightSidebar } + : {}), + ...(patch.parentId !== undefined ? { parent_id: patch.parentId } : {}), + ...(patch.objectType !== undefined + ? { object_type: patch.objectType } + : {}), }); } export async function deleteStructureNode(id: string) { - return api<{ ok: boolean }>( - `/records/StructureNode/${encodeURIComponent(id)}`, - { method: "DELETE" } - ); + return deleteDto("StructureNode", id); } /** Move a node under a new parent (or to top-level with `null`). */ @@ -2442,9 +2427,9 @@ export async function reorderStructureNodes( parentId: string | null, orderedIds: string[] ) { - return api<{ ok: boolean }>("/structure/reorder", { - method: "POST", - body: JSON.stringify({ parentId, orderedIds }), + return actionDto<{ ok: boolean }>("StructureNode", "reorder", { + parent_id: parentId, + ordered_ids: orderedIds, }); } @@ -2463,21 +2448,14 @@ export async function fetchStructureGraph() { } export async function saveStructureGraphLayout(layout: StructureGraphLayoutDto) { - return api<{ ok: boolean }>("/structure/graph/layout", { - method: "PUT", - body: JSON.stringify({ layout }), - }); + return actionDto<{ ok: boolean }>("StructureNode", "save_layout", { layout }); } /** Attach (or detach with `null`) an agent on a structure node. */ export async function setNodeAgent(nodeId: string, agentId: string | null) { - if (agentId) { - return api<{ ok: boolean }>(`/nodes/${nodeId}/agent`, { - method: "POST", - body: JSON.stringify({ agentId }), - }); - } - return api<{ ok: boolean }>(`/nodes/${nodeId}/agent`, { method: "DELETE" }); + return actionDto<{ ok: boolean }>("StructureNode", "set_agent", { + agent_id: agentId, + }, nodeId); } export function connectWebSocket(onMessage: (data: unknown) => void): () => void { @@ -2623,21 +2601,24 @@ export interface AdminUpdateUserInput { } export function createAdminUser(input: AdminCreateUserInput) { - return api<{ user: AdminUserRow }>("/admin/users", { - method: "POST", - body: JSON.stringify(input), - }); + return actionDto("User", "create_account", { + email: input.email, + password: input.password, + display_name: input.displayName, + is_admin: input.isAdmin, + }, undefined, true).then((row) => ({ user: rowDto(row) })); } export function updateAdminUser(userId: string, input: AdminUpdateUserInput) { - return api<{ user: AdminUserRow }>(`/admin/users/${userId}`, { - method: "PATCH", - body: JSON.stringify(input), - }); + return updateDto("User", userId, { + email: input.email, + display_name: input.displayName, + is_admin: input.isAdmin, + }).then((user) => ({ user })); } export function deleteAdminUser(userId: string) { - return api<{ ok: boolean }>(`/admin/users/${userId}`, { method: "DELETE" }); + return deleteDto("User", userId); } export function createAdminTenantForUser( @@ -2645,30 +2626,33 @@ export function createAdminTenantForUser( name: string, slug?: string ) { - return api<{ tenant: { id: string; name: string; slug: string }; user: AdminUserRow }>( - `/admin/users/${userId}/tenants`, - { - method: "POST", - body: JSON.stringify({ name, slug }), + return createDto<{ id: string; name: string; slug: string }>("Tenant", { + name, + slug, + owner_user_id: userId, + }).then(async (tenant) => { + const { users } = await fetchUsers(); + const user = users.find((candidate) => candidate.id === userId); + if (!user) { + throw new ApiError(404, "Tenant owner was not returned after provisioning"); } - ); + return { tenant, user }; + }); } export function updateAdminTenant( tenantId: string, input: { name?: string; slug?: string } ) { - return api<{ tenant: { id: string; name: string; slug: string; isOperator: boolean } }>( - `/admin/tenants/${tenantId}`, - { - method: "PATCH", - body: JSON.stringify(input), - } - ); + return updateDto<{ id: string; name: string; slug: string; isOperator: boolean }>( + "Tenant", + tenantId, + input + ).then((tenant) => ({ tenant })); } export function deleteAdminTenant(tenantId: string) { - return api<{ ok: boolean }>(`/admin/tenants/${tenantId}`, { method: "DELETE" }); + return deleteDto("Tenant", tenantId); } export function fetchAuthTenants() { @@ -2676,10 +2660,7 @@ export function fetchAuthTenants() { } export function createAuthTenant(name: string, slug?: string) { - return api<{ id: string; slug: string }>("/auth/tenants", { - method: "POST", - body: JSON.stringify({ name, slug }), - }); + return createDto<{ id: string; slug: string }>("Tenant", { name, slug }); } export function logoutAuth() { @@ -2731,10 +2712,33 @@ export function fetchProfile() { } export function updateProfile(patch: UserProfileUpdate) { - return api<{ profile: UserProfile }>("/auth/profile", { - method: "PATCH", - body: JSON.stringify(patch), - }); + return fetchProfile().then(({ profile: current }) => + updateDto("UserProfile", current.id, { + display_name: patch.displayName, + avatar_url: patch.avatarUrl, + headline: patch.headline, + bio: patch.bio, + pronouns: patch.pronouns, + location: patch.location, + timezone: patch.timezone, + phone: patch.phone, + company: patch.company, + job_title: patch.jobTitle, + website: patch.website, + twitter: patch.twitter, + github: patch.github, + linkedin: patch.linkedin, + emoji: patch.emoji, + birthday: patch.birthday, + languages: patch.languages, + interests: patch.interests, + values: patch.values, + goals: patch.goals, + personality_notes: patch.personalityNotes, + decision_style: patch.decisionStyle, + risk_tolerance: patch.riskTolerance, + }).then((profile) => ({ profile })) + ); } /* --------------------------- Bridge connections --------------------------- */ @@ -2763,14 +2767,16 @@ export function createBridgeConnection(input: { remoteBridgeUrl?: string; remoteBridgeToken?: string; }) { - return api<{ connection: BridgeConnection }>("/connections", { - method: "POST", - body: JSON.stringify(input), - }); + return actionDto("BridgeConnection", "register", { + label: input.label, + mode: input.mode, + remote_bridge_url: input.remoteBridgeUrl, + remote_bridge_token: input.remoteBridgeToken, + }, undefined, true).then((row) => ({ connection: rowDto(row) })); } export function deleteBridgeConnection(id: string) { - return api<{ ok: boolean }>(`/connections/${id}`, { method: "DELETE" }); + return deleteDto("BridgeConnection", id); } /* ----------------------------- Marketplace ----------------------------- */ @@ -2876,21 +2882,21 @@ export function fetchUnofficialCatalog() { } export function addCatalogSource(name: string, url: string) { - return api<{ id: string }>("/marketplace/catalog/sources", { - method: "POST", - body: JSON.stringify({ name, url }), - }); + return actionDto<{ id: string }>("CatalogSource", "add", { name, url }); } export function removeCatalogSource(id: string) { - return api<{ ok: boolean }>(`/marketplace/catalog/sources/${id}`, { method: "DELETE" }); + return actionDto<{ ok: boolean }>("CatalogSource", "remove", {}, id, true); } export function installCatalogEntry(entryId: string, sourceCatalog?: string) { - return api>(`/marketplace/catalog/install/${entryId}`, { - method: "POST", - body: JSON.stringify({ sourceCatalog }), - }); + return actionDto>( + "CatalogInstall", + "install_entry", + { entry_id: entryId, source_catalog: sourceCatalog }, + undefined, + true + ); } export function fetchInstalledCatalog() { @@ -2903,38 +2909,44 @@ export function fetchInstalledCatalog() { } export function registerLocalPlugin(path: string) { - return api<{ + return actionDto<{ pluginId: string; pluginRoot: string; name: string; version: string; installed: boolean; built: boolean; - }>("/marketplace/catalog/local-plugins", { - method: "POST", - body: JSON.stringify({ path }), - }); + }>("CatalogInstall", "register_local_plugin", { path }, undefined, true); } export function removeLocalPlugin(path: string) { - return api<{ ok: boolean }>("/marketplace/catalog/local-plugins", { - method: "DELETE", - body: JSON.stringify({ path }), - }); + return actionDto<{ ok: boolean }>( + "CatalogInstall", + "unregister_local_plugin", + { path }, + undefined, + true + ); } export function installWorkspacePlugin(pluginId: string) { - return api<{ ok: boolean; pluginId: string }>("/marketplace/catalog/plugins/install", { - method: "POST", - body: JSON.stringify({ pluginId }), - }); + return actionDto<{ ok: boolean; pluginId: string }>( + "CatalogInstall", + "install_plugin", + { plugin_id: pluginId }, + undefined, + true + ); } export function uninstallWorkspacePlugin(pluginId: string) { - return api<{ ok: boolean; pluginId: string }>("/marketplace/catalog/plugins/uninstall", { - method: "POST", - body: JSON.stringify({ pluginId }), - }); + return actionDto<{ ok: boolean; pluginId: string }>( + "CatalogInstall", + "uninstall_plugin", + { plugin_id: pluginId }, + undefined, + true + ); } export function fetchNetworkStatus() { @@ -2942,9 +2954,10 @@ export function fetchNetworkStatus() { } export function enableTailscaleFederation() { - return api<{ federationUrl: string | null; error?: string }>("/network/tailscale/enable", { - method: "POST", - }); + return actionDto<{ + federationUrl: string | null; + error?: string; + }>("PeerConnection", "enable_tailscale", {}, undefined, true); } export function fetchNetworkPeers() { @@ -2952,35 +2965,29 @@ export function fetchNetworkPeers() { } export function inviteNetworkPeer(email: string, remoteBridgeUrl?: string) { - return api<{ inviteId: string }>("/network/peers/invite", { - method: "POST", - body: JSON.stringify({ email, remoteBridgeUrl }), - }); + return actionDto<{ inviteId: string }>("PeerConnection", "invite", { + email, + remote_bridge_url: remoteBridgeUrl, + }, undefined, true); } export function refreshNetworkPeers() { - return api<{ peers: Array> }>("/network/peers/refresh", { - method: "POST", - }); -} - -export function createFederatedShareInvite(body: { - resourceKind: string; - resourceId: string; - inviteeEmail: string; - role?: string; -}) { - return api<{ inviteId: string; inviteToken: string; inviteUrl: string }>( - "/network/share-invites", - { method: "POST", body: JSON.stringify(body) } + return actionDto<{ peers: Array> }>( + "PeerConnection", + "refresh_health", + {} ); } export function acceptFederatedShareInvite(inviteToken: string, ownerBridgeUrl: string) { - return api>("/network/share-invites/accept", { - method: "POST", - body: JSON.stringify({ inviteToken, ownerBridgeUrl }), - }); + void ownerBridgeUrl; + return actionDto>( + "FederatedShareInvite", + "accept", + { invite_token: inviteToken }, + undefined, + true + ); } export function fetchBridgeHealth() { @@ -3005,18 +3012,31 @@ export function fetchOnboardingDetect() { } export function startOnboardingLocalLlm(modelPath: string) { - return api<{ ok: boolean }>("/onboarding/llm/local", { - method: "POST", - body: JSON.stringify({ modelPath }), - }); + return actionDto( + "ModelRuntime", + "select_model", + { model_id: `local:${modelPath}` }, + "runtime", + true + ).then(() => ({ ok: true })); } export function markOnboardingCloudReady() { - return api<{ ok: boolean }>("/onboarding/llm/cloud-ready", { method: "POST" }); + return actionDto( + "TenantOnboardingConfig", + "mark_llm_ready", + {}, + getActiveTenantId() ?? undefined + ).then(() => ({ ok: true })); } export function completeOnboarding() { - return api<{ ok: boolean }>("/onboarding/complete", { method: "POST" }); + return actionDto( + "TenantOnboardingConfig", + "complete", + {}, + getActiveTenantId() ?? undefined + ).then(() => ({ ok: true })); } export function fetchMarketplaceWallet() { @@ -3036,38 +3056,6 @@ export function fetchMarketplaceBillingConfig() { return api("/marketplace/billing/config"); } -export interface MarketplaceCheckoutResult { - mode: "stripe" | "dev"; - clientSecret?: string; - id?: string; - usdCents?: number; - credits: number; - publishableKey?: string | null; -} - -export function checkoutMarketplaceCredits(usdCents: number) { - return api("/marketplace/wallet/checkout", { - method: "POST", - body: JSON.stringify({ usdCents }), - }); -} - -export function confirmMarketplacePurchase(opts: { - amount: number; - paymentIntentId?: string; - usdCents?: number; -}) { - return api<{ balance: number }>("/marketplace/wallet/purchase", { - method: "POST", - body: JSON.stringify(opts), - }); -} - -/** @deprecated Use checkoutMarketplaceCredits + confirmMarketplacePurchase */ -export function purchaseMarketplaceCredits(amount: number) { - return confirmMarketplacePurchase({ amount }); -} - export interface PlatformBillingConfig { configured: boolean; publishableKey: string | null; @@ -3084,16 +3072,27 @@ export function updateAdminBillingConfig(body: { publishableKey?: string; creditsPerUsd?: number; }) { - return api("/admin/billing", { - method: "PUT", - body: JSON.stringify(body), - }); + return actionDto( + "PlatformBillingConfig", + "configure", + { + secret_key: body.secretKey, + publishable_key: body.publishableKey, + credits_per_usd: body.creditsPerUsd, + }, + "platform-billing", + true + ).then(rowDto); } export function testAdminBillingConnection() { - return api<{ ok: boolean; detail?: string }>("/admin/billing/test", { - method: "POST", - }); + return actionDto<{ ok: boolean; detail?: string }>( + "PlatformBillingConfig", + "test_connection", + {}, + "platform-billing", + true + ); } export interface WorkspaceTemplateNode { @@ -3132,10 +3131,21 @@ export function createMarketplaceListing(body: { inferenceEndpointId?: string; bundleChildren?: unknown[]; }) { - return api<{ id: string }>("/marketplace/listings", { - method: "POST", - body: JSON.stringify(body), - }); + return actionDto<{ id: string }>("MarketplaceListing", "publish", { + kind: body.kind, + resource_id: body.resourceId, + title: body.title, + description: body.description, + price_credits: body.priceCredits, + delivery_mode: body.deliveryMode, + pricing_model: body.pricingModel, + price_period: body.pricePeriod, + meter_unit: body.meterUnit, + meter_rate: body.meterRate, + license: body.license, + inference_endpoint_id: body.inferenceEndpointId, + bundle_children: body.bundleChildren, + }, undefined, true); } export function fetchMyMarketplaceListings() { @@ -3147,9 +3157,13 @@ export function fetchMarketplaceEntitlements() { } export function cancelMarketplaceEntitlement(entitlementId: string) { - return api<{ ok: boolean }>(`/marketplace/entitlements/${entitlementId}/cancel`, { - method: "POST", - }); + return actionDto<{ ok: boolean }>( + "MarketplaceEntitlement", + "cancel", + {}, + entitlementId, + true + ); } export function fetchInferenceEndpoints() { @@ -3164,35 +3178,42 @@ export function createInferenceEndpoint(body: { meterRate?: number; capacityHint?: number; }) { - return api<{ id: string }>("/marketplace/inference/endpoints", { - method: "POST", - body: JSON.stringify(body), - }); + return actionDto<{ id: string }>("InferenceEndpoint", "publish", { + name: body.name, + base_model_path: body.baseModelPath, + adapter_ids_json: body.adapterIds, + meter_unit: body.meterUnit, + meter_rate: body.meterRate, + capacity_hint: body.capacityHint, + }, undefined, true); } export function acquireMarketplaceListing(listingId: string) { - return api<{ + return actionDto<{ ok: boolean; mode: "clone" | "live"; import?: { kind: string; newId: string }; entitlementId?: string; shareGrantId?: string; balance: number; - }>(`/marketplace/listings/${listingId}/acquire`, { method: "POST" }); + }>("MarketplaceListing", "acquire", {}, listingId, true); } export function exportPortableEntity(kind: string, resourceId: string) { - return api<{ bundle: unknown }>("/marketplace/export", { - method: "POST", - body: JSON.stringify({ kind, resourceId }), + return actionDto<{ bundle: unknown }>("MarketplaceListing", "export_portable", { + kind, + resource_id: resourceId, }); } export function importPortableBundle(bundle: unknown) { - return api<{ ok: boolean; kind: string; newId: string }>("/marketplace/import", { - method: "POST", - body: JSON.stringify({ bundle }), - }); + return actionDto<{ ok: boolean; kind: string; newId: string }>( + "MarketplaceListing", + "import_portable", + { bundle } as Record, + undefined, + true + ); } /* ----------------------------- Sharing ----------------------------- */ @@ -3258,14 +3279,17 @@ export function createShareGrant(body: { granteeTenantId?: string; role?: string; }) { - return api<{ id: string }>("/shares/", { - method: "POST", - body: JSON.stringify(body), - }); + return actionDto<{ id: string }>("ShareGrant", "grant", { + resource_kind: body.resourceKind, + resource_id: body.resourceId, + grantee_user_id: body.granteeUserId, + grantee_tenant_id: body.granteeTenantId, + role: body.role, + }, undefined, true); } export function revokeShareGrant(grantId: string) { - return api<{ ok: boolean }>(`/shares/${grantId}`, { method: "DELETE" }); + return actionDto<{ ok: boolean }>("ShareGrant", "revoke", {}, grantId, true); } /** A model shared with the current user for FREE inference. */ @@ -3285,10 +3309,18 @@ export function shareModel(body: { granteeEmail?: string; name?: string; }) { - return api<{ id: string; endpointId: string }>("/shares/model", { - method: "POST", - body: JSON.stringify(body), - }); + return actionDto<{ id: string; endpointId: string }>( + "ShareGrant", + "share_model", + { + model_path: body.modelPath, + grantee_user_id: body.granteeUserId, + grantee_email: body.granteeEmail, + name: body.name, + }, + undefined, + true + ); } /** Models shared WITH me (incoming free `model` grants). */ @@ -3297,9 +3329,12 @@ export function fetchSharedModels() { } export function cloneSharedResource(kind: string, resourceId: string) { - return api<{ ok: boolean; kind: string; newId: string }>( - `/shares/clone/${kind}/${encodeURIComponent(resourceId)}`, - { method: "POST" } + return actionDto<{ ok: boolean; kind: string; newId: string }>( + "ShareGrant", + "clone_shared", + { kind, resource_id: resourceId }, + undefined, + true ); } @@ -3420,10 +3455,11 @@ export function createDmConversation(body: { memberEmails?: string[]; memberAgents?: Array<{ agentId: string; agentTenantId?: string }>; }) { - return api<{ conversation: DmConversation }>("/dm/conversations", { - method: "POST", - body: JSON.stringify(body), - }); + return actionDto("DirectConversation", "start", { + kind: body.kind, + title: body.title, + member_user_ids: body.memberUserIds, + }).then((row) => ({ conversation: rowDto(row) })); } export function fetchDmMessages( @@ -3443,29 +3479,34 @@ export function sendDmMessage( conversationId: string, body: { bodyText?: string; attachments?: DmAttachmentInput[] } ) { - return api<{ message: DmMessage }>( - `/dm/conversations/${conversationId}/messages`, - { method: "POST", body: JSON.stringify(body) } - ); + return actionDto("DirectMessage", "send", { + conversation_id: conversationId, + body_text: body.bodyText, + attachments: body.attachments, + }).then((row) => ({ message: rowDto(row) })); } export function markDmConversationRead( conversationId: string, messageId?: string ) { - return api<{ ok: boolean }>(`/dm/conversations/${conversationId}/read`, { - method: "POST", - body: JSON.stringify({ messageId }), - }); + return actionDto<{ ok: boolean }>( + "DirectConversation", + "mark_read", + { message_id: messageId }, + conversationId + ); } export function addDmConversationMember( conversationId: string, body: { userId?: string; email?: string } ) { - return api<{ member: DmConversationMember }>( - `/dm/conversations/${conversationId}/members`, - { method: "POST", body: JSON.stringify(body) } + return actionDto<{ member: DmConversationMember }>( + "DirectConversation", + "add_member", + { user_id: body.userId }, + conversationId ); } @@ -3473,9 +3514,12 @@ export function removeDmConversationMember( conversationId: string, userId: string ) { - return api<{ ok: boolean }>( - `/dm/conversations/${conversationId}/members/${userId}`, - { method: "DELETE" } + return actionDto<{ ok: boolean }>( + "DirectConversation", + "remove_member", + { user_id: userId }, + conversationId, + true ); } @@ -3487,12 +3531,13 @@ export function shareDmResource( role?: "viewer" | "editor" | "owner"; } ) { - return api<{ + return actionDto<{ grants: Array<{ granteeUserId: string; grantId: string }>; - }>(`/dm/conversations/${conversationId}/share`, { - method: "POST", - body: JSON.stringify(body), - }); + }>("DirectConversation", "share", { + resource_kind: body.resourceKind, + resource_id: body.resourceId, + role: body.role, + }, conversationId, true); } export function sendDmTyping(conversationId: string) { @@ -3554,23 +3599,28 @@ export function fetchNotificationUnreadCount() { } export function markNotificationsRead(input: { ids?: string[]; all?: boolean }) { - return api<{ updated: number; unreadCount: number }>("/notifications/read", { - method: "POST", - body: JSON.stringify(input), - }); + if (input.all) { + return actionDto<{ changed: number }>( + "Notification", + "mark_all_read", + {} + ).then(({ changed }) => ({ updated: changed, unreadCount: 0 })); + } + return Promise.all( + (input.ids ?? []).map((id) => + actionDto("Notification", "mark_read", {}, id) + ) + ).then((rows) => ({ updated: rows.length, unreadCount: 0 })); } export function deleteNotification(id: string) { - return api<{ ok: boolean; unreadCount: number }>(`/notifications/${id}`, { - method: "DELETE", - }); + return deleteDto("Notification", id).then(() => ({ ok: true, unreadCount: 0 })); } export function clearNotifications(input: { readOnly?: boolean } = {}) { - return api<{ deleted: number; unreadCount: number }>("/notifications/clear", { - method: "POST", - body: JSON.stringify(input), - }); + return actionDto<{ deleted: number }>("Notification", "clear", { + read_only: input.readOnly, + }).then(({ deleted }) => ({ deleted, unreadCount: 0 })); } /* ----------------------------- Automations (Hooks) ----------------------------- */ @@ -3643,21 +3693,39 @@ export function fetchHooks() { } export function createHook(body: CreateHookBody) { - return api<{ hook: Hook }>("/hooks/", { - method: "POST", - body: JSON.stringify(body), - }); + return createDto("Hook", { + owner_kind: body.ownerKind, + owner_id: body.ownerId, + name: body.name, + enabled: body.enabled, + trigger_kind: body.triggerKind, + event_type: body.eventType, + schedule_cron: body.scheduleCron, + condition_json: body.conditionJson, + action_kind: body.actionKind, + action_config_json: body.actionConfigJson, + require_approval: body.requireApproval, + }).then((hook) => ({ hook })); } export function updateHook(id: string, body: Partial) { - return api<{ hook: Hook }>(`/hooks/${id}`, { - method: "PATCH", - body: JSON.stringify(body), - }); + return updateDto("Hook", id, { + owner_kind: body.ownerKind, + owner_id: body.ownerId, + name: body.name, + enabled: body.enabled, + trigger_kind: body.triggerKind, + event_type: body.eventType, + schedule_cron: body.scheduleCron, + condition_json: body.conditionJson, + action_kind: body.actionKind, + action_config_json: body.actionConfigJson, + require_approval: body.requireApproval, + }).then((hook) => ({ hook })); } export function deleteHook(id: string) { - return api<{ ok: boolean }>(`/hooks/${id}`, { method: "DELETE" }); + return deleteDto("Hook", id); } export function fetchHookRuns(id: string) { @@ -3665,11 +3733,11 @@ export function fetchHookRuns(id: string) { } export function approveHookRun(runId: string) { - return api<{ ok: boolean }>(`/hooks/runs/${runId}/approve`, { method: "POST" }); + return actionDto<{ ok: boolean }>("HookRun", "approve", {}, runId, true); } export function rejectHookRun(runId: string) { - return api<{ ok: boolean }>(`/hooks/runs/${runId}/reject`, { method: "POST" }); + return actionDto<{ ok: boolean }>("HookRun", "reject", {}, runId, true); } export function fetchEvents(limit?: number) { @@ -3721,14 +3789,12 @@ export function createSupportTicket(body: { targetKind?: "platform_github" | "platform_admin" | "resource_owner"; sharedGrantId?: string | null; ownerUserId?: string | null; -}) { - return api<{ ticket?: SupportTicket; redirectUrl?: string; kind?: string }>( - "/support/tickets", - { - method: "POST", - body: JSON.stringify(body), - } - ); +}): Promise<{ ticket?: SupportTicket; redirectUrl?: string; kind?: string }> { + return actionDto("SupportTicket", "open", { + subject: body.subject, + body: body.body, + category: body.category, + }).then((row) => ({ ticket: rowDto(row) })); } export function fetchOwnerSupportTickets() { @@ -3751,10 +3817,10 @@ export function fetchSupportTicket(id: string) { } export function postSupportMessage(id: string, body: string) { - return api<{ message: SupportMessage }>(`/support/tickets/${id}/messages`, { - method: "POST", - body: JSON.stringify({ body }), - }); + return actionDto("SupportMessage", "reply", { + ticket_id: id, + body, + }).then((row) => ({ message: rowDto(row) })); } export function fetchSupportGroup() { @@ -3771,10 +3837,12 @@ export function addSupportGroupMember(body: { memberId: string; tenantId?: string | null; }) { - return api<{ member: SupportGroupMember }>("/support/group/members", { - method: "POST", - body: JSON.stringify(body), - }); + return actionDto<{ member: SupportGroupMember }>("PlatformGroupMember", "add", { + group_id: "support", + member_kind: body.memberKind, + member_id: body.memberId, + tenant_id: body.tenantId, + }, undefined, true); } export function removeSupportGroupMember(body: { @@ -3782,10 +3850,12 @@ export function removeSupportGroupMember(body: { memberId: string; tenantId?: string | null; }) { - return api<{ ok: boolean }>("/support/group/members", { - method: "DELETE", - body: JSON.stringify(body), - }); + return actionDto<{ ok: boolean }>("PlatformGroupMember", "remove", { + group_id: "support", + member_kind: body.memberKind, + member_id: body.memberId, + tenant_id: body.tenantId, + }, undefined, true); } export function fetchAdminSupportTickets(status?: SupportTicketStatus) { @@ -3797,10 +3867,8 @@ export function updateAdminSupportTicket( id: string, body: { status?: SupportTicketStatus; priority?: string | null } ) { - return api<{ ticket: SupportTicket }>(`/support/admin/tickets/${id}`, { - method: "PATCH", - body: JSON.stringify(body), - }); + return actionDto("SupportTicket", "set_status", body, id) + .then((row) => ({ ticket: rowDto(row) })); } /* ----------------------------- Wiki ----------------------------- */ @@ -3850,10 +3918,13 @@ export function createWikiPage(body: { visibility?: WikiVisibility; slug?: string; }) { - return api<{ page: WikiPage }>("/wiki/pages", { - method: "POST", - body: JSON.stringify(body), - }); + return createDto("WikiPage", { + title: body.title, + body_markdown: body.bodyMarkdown, + space: body.space, + visibility: body.visibility, + slug: body.slug, + }).then((page) => ({ page })); } export function updateWikiPage( @@ -3865,14 +3936,16 @@ export function updateWikiPage( visibility?: WikiVisibility; } ) { - return api<{ page: WikiPage }>(`/wiki/pages/${id}`, { - method: "PATCH", - body: JSON.stringify(body), - }); + return updateDto("WikiPage", id, { + title: body.title, + body_markdown: body.bodyMarkdown, + space: body.space, + visibility: body.visibility, + }).then((page) => ({ page })); } export function deleteWikiPage(id: string) { - return api<{ ok: boolean }>(`/wiki/pages/${id}`, { method: "DELETE" }); + return deleteDto("WikiPage", id); } export interface WikiPageProposal { @@ -3898,13 +3971,15 @@ export function fetchWikiProposals(status: "pending" | "all" = "pending") { } export function approveWikiProposal(id: string) { - return api<{ ok: boolean; pageId?: string }>(`/wiki/proposals/${id}/approve`, { - method: "POST", - }); + return actionDto<{ ok: boolean; pageId?: string }>( + "WikiProposal", + "approve", + {}, + id, + true + ); } export function rejectWikiProposal(id: string) { - return api<{ ok: boolean }>(`/wiki/proposals/${id}/reject`, { - method: "POST", - }); + return actionDto<{ ok: boolean }>("WikiProposal", "reject", {}, id, true); } diff --git a/apps/web/src/lib/agent-work-api.ts b/apps/web/src/lib/agent-work-api.ts new file mode 100644 index 0000000..d0c71c1 --- /dev/null +++ b/apps/web/src/lib/agent-work-api.ts @@ -0,0 +1,59 @@ +import { + createRecordApi, + deleteRecordApi, + runRecordActionApi, + updateRecordApi, + type RecordRowClient, +} from "./object-types-api"; + +export type AgentWorkObjectType = + | "Agent" + | "AgentAssignment" + | "TaskCard" + | "CardComment" + | "Workflow" + | "WorkflowRun" + | "WorkflowComment" + | "Schedule" + | "CalendarEvent"; + +export function agentWorkDto(row: RecordRowClient): T { + return { id: row.id, ...row.data } as T; +} + +export async function createAgentWorkRecord( + objectType: AgentWorkObjectType, + data: Record +): Promise { + return agentWorkDto(await createRecordApi(objectType, data)); +} + +export async function updateAgentWorkRecord( + objectType: AgentWorkObjectType, + id: string, + data: Record +): Promise { + return agentWorkDto(await updateRecordApi(objectType, id, data)); +} + +export async function deleteAgentWorkRecord( + objectType: AgentWorkObjectType, + id: string +): Promise<{ ok: boolean }> { + await deleteRecordApi(objectType, id); + return { ok: true }; +} + +export function runAgentWorkAction( + objectType: AgentWorkObjectType, + action: string, + input: Record, + id?: string, + confirmed = false +): Promise { + return runRecordActionApi(objectType, action, input, { + id, + confirmed, + idempotencyKey: crypto.randomUUID(), + }) as Promise; +} diff --git a/apps/web/src/lib/api-holdings.ts b/apps/web/src/lib/api-holdings.ts index a308e74..396d790 100644 --- a/apps/web/src/lib/api-holdings.ts +++ b/apps/web/src/lib/api-holdings.ts @@ -1,4 +1,11 @@ import { api } from "../api"; +import { + createRecordApi, + deleteRecordApi, + runRecordActionApi, + waitForOperationRun, + type RecordRowClient, +} from "./object-types-api"; export type HoldingCategory = "bank" | "wallet" | "exchange" | "paypal" | "manual"; @@ -40,6 +47,49 @@ export interface HoldingsListResponse { netWorthCad: number; } +function holdingDto(row: RecordRowClient): HoldingConnection { + return { + id: row.id, + category: row.data.category as HoldingCategory, + provider: String(row.data.provider ?? ""), + label: String(row.data.label ?? ""), + currency: String(row.data.currency ?? "CAD"), + reference: (row.data.reference as string | null) ?? null, + status: row.data.status as HoldingConnection["status"], + externalId: (row.data.external_id as string | null) ?? null, + balance: Number(row.data.balance ?? 0), + balanceCad: Number(row.data.balance_cad ?? 0), + breakdown: row.data.breakdown_json ?? null, + lastSyncedAt: (row.data.last_synced_at as string | null) ?? null, + createdAt: String(row.data.created_at ?? ""), + }; +} + +async function financeAction( + action: string, + input: Record, + id?: string +): Promise { + const result = await runRecordActionApi("FinanceConnection", action, input, { + id, + confirmed: true, + idempotencyKey: crypto.randomUUID(), + }); + if ( + result && + typeof result === "object" && + "status" in result && + result.status === "accepted" && + "operationRunId" in result && + typeof result.operationRunId === "string" + ) { + const run = await waitForOperationRun(result.operationRunId); + if (run.status === "failed") throw new Error(run.errorMessage ?? "Finance action failed"); + return run.result as T; + } + return result as T; +} + export interface CryptoPortfolio { address: string; totalUsd: number; @@ -57,10 +107,7 @@ export function fetchHoldings(): Promise { } export function saveMoralisConfig(apiKey: string): Promise<{ ok: boolean }> { - return api("/financial/config/moralis", { - method: "POST", - body: JSON.stringify({ apiKey }), - }); + return financeAction("configure_moralis", { api_key: apiKey }); } export function savePayPalConfig(body: { @@ -68,9 +115,10 @@ export function savePayPalConfig(body: { clientSecret: string; env: "sandbox" | "live"; }): Promise<{ ok: boolean; env: string }> { - return api("/financial/config/paypal", { - method: "POST", - body: JSON.stringify(body), + return financeAction("configure_paypal", { + client_id: body.clientId, + client_secret: body.clientSecret, + env: body.env, }); } @@ -82,28 +130,33 @@ export function createManualConnection(body: { currency: string; reference?: string; }): Promise { - return api("/financial/connections", { - method: "POST", - body: JSON.stringify(body), - }); + return createRecordApi("FinanceConnection", { + category: body.category, + provider: body.provider, + label: body.label, + balance: body.balance, + balance_cad: body.balance, + currency: body.currency, + reference: body.reference, + status: "active", + }).then(holdingDto); } export function deleteConnection(id: string): Promise<{ ok: boolean; netWorthCad: number }> { - return api(`/financial/connections/${id}`, { method: "DELETE" }); + return deleteRecordApi("FinanceConnection", id) + .then(fetchHoldings) + .then((holdings) => ({ ok: true, netWorthCad: holdings.netWorthCad })); } export function refreshConnection(id: string): Promise { - return api(`/financial/connections/${id}/refresh`, { method: "POST" }); + return financeAction("refresh_external", {}, id).then(holdingDto); } export function previewCryptoBalance( address: string, chains?: string[] ): Promise { - return api("/financial/crypto/balance", { - method: "POST", - body: JSON.stringify({ address, chains }), - }); + return financeAction("preview_crypto", { address, chains }); } export function connectCryptoWallet(body: { @@ -112,9 +165,31 @@ export function connectCryptoWallet(body: { label?: string; chains?: string[]; }): Promise<{ connection: HoldingConnection; portfolio: CryptoPortfolio }> { - return api("/financial/crypto/connect", { - method: "POST", - body: JSON.stringify(body), + return financeAction("connect_external", { + provider: "crypto", + address: body.address, + wallet_provider: body.provider, + label: body.label, + chains: body.chains, + }).then((row) => { + const connection = holdingDto(row); + const tokens = + connection.breakdown && + typeof connection.breakdown === "object" && + "tokens" in connection.breakdown && + Array.isArray(connection.breakdown.tokens) + ? (connection.breakdown.tokens as TokenBreakdown[]) + : []; + return { + connection, + portfolio: { + address: body.address, + totalUsd: connection.balance, + totalCad: connection.balanceCad, + tokens, + chains: body.chains ?? [], + }, + }; }); } @@ -122,9 +197,19 @@ export function connectPayPal(label?: string): Promise<{ connection: HoldingConnection; balance: { total: number; currency: string; totalCad: number }; }> { - return api("/financial/paypal/connect", { - method: "POST", - body: JSON.stringify({ label }), + return financeAction("connect_external", { + provider: "paypal", + label, + }).then((row) => { + const connection = holdingDto(row); + return { + connection, + balance: { + total: connection.balance, + currency: connection.currency, + totalCad: connection.balanceCad, + }, + }; }); } diff --git a/apps/web/src/lib/coding-agent-api.ts b/apps/web/src/lib/coding-agent-api.ts new file mode 100644 index 0000000..10143c3 --- /dev/null +++ b/apps/web/src/lib/coding-agent-api.ts @@ -0,0 +1,54 @@ +import { + runRecordActionApi, + type OperationRunClient, + type RecordRowClient, + waitForOperationRun, +} from "./object-types-api"; + +export type CodingAgentObjectType = + | "ChatSession" + | "ModelRuntime" + | "EmbeddingRuntime" + | "CapabilityIndex" + | "PromptQueueJob" + | "Dataset" + | "MemoryMaintenance" + | "AutonomousRuntime" + | "TrainingJob" + | "InferenceRuntime"; + +export interface CodingAgentActionOptions { + id?: string; + confirmed?: boolean; + signal?: AbortSignal; + waitForCompletion?: boolean; +} + +function isOperationRun(value: unknown): value is { operationRunId: string } { + return ( + Boolean(value) && + typeof value === "object" && + typeof (value as { operationRunId?: unknown }).operationRunId === "string" + ); +} + +export async function runCodingAgentAction( + objectType: CodingAgentObjectType, + action: string, + input: Record, + options: CodingAgentActionOptions = {} +): Promise { + const result = await runRecordActionApi(objectType, action, input, { + id: options.id, + confirmed: options.confirmed, + idempotencyKey: crypto.randomUUID(), + }); + if (options.waitForCompletion && isOperationRun(result)) { + return waitForOperationRun(result.operationRunId, { signal: options.signal }); + } + return result as T; +} + +export function codingAgentDto(row: RecordRowClient): T { + return { id: row.id, ...row.data } as T; +} diff --git a/apps/web/src/lib/object-types-api.ts b/apps/web/src/lib/object-types-api.ts index d613718..efaee31 100644 --- a/apps/web/src/lib/object-types-api.ts +++ b/apps/web/src/lib/object-types-api.ts @@ -31,6 +31,8 @@ export interface ObjectTypeClient { confirmation?: { required: boolean; ttlSeconds?: number }; inputSchema?: Record; outputSchema?: Record; + roles?: Array<"viewer" | "editor" | "owner" | "intelligence">; + cancellable?: boolean; }>; } @@ -38,6 +40,16 @@ export interface RecordRowClient { id: string; objectType: string; data: Record; + version?: string; +} + +export interface OperationRunClient { + id: string; + status: "pending" | "running" | "succeeded" | "failed" | "cancelled"; + progress?: number; + result?: unknown; + errorCode?: string; + errorMessage?: string; } export async function fetchObjectTypes(): Promise { @@ -51,8 +63,21 @@ export async function fetchObjectType(name: string): Promise { export async function fetchRecords( objectType: string, - opts?: { parentId?: string | null; limit?: number } -): Promise<{ records: RecordRowClient[]; total: number }> { + opts?: { + parentId?: string | null; + limit?: number; + offset?: number; + sort?: string; + direction?: "asc" | "desc"; + filters?: Record; + } +): Promise<{ + objectType?: string; + records: RecordRowClient[]; + total: number; + limit?: number; + offset?: number; +}> { const q = new URLSearchParams(); if (opts && "parentId" in (opts ?? {})) { q.set( @@ -61,6 +86,12 @@ export async function fetchRecords( ); } if (opts?.limit != null) q.set("limit", String(opts.limit)); + if (opts?.offset != null) q.set("offset", String(opts.offset)); + if (opts?.sort) q.set("sort", opts.sort); + if (opts?.direction) q.set("direction", opts.direction); + for (const [name, value] of Object.entries(opts?.filters ?? {})) { + if (value != null && value !== "") q.set(`filters[${name}]`, String(value)); + } const qs = q.toString(); return api( `/records/${encodeURIComponent(objectType)}${qs ? `?${qs}` : ""}` @@ -89,12 +120,14 @@ export async function createRecordApi( export async function updateRecordApi( objectType: string, id: string, - data: Record + data: Record, + expectedVersion?: string ): Promise { return api( `/records/${encodeURIComponent(objectType)}/${encodeURIComponent(id)}`, { method: "PUT", + headers: expectedVersion ? { "If-Match": expectedVersion } : undefined, body: JSON.stringify({ data }), } ); @@ -102,11 +135,15 @@ export async function updateRecordApi( export async function deleteRecordApi( objectType: string, - id: string + id: string, + expectedVersion?: string ): Promise { await api( `/records/${encodeURIComponent(objectType)}/${encodeURIComponent(id)}`, - { method: "DELETE" } + { + method: "DELETE", + headers: expectedVersion ? { "If-Match": expectedVersion } : undefined, + } ); } @@ -118,6 +155,7 @@ export async function runRecordActionApi( id?: string; confirmationId?: string; idempotencyKey?: string; + expectedVersion?: string; confirmed?: boolean; } ): Promise { @@ -131,6 +169,9 @@ export async function runRecordActionApi( if (opts?.idempotencyKey) { headers["Idempotency-Key"] = opts.idempotencyKey; } + if (opts?.expectedVersion) { + headers["If-Match"] = opts.expectedVersion; + } try { const response = await api<{ result: unknown }>(target, { method: "POST", @@ -157,3 +198,43 @@ export async function runRecordActionApi( }); } } + +export async function fetchOperationRun(id: string): Promise { + const row = await fetchRecord("OperationRun", id); + return { + id: row.id, + status: String(row.data.status) as OperationRunClient["status"], + progress: + typeof row.data.progress === "number" ? row.data.progress : undefined, + result: row.data.result_json, + errorCode: + typeof row.data.error_code === "string" ? row.data.error_code : undefined, + errorMessage: + typeof row.data.error_message === "string" + ? row.data.error_message + : undefined, + }; +} + +export async function waitForOperationRun( + id: string, + opts: { signal?: AbortSignal; intervalMs?: number } = {} +): Promise { + const intervalMs = Math.max(opts.intervalMs ?? 750, 100); + for (;;) { + opts.signal?.throwIfAborted(); + const run = await fetchOperationRun(id); + if (["succeeded", "failed", "cancelled"].includes(run.status)) return run; + await new Promise((resolve, reject) => { + const timer = window.setTimeout(resolve, intervalMs); + opts.signal?.addEventListener( + "abort", + () => { + window.clearTimeout(timer); + reject(opts.signal?.reason ?? new DOMException("Aborted", "AbortError")); + }, + { once: true } + ); + }); + } +} diff --git a/apps/web/src/pages/Structure.tsx b/apps/web/src/pages/Structure.tsx index e534e0b..42f03c1 100644 --- a/apps/web/src/pages/Structure.tsx +++ b/apps/web/src/pages/Structure.tsx @@ -10,11 +10,18 @@ import { } from "lucide-react"; import { toast } from "sonner"; import { - api, + createStructureDivision, createStructureNode, + createStructurePage, + deleteStructureDepartment, + deleteStructureDivision, + deleteStructurePage, fetchAiAgents, fetchAgentAssignments, setAgentAssignment, + updateStructureDepartment, + updateStructureDivision, + updateStructurePage, type AiAgent, type AiAgentAssignment, type AiAssignmentRole, @@ -559,10 +566,7 @@ function DepartmentRow({ { - await api(`/departments/${department.id}`, { - method: "PUT", - body: JSON.stringify({ label: next }), - }); + await updateStructureDepartment(department.id, { label: next }); await reload(); }} /> @@ -584,10 +588,7 @@ function DepartmentRow({ { - await api(`/departments/${department.id}`, { - method: "PUT", - body: JSON.stringify({ icon: next }), - }); + await updateStructureDepartment(department.id, { icon: next }); await reload(); }} /> @@ -603,9 +604,7 @@ function DepartmentRow({ ) return; try { - await api(`/departments/${department.id}`, { - method: "DELETE", - }); + await deleteStructureDepartment(department.id); toast.success("Department deleted"); await reload(); } catch (err) { @@ -833,10 +832,7 @@ function DivisionRow({ { - await api(`/divisions/${dept.id}/${division.id}`, { - method: "PUT", - body: JSON.stringify({ label: next }), - }); + await updateStructureDivision(dept.id, division.id, { label: next }); await reload(); }} /> @@ -858,11 +854,8 @@ function DivisionRow({ checked={division.rightSidebar === "price"} onCheckedChange={async (checked) => { try { - await api(`/divisions/${dept.id}/${division.id}`, { - method: "PUT", - body: JSON.stringify({ - rightSidebar: checked ? "price" : null, - }), + await updateStructureDivision(dept.id, division.id, { + rightSidebar: checked ? "price" : null, }); await reload(); } catch (err) { @@ -881,10 +874,7 @@ function DivisionRow({ { - await api(`/divisions/${dept.id}/${division.id}`, { - method: "PUT", - body: JSON.stringify({ icon: next }), - }); + await updateStructureDivision(dept.id, division.id, { icon: next }); await reload(); }} /> @@ -900,9 +890,7 @@ function DivisionRow({ ) return; try { - await api(`/divisions/${dept.id}/${division.id}`, { - method: "DELETE", - }); + await deleteStructureDivision(dept.id, division.id); toast.success("Division deleted"); await reload(); } catch (err) { @@ -946,14 +934,11 @@ function CreateDivisionDialog({ const submit = async () => { setBusy(true); try { - await api(`/departments/${dept.id}/divisions`, { - method: "POST", - body: JSON.stringify({ - id, - label, - icon, - rightSidebar: rightSidebar ? "price" : null, - }), + await createStructureDivision(dept.id, { + id, + label, + icon, + rightSidebar: rightSidebar ? "price" : null, }); toast.success("Division created"); reset(); @@ -1184,9 +1169,8 @@ function PageRow({ { - await api(`/pages/${dept.id}/${division.id}/${page.id}`, { - method: "PUT", - body: JSON.stringify({ label: next }), + await updateStructurePage(dept.id, division.id, page.id, { + label: next, }); await reload(); }} @@ -1208,9 +1192,8 @@ function PageRow({ { - await api(`/pages/${dept.id}/${division.id}/${page.id}`, { - method: "PUT", - body: JSON.stringify({ icon: next }), + await updateStructurePage(dept.id, division.id, page.id, { + icon: next, }); await reload(); }} @@ -1222,9 +1205,7 @@ function PageRow({ onClick={async () => { if (!confirm(`Delete page "${page.label}"?`)) return; try { - await api(`/pages/${dept.id}/${division.id}/${page.id}`, { - method: "DELETE", - }); + await deleteStructurePage(dept.id, division.id, page.id); toast.success("Page deleted"); await reload(); } catch (err) { @@ -1274,9 +1255,11 @@ function CreatePageDialog({ const submit = async () => { setBusy(true); try { - await api(`/divisions/${dept.id}/${division.id}/pages`, { - method: "POST", - body: JSON.stringify({ id, label, icon, segment }), + await createStructurePage(dept.id, division.id, { + id, + label, + icon, + segment, }); toast.success("Page created"); reset(); diff --git a/apps/web/src/pages/records/RecordFormPage.tsx b/apps/web/src/pages/records/RecordFormPage.tsx index 6c4ceaf..becc5ed 100644 --- a/apps/web/src/pages/records/RecordFormPage.tsx +++ b/apps/web/src/pages/records/RecordFormPage.tsx @@ -4,6 +4,7 @@ import { Page, PageHeader } from "@/components/PageHeader"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; import { createRecordApi, deleteRecordApi, @@ -11,6 +12,7 @@ import { fetchRecord, runRecordActionApi, updateRecordApi, + waitForOperationRun, type FieldDefClient, type ObjectTypeClient, } from "@/lib/object-types-api"; @@ -75,6 +77,7 @@ export function RecordFormPage({ Record> >({}); const [status, setStatus] = useState(null); + const [recordVersion, setRecordVersion] = useState(); const canSave = !!def && (!def.operations || @@ -103,6 +106,7 @@ export function RecordFormPage({ initial[f.name] = v == null ? "" : typeof v === "string" ? v : JSON.stringify(v); } + setRecordVersion(row.version); } setValues(initial); setError(null); @@ -145,7 +149,13 @@ export function RecordFormPage({ `/records/${encodeURIComponent(objectType)}/${encodeURIComponent(created.id)}` ); } else if (recordId) { - await updateRecordApi(objectType, recordId, data); + const updated = await updateRecordApi( + objectType, + recordId, + data, + recordVersion + ); + setRecordVersion(updated.version); } } catch (err) { setError(err instanceof Error ? err.message : String(err)); @@ -157,6 +167,7 @@ export function RecordFormPage({ async function refreshRecord() { if (!objectType || !recordId || !def || isNew) return; const row = await fetchRecord(objectType, recordId); + setRecordVersion(row.version); const next: Record = {}; for (const field of formFields(def)) { const value = row.data[field.name]; @@ -178,7 +189,7 @@ export function RecordFormPage({ setSaving(true); setError(null); try { - await deleteRecordApi(objectType, recordId); + await deleteRecordApi(objectType, recordId, recordVersion); navigate(`/records/${encodeURIComponent(objectType)}`); } catch (err) { setError(err instanceof Error ? err.message : String(err)); @@ -230,14 +241,30 @@ export function RecordFormPage({ action.effect && action.effect !== "read" ? crypto.randomUUID() : undefined, + expectedVersion: recordVersion, }); - setStatus( + if ( result && - typeof result === "object" && - "operationRunId" in result - ? `${action.label} accepted` - : `${action.label} completed` - ); + typeof result === "object" && + "operationRunId" in result + ) { + const operationRunId = String( + (result as { operationRunId: unknown }).operationRunId + ); + setStatus(`${action.label} accepted; waiting for completion…`); + const run = await waitForOperationRun(operationRunId); + if (run.status === "failed") { + throw new Error( + run.errorMessage ?? `${action.label} failed (${run.errorCode ?? "unknown"})` + ); + } + if (run.status === "cancelled") { + throw new Error(`${action.label} was cancelled`); + } + setStatus(`${action.label} completed`); + } else { + setStatus(`${action.label} completed`); + } if (!isNew) await refreshRecord(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); @@ -290,7 +317,13 @@ export function RecordFormPage({ {status}

{def && ( -
+
{ + event.preventDefault(); + void onSave(); + }} + > {formFields(def).map((f) => (