diff --git a/backend/package.json b/backend/package.json index d62b774b..432eb3e4 100644 --- a/backend/package.json +++ b/backend/package.json @@ -41,7 +41,7 @@ "prepack": "npm run build", "typecheck": "tsc --noEmit", "test": "jest", - "test:core": "jest --runInBand --forceExit src/agent/communication/__tests__/agentMessageBus.test.ts src/agent/core/executors/__tests__/strategyExecutor.test.ts src/agent/core/executors/__tests__/hypothesisExecutor.test.ts src/agent/context/__tests__/enhancedSessionContext.test.ts src/tests/adbTools.test.ts src/services/__tests__/sessionLogger.test.ts src/services/__tests__/traceAnalysisSkillConfig.test.ts src/agent/agents/domain/__tests__/registry.test.ts src/agentv3/__tests__/sqlIncludeInjector.test.ts src/middleware/__tests__/auth.test.ts src/services/__tests__/rbac.test.ts src/routes/__tests__/agentRoutesRbac.test.ts src/routes/__tests__/ownerGuardRoutes.test.ts src/routes/__tests__/requestContextRouteCoverage.test.ts src/middleware/__tests__/legacyApiCompatibility.test.ts src/services/__tests__/enterpriseDb.test.ts src/services/__tests__/enterpriseRepository.test.ts", + "test:core": "jest --runInBand --forceExit src/agent/communication/__tests__/agentMessageBus.test.ts src/agent/core/executors/__tests__/strategyExecutor.test.ts src/agent/core/executors/__tests__/hypothesisExecutor.test.ts src/agent/context/__tests__/enhancedSessionContext.test.ts src/tests/adbTools.test.ts src/services/__tests__/sessionLogger.test.ts src/services/__tests__/traceAnalysisSkillConfig.test.ts src/agent/agents/domain/__tests__/registry.test.ts src/agentv3/__tests__/sqlIncludeInjector.test.ts src/middleware/__tests__/auth.test.ts src/services/__tests__/rbac.test.ts src/routes/__tests__/agentRoutesRbac.test.ts src/routes/__tests__/ownerGuardRoutes.test.ts src/routes/__tests__/requestContextRouteCoverage.test.ts src/middleware/__tests__/legacyApiCompatibility.test.ts src/services/__tests__/enterpriseDb.test.ts src/services/__tests__/enterpriseSchema.test.ts src/services/__tests__/enterpriseRepository.test.ts", "test:watch": "jest --watch", "test:coverage": "jest --coverage", "test:unit": "jest --testPathPatterns=src/tests", diff --git a/backend/src/services/__tests__/enterpriseDb.test.ts b/backend/src/services/__tests__/enterpriseDb.test.ts index f2527380..475fe129 100644 --- a/backend/src/services/__tests__/enterpriseDb.test.ts +++ b/backend/src/services/__tests__/enterpriseDb.test.ts @@ -43,7 +43,7 @@ describe('enterprise SQLite WAL database', () => { const rows = db.prepare( 'SELECT version FROM enterprise_schema_migrations ORDER BY version', ).all(); - expect(rows).toEqual([{ version: 1 }, { version: 2 }, { version: 3 }]); + expect(rows).toEqual([{ version: 1 }, { version: 2 }, { version: 3 }, { version: 4 }]); } finally { db.close(); } diff --git a/backend/src/services/__tests__/enterpriseSchema.test.ts b/backend/src/services/__tests__/enterpriseSchema.test.ts index 53bc1d6c..ed3bd8e0 100644 --- a/backend/src/services/__tests__/enterpriseSchema.test.ts +++ b/backend/src/services/__tests__/enterpriseSchema.test.ts @@ -5,7 +5,7 @@ import Database from 'better-sqlite3'; import { applyEnterpriseMinimalSchema, - ENTERPRISE_MINIMAL_SCHEMA_TABLES, + ENTERPRISE_CORE_SCHEMA_TABLES, } from '../enterpriseSchema'; function tableNames(db: Database.Database): Set { @@ -27,7 +27,54 @@ function indexNames(db: Database.Database): Set { return new Set(rows.map(row => row.name)); } -describe('enterprise minimal schema', () => { +function expectColumns(db: Database.Database, table: string, columns: string[]): void { + expect([...columnNames(db, table)]).toEqual(expect.arrayContaining(columns)); +} + +function seedCoreGraph(db: Database.Database, now = Date.now()): void { + db.prepare(` + INSERT INTO organizations (id, name, status, plan, created_at, updated_at) + VALUES ('tenant-a', 'Tenant A', 'active', 'enterprise', ?, ?) + `).run(now, now); + db.prepare(` + INSERT INTO workspaces (id, tenant_id, name, created_at, updated_at) + VALUES ('workspace-a', 'tenant-a', 'Workspace A', ?, ?) + `).run(now, now); + db.prepare(` + INSERT INTO users (id, tenant_id, email, display_name, idp_subject, created_at, updated_at) + VALUES ('user-a', 'tenant-a', 'a@example.test', 'User A', 'oidc|a', ?, ?) + `).run(now, now); + db.prepare(` + INSERT INTO memberships (tenant_id, workspace_id, user_id, role, created_at) + VALUES ('tenant-a', 'workspace-a', 'user-a', 'admin', ?) + `).run(now); + db.prepare(` + INSERT INTO trace_assets + (id, tenant_id, workspace_id, owner_user_id, local_path, sha256, size_bytes, status, created_at) + VALUES + ('trace-a', 'tenant-a', 'workspace-a', 'user-a', '/tmp/trace-a.pftrace', 'abc123', 1024, 'ready', ?) + `).run(now); + db.prepare(` + INSERT INTO provider_snapshots + (id, tenant_id, provider_id, snapshot_hash, runtime_kind, resolved_config_json, secret_version, created_at) + VALUES + ('provider-snapshot-a', 'tenant-a', 'provider-a', 'hash-a', 'openai-agents-sdk', '{"models":{}}', 'v1', ?) + `).run(now); + db.prepare(` + INSERT INTO analysis_sessions + (id, tenant_id, workspace_id, trace_id, created_by, provider_snapshot_id, visibility, status, created_at, updated_at) + VALUES + ('session-a', 'tenant-a', 'workspace-a', 'trace-a', 'user-a', 'provider-snapshot-a', 'private', 'running', ?, ?) + `).run(now, now); + db.prepare(` + INSERT INTO analysis_runs + (id, tenant_id, workspace_id, session_id, mode, status, question, started_at) + VALUES + ('run-a', 'tenant-a', 'workspace-a', 'session-a', 'full', 'running', 'analyze', ?) + `).run(now); +} + +describe('enterprise core schema', () => { let db: Database.Database | undefined; beforeEach(() => { @@ -39,24 +86,60 @@ describe('enterprise minimal schema', () => { db = undefined; }); - test('creates the §0.1.7 minimal enterprise tables and key columns', () => { + test('creates the §10.2 core enterprise tables and key columns', () => { applyEnterpriseMinimalSchema(db!); const tables = tableNames(db!); - for (const table of ENTERPRISE_MINIMAL_SCHEMA_TABLES) { + for (const table of ENTERPRISE_CORE_SCHEMA_TABLES) { expect(tables.has(table)).toBe(true); } expect(tables.has('enterprise_schema_migrations')).toBe(true); + expect(tables.has('sso_sessions')).toBe(true); - expect([...columnNames(db!, 'organizations')]).toEqual(expect.arrayContaining([ + expectColumns(db!, 'organizations', [ 'id', 'name', 'status', 'plan', 'created_at', 'updated_at', - ])); - expect([...columnNames(db!, 'trace_assets')]).toEqual(expect.arrayContaining([ + ]); + expectColumns(db!, 'workspaces', [ + 'id', + 'tenant_id', + 'name', + 'retention_policy', + 'quota_policy', + 'created_at', + 'updated_at', + ]); + expectColumns(db!, 'users', [ + 'id', + 'tenant_id', + 'email', + 'display_name', + 'idp_subject', + 'created_at', + 'updated_at', + ]); + expectColumns(db!, 'memberships', [ + 'tenant_id', + 'workspace_id', + 'user_id', + 'role', + 'created_at', + ]); + expectColumns(db!, 'api_keys', [ + 'id', + 'tenant_id', + 'workspace_id', + 'owner_user_id', + 'key_hash', + 'scopes', + 'expires_at', + 'revoked_at', + ]); + expectColumns(db!, 'trace_assets', [ 'id', 'tenant_id', 'workspace_id', @@ -68,20 +151,63 @@ describe('enterprise minimal schema', () => { 'metadata_json', 'created_at', 'expires_at', - ])); - expect([...columnNames(db!, 'analysis_sessions')]).toEqual(expect.arrayContaining([ + ]); + expectColumns(db!, 'trace_processor_leases', [ + 'id', + 'tenant_id', + 'workspace_id', + 'trace_id', + 'mode', + 'state', + 'rss_bytes', + 'heartbeat_at', + 'expires_at', + ]); + expectColumns(db!, 'trace_processor_holders', [ + 'id', + 'lease_id', + 'holder_type', + 'holder_ref', + 'window_id', + 'heartbeat_at', + 'created_at', + ]); + expectColumns(db!, 'analysis_sessions', [ 'id', 'tenant_id', 'workspace_id', 'trace_id', 'created_by', 'provider_snapshot_id', + 'title', 'visibility', 'status', 'created_at', 'updated_at', - ])); - expect([...columnNames(db!, 'agent_events')]).toEqual(expect.arrayContaining([ + ]); + expectColumns(db!, 'analysis_runs', [ + 'id', + 'tenant_id', + 'workspace_id', + 'session_id', + 'mode', + 'status', + 'question', + 'started_at', + 'completed_at', + 'error_json', + ]); + expectColumns(db!, 'conversation_turns', [ + 'id', + 'tenant_id', + 'workspace_id', + 'session_id', + 'run_id', + 'role', + 'content_json', + 'created_at', + ]); + expectColumns(db!, 'agent_events', [ 'id', 'tenant_id', 'workspace_id', @@ -90,8 +216,32 @@ describe('enterprise minimal schema', () => { 'event_type', 'payload_json', 'created_at', - ])); - expect([...columnNames(db!, 'provider_snapshots')]).toEqual(expect.arrayContaining([ + ]); + expectColumns(db!, 'runtime_snapshots', [ + 'id', + 'tenant_id', + 'workspace_id', + 'session_id', + 'run_id', + 'runtime_type', + 'snapshot_json', + 'created_at', + ]); + expectColumns(db!, 'provider_credentials', [ + 'id', + 'tenant_id', + 'workspace_id', + 'owner_user_id', + 'scope', + 'name', + 'type', + 'models_json', + 'secret_ref', + 'policy_json', + 'created_at', + 'updated_at', + ]); + expectColumns(db!, 'provider_snapshots', [ 'id', 'tenant_id', 'provider_id', @@ -100,122 +250,183 @@ describe('enterprise minimal schema', () => { 'resolved_config_json', 'secret_version', 'created_at', - ])); - expect([...columnNames(db!, 'audit_events')]).toEqual(expect.arrayContaining([ + ]); + expectColumns(db!, 'report_artifacts', [ 'id', 'tenant_id', 'workspace_id', - 'actor_user_id', - 'action', - 'resource_type', - 'resource_id', - 'metadata_json', + 'session_id', + 'run_id', + 'local_path', + 'content_hash', + 'visibility', + 'created_by', 'created_at', - ])); - expect([...columnNames(db!, 'sso_sessions')]).toEqual(expect.arrayContaining([ + 'expires_at', + ]); + expectColumns(db!, 'memory_entries', [ 'id', 'tenant_id', 'workspace_id', - 'user_id', - 'selected_workspace_id', - 'auth_context_json', + 'scope', + 'source_run_id', + 'content_json', + 'embedding_ref', 'created_at', - 'expires_at', - 'revoked_at', - ])); - expect([...columnNames(db!, 'api_keys')]).toEqual(expect.arrayContaining([ + 'updated_at', + ]); + expectColumns(db!, 'skill_registry_entries', [ 'id', 'tenant_id', 'workspace_id', - 'owner_user_id', - 'name', - 'key_hash', - 'scopes', + 'scope', + 'version', + 'enabled', + 'source_path', 'created_at', - 'expires_at', - 'revoked_at', - 'last_used_at', - ])); + 'updated_at', + ]); + expectColumns(db!, 'tenant_tombstones', [ + 'tenant_id', + 'requested_by', + 'requested_at', + 'purge_after', + 'status', + 'proof_hash', + ]); + expectColumns(db!, 'audit_events', [ + 'id', + 'tenant_id', + 'workspace_id', + 'actor_user_id', + 'action', + 'resource_type', + 'resource_id', + 'metadata_json', + 'created_at', + ]); }); - test('creates owner-guard and replay indexes for high-risk lookup paths', () => { + test('creates owner-guard, replay, migration, audit, and tombstone indexes', () => { applyEnterpriseMinimalSchema(db!); const indexes = indexNames(db!); - expect(indexes.has('idx_trace_assets_owner_guard')).toBe(true); - expect(indexes.has('idx_analysis_sessions_owner_guard')).toBe(true); - expect(indexes.has('idx_analysis_runs_status')).toBe(true); - expect(indexes.has('idx_agent_events_replay')).toBe(true); - expect(indexes.has('idx_agent_events_owner_guard')).toBe(true); - expect(indexes.has('idx_provider_snapshots_provider')).toBe(true); - expect(indexes.has('idx_audit_events_tenant_time')).toBe(true); - expect(indexes.has('idx_audit_events_actor')).toBe(true); - expect(indexes.has('idx_sso_sessions_user')).toBe(true); - expect(indexes.has('idx_sso_sessions_expiry')).toBe(true); - expect(indexes.has('idx_api_keys_scope')).toBe(true); - expect(indexes.has('idx_api_keys_owner')).toBe(true); - expect(indexes.has('idx_api_keys_expiry')).toBe(true); + for (const index of [ + 'idx_trace_assets_owner_guard', + 'idx_trace_assets_tenant_workspace_id_unique', + 'idx_trace_processor_leases_owner_guard', + 'idx_trace_processor_leases_trace', + 'idx_trace_processor_holders_lease', + 'idx_analysis_sessions_owner_guard', + 'idx_analysis_sessions_tenant_workspace_id_unique', + 'idx_analysis_runs_status', + 'idx_analysis_runs_tenant_workspace_id_unique', + 'idx_conversation_turns_session', + 'idx_conversation_turns_run', + 'idx_agent_events_replay', + 'idx_agent_events_owner_guard', + 'idx_runtime_snapshots_session', + 'idx_runtime_snapshots_run', + 'idx_provider_credentials_scope', + 'idx_provider_credentials_owner', + 'idx_provider_snapshots_provider', + 'idx_report_artifacts_owner_guard', + 'idx_report_artifacts_session', + 'idx_memory_entries_scope', + 'idx_skill_registry_entries_scope', + 'idx_audit_events_tenant_time', + 'idx_audit_events_actor', + 'idx_tenant_tombstones_status', + 'idx_sso_sessions_user', + 'idx_sso_sessions_expiry', + 'idx_api_keys_scope', + 'idx_api_keys_owner', + 'idx_api_keys_expiry', + ]) { + expect(indexes.has(index)).toBe(true); + } }); - test('is idempotent and records the applied schema version once', () => { + test('is idempotent and records every applied schema version once', () => { applyEnterpriseMinimalSchema(db!); applyEnterpriseMinimalSchema(db!); const rows = db!.prepare( 'SELECT version FROM enterprise_schema_migrations ORDER BY version', ).all(); - expect(rows).toEqual([{ version: 1 }, { version: 2 }, { version: 3 }]); + expect(rows).toEqual([{ version: 1 }, { version: 2 }, { version: 3 }, { version: 4 }]); }); - test('enforces the tenant workspace session run event chain', () => { + test('enforces the full tenant workspace session run event chain', () => { applyEnterpriseMinimalSchema(db!); const now = Date.now(); + seedCoreGraph(db!, now); db!.prepare(` - INSERT INTO organizations (id, name, status, plan, created_at, updated_at) - VALUES ('tenant-a', 'Tenant A', 'active', 'enterprise', ?, ?) - `).run(now, now); - db!.prepare(` - INSERT INTO workspaces (id, tenant_id, name, created_at, updated_at) - VALUES ('workspace-a', 'tenant-a', 'Workspace A', ?, ?) - `).run(now, now); + INSERT INTO trace_processor_leases + (id, tenant_id, workspace_id, trace_id, mode, state, rss_bytes, heartbeat_at, expires_at) + VALUES + ('lease-a', 'tenant-a', 'workspace-a', 'trace-a', 'shared', 'active', 1234, ?, ?) + `).run(now, now + 60_000); db!.prepare(` - INSERT INTO users (id, tenant_id, email, display_name, idp_subject, created_at, updated_at) - VALUES ('user-a', 'tenant-a', 'a@example.test', 'User A', 'oidc|a', ?, ?) + INSERT INTO trace_processor_holders + (id, lease_id, holder_type, holder_ref, window_id, heartbeat_at, created_at) + VALUES + ('holder-a', 'lease-a', 'agent_run', 'run-a', 'window-a', ?, ?) `).run(now, now); db!.prepare(` - INSERT INTO memberships (tenant_id, workspace_id, user_id, role, created_at) - VALUES ('tenant-a', 'workspace-a', 'user-a', 'admin', ?) + INSERT INTO conversation_turns + (id, tenant_id, workspace_id, session_id, run_id, role, content_json, created_at) + VALUES + ('turn-a', 'tenant-a', 'workspace-a', 'session-a', 'run-a', 'user', '{"text":"analyze"}', ?) `).run(now); db!.prepare(` - INSERT INTO trace_assets - (id, tenant_id, workspace_id, owner_user_id, local_path, sha256, size_bytes, status, created_at) + INSERT INTO agent_events + (id, tenant_id, workspace_id, run_id, cursor, event_type, payload_json, created_at) VALUES - ('trace-a', 'tenant-a', 'workspace-a', 'user-a', '/tmp/trace-a.pftrace', 'abc123', 1024, 'ready', ?) + ('event-a-1', 'tenant-a', 'workspace-a', 'run-a', 1, 'progress', '{}', ?) `).run(now); db!.prepare(` - INSERT INTO provider_snapshots - (id, tenant_id, provider_id, snapshot_hash, runtime_kind, resolved_config_json, secret_version, created_at) + INSERT INTO runtime_snapshots + (id, tenant_id, workspace_id, session_id, run_id, runtime_type, snapshot_json, created_at) VALUES - ('provider-snapshot-a', 'tenant-a', 'provider-a', 'hash-a', 'openai-agents-sdk', '{"models":{}}', 'v1', ?) + ('runtime-snapshot-a', 'tenant-a', 'workspace-a', 'session-a', 'run-a', 'claude-agent-sdk', '{}', ?) `).run(now); db!.prepare(` - INSERT INTO analysis_sessions - (id, tenant_id, workspace_id, trace_id, created_by, provider_snapshot_id, visibility, status, created_at, updated_at) + INSERT INTO provider_credentials + (id, tenant_id, workspace_id, owner_user_id, scope, name, type, models_json, secret_ref, policy_json, created_at, updated_at) VALUES - ('session-a', 'tenant-a', 'workspace-a', 'trace-a', 'user-a', 'provider-snapshot-a', 'private', 'running', ?, ?) + ('provider-credential-a', 'tenant-a', 'workspace-a', 'user-a', 'workspace', 'Provider A', 'openai', '[]', 'secret:v1', '{}', ?, ?) `).run(now, now); db!.prepare(` - INSERT INTO analysis_runs - (id, tenant_id, workspace_id, session_id, mode, status, question, started_at) + INSERT INTO report_artifacts + (id, tenant_id, workspace_id, session_id, run_id, local_path, content_hash, visibility, created_by, created_at, expires_at) VALUES - ('run-a', 'tenant-a', 'workspace-a', 'session-a', 'full', 'running', 'analyze', ?) - `).run(now); + ('report-a', 'tenant-a', 'workspace-a', 'session-a', 'run-a', '/tmp/report-a.html', 'hash-report', 'private', 'user-a', ?, ?) + `).run(now, now + 60_000); db!.prepare(` - INSERT INTO agent_events - (id, tenant_id, workspace_id, run_id, cursor, event_type, payload_json, created_at) + INSERT INTO memory_entries + (id, tenant_id, workspace_id, scope, source_run_id, content_json, embedding_ref, created_at, updated_at) VALUES - ('event-a-1', 'tenant-a', 'workspace-a', 'run-a', 1, 'progress', '{}', ?) + ('memory-a', 'tenant-a', 'workspace-a', 'workspace', 'run-a', '{}', 'embedding:a', ?, ?) + `).run(now, now); + db!.prepare(` + INSERT INTO skill_registry_entries + (id, tenant_id, workspace_id, scope, version, enabled, source_path, created_at, updated_at) + VALUES + ('skill-a', 'tenant-a', 'workspace-a', 'workspace', '1', 1, 'skills/custom.skill.yaml', ?, ?) + `).run(now, now); + db!.prepare(` + INSERT INTO tenant_tombstones + (tenant_id, requested_by, requested_at, purge_after, status, proof_hash) + VALUES + ('tenant-a', 'user-a', ?, ?, 'pending', 'proof-a') + `).run(now, now + 7 * 24 * 60 * 60 * 1000); + db!.prepare(` + INSERT INTO audit_events + (id, tenant_id, workspace_id, actor_user_id, action, resource_type, resource_id, metadata_json, created_at) + VALUES + ('audit-a', 'tenant-a', 'workspace-a', 'user-a', 'tenant.delete.requested', 'tenant', 'tenant-a', '{}', ?) `).run(now); expect(() => { @@ -228,10 +439,50 @@ describe('enterprise minimal schema', () => { }).toThrow(); expect(() => { db!.prepare(` - INSERT INTO analysis_runs - (id, tenant_id, workspace_id, session_id, mode, status, question, started_at) + INSERT INTO trace_processor_holders + (id, lease_id, holder_type, holder_ref, created_at) + VALUES + ('holder-missing', 'missing-lease', 'agent_run', 'run-a', ?) + `).run(now); + }).toThrow(); + expect(() => { + db!.prepare(` + INSERT INTO runtime_snapshots + (id, tenant_id, workspace_id, session_id, run_id, runtime_type, snapshot_json, created_at) + VALUES + ('runtime-snapshot-missing', 'tenant-a', 'workspace-a', 'session-a', 'missing-run', 'claude-agent-sdk', '{}', ?) + `).run(now); + }).toThrow(); + }); + + test('rejects cross-tenant workspace and session/run references on new core tables', () => { + applyEnterpriseMinimalSchema(db!); + const now = Date.now(); + seedCoreGraph(db!, now); + + db!.prepare(` + INSERT INTO organizations (id, name, status, plan, created_at, updated_at) + VALUES ('tenant-b', 'Tenant B', 'active', 'enterprise', ?, ?) + `).run(now, now); + db!.prepare(` + INSERT INTO workspaces (id, tenant_id, name, created_at, updated_at) + VALUES ('workspace-b', 'tenant-b', 'Workspace B', ?, ?) + `).run(now, now); + + expect(() => { + db!.prepare(` + INSERT INTO trace_processor_leases + (id, tenant_id, workspace_id, trace_id, mode, state, heartbeat_at) + VALUES + ('lease-cross-workspace', 'tenant-a', 'workspace-b', 'trace-a', 'shared', 'active', ?) + `).run(now); + }).toThrow(); + expect(() => { + db!.prepare(` + INSERT INTO conversation_turns + (id, tenant_id, workspace_id, session_id, run_id, role, content_json, created_at) VALUES - ('run-missing', 'tenant-a', 'workspace-a', 'missing-session', 'full', 'running', 'analyze', ?) + ('turn-cross-session', 'tenant-b', 'workspace-b', 'session-a', 'run-a', 'user', '{}', ?) `).run(now); }).toThrow(); }); diff --git a/backend/src/services/enterpriseSchema.ts b/backend/src/services/enterpriseSchema.ts index c7db3db9..ea90ca55 100644 --- a/backend/src/services/enterpriseSchema.ts +++ b/backend/src/services/enterpriseSchema.ts @@ -9,21 +9,35 @@ interface MigrationStep { up: (db: Database.Database) => void; } -export const ENTERPRISE_MINIMAL_SCHEMA_TABLES = [ +export const ENTERPRISE_CORE_SCHEMA_TABLES = [ 'organizations', 'workspaces', 'users', 'memberships', 'api_keys', 'trace_assets', + 'trace_processor_leases', + 'trace_processor_holders', 'analysis_sessions', 'analysis_runs', + 'conversation_turns', 'agent_events', + 'runtime_snapshots', + 'provider_credentials', 'provider_snapshots', + 'report_artifacts', + 'memory_entries', + 'skill_registry_entries', + 'tenant_tombstones', 'audit_events', +] as const; + +export const ENTERPRISE_MINIMAL_SCHEMA_TABLES = [ + ...ENTERPRISE_CORE_SCHEMA_TABLES, 'sso_sessions', ] as const; +export type EnterpriseCoreSchemaTable = typeof ENTERPRISE_CORE_SCHEMA_TABLES[number]; export type EnterpriseMinimalSchemaTable = typeof ENTERPRISE_MINIMAL_SCHEMA_TABLES[number]; const MIGRATIONS: MigrationStep[] = [ @@ -266,6 +280,202 @@ const MIGRATIONS: MigrationStep[] = [ `); }, }, + { + version: 4, + up: (db) => { + db.exec(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_workspaces_tenant_id_unique + ON workspaces(tenant_id, id); + CREATE UNIQUE INDEX IF NOT EXISTS idx_users_tenant_id_unique + ON users(tenant_id, id); + CREATE UNIQUE INDEX IF NOT EXISTS idx_trace_assets_tenant_workspace_id_unique + ON trace_assets(tenant_id, workspace_id, id); + CREATE UNIQUE INDEX IF NOT EXISTS idx_analysis_sessions_tenant_workspace_id_unique + ON analysis_sessions(tenant_id, workspace_id, id); + CREATE UNIQUE INDEX IF NOT EXISTS idx_analysis_runs_tenant_workspace_id_unique + ON analysis_runs(tenant_id, workspace_id, id); + + CREATE TABLE IF NOT EXISTS trace_processor_leases ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + workspace_id TEXT NOT NULL, + trace_id TEXT NOT NULL, + mode TEXT NOT NULL, + state TEXT NOT NULL, + rss_bytes INTEGER, + heartbeat_at INTEGER, + expires_at INTEGER, + FOREIGN KEY (tenant_id) REFERENCES organizations(id) ON DELETE CASCADE, + FOREIGN KEY (tenant_id, workspace_id) REFERENCES workspaces(tenant_id, id) ON DELETE CASCADE, + FOREIGN KEY (tenant_id, workspace_id, trace_id) REFERENCES trace_assets(tenant_id, workspace_id, id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_trace_processor_leases_owner_guard + ON trace_processor_leases(tenant_id, workspace_id, id); + CREATE INDEX IF NOT EXISTS idx_trace_processor_leases_trace + ON trace_processor_leases(tenant_id, workspace_id, trace_id, state); + CREATE INDEX IF NOT EXISTS idx_trace_processor_leases_state + ON trace_processor_leases(tenant_id, workspace_id, state, heartbeat_at); + CREATE INDEX IF NOT EXISTS idx_trace_processor_leases_expiry + ON trace_processor_leases(expires_at, heartbeat_at); + + CREATE TABLE IF NOT EXISTS trace_processor_holders ( + id TEXT PRIMARY KEY, + lease_id TEXT NOT NULL, + holder_type TEXT NOT NULL, + holder_ref TEXT NOT NULL, + window_id TEXT, + heartbeat_at INTEGER, + created_at INTEGER NOT NULL, + FOREIGN KEY (lease_id) REFERENCES trace_processor_leases(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_trace_processor_holders_lease + ON trace_processor_holders(lease_id, holder_type, holder_ref); + CREATE INDEX IF NOT EXISTS idx_trace_processor_holders_window + ON trace_processor_holders(window_id, heartbeat_at); + + CREATE TABLE IF NOT EXISTS conversation_turns ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + workspace_id TEXT NOT NULL, + session_id TEXT NOT NULL, + run_id TEXT NOT NULL, + role TEXT NOT NULL, + content_json TEXT NOT NULL, + created_at INTEGER NOT NULL, + FOREIGN KEY (tenant_id) REFERENCES organizations(id) ON DELETE CASCADE, + FOREIGN KEY (tenant_id, workspace_id) REFERENCES workspaces(tenant_id, id) ON DELETE CASCADE, + FOREIGN KEY (tenant_id, workspace_id, session_id) REFERENCES analysis_sessions(tenant_id, workspace_id, id) ON DELETE CASCADE, + FOREIGN KEY (tenant_id, workspace_id, run_id) REFERENCES analysis_runs(tenant_id, workspace_id, id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_conversation_turns_session + ON conversation_turns(tenant_id, workspace_id, session_id, created_at); + CREATE INDEX IF NOT EXISTS idx_conversation_turns_run + ON conversation_turns(tenant_id, workspace_id, run_id, created_at); + + CREATE TABLE IF NOT EXISTS runtime_snapshots ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + workspace_id TEXT NOT NULL, + session_id TEXT NOT NULL, + run_id TEXT NOT NULL, + runtime_type TEXT NOT NULL, + snapshot_json TEXT NOT NULL, + created_at INTEGER NOT NULL, + FOREIGN KEY (tenant_id) REFERENCES organizations(id) ON DELETE CASCADE, + FOREIGN KEY (tenant_id, workspace_id) REFERENCES workspaces(tenant_id, id) ON DELETE CASCADE, + FOREIGN KEY (tenant_id, workspace_id, session_id) REFERENCES analysis_sessions(tenant_id, workspace_id, id) ON DELETE CASCADE, + FOREIGN KEY (tenant_id, workspace_id, run_id) REFERENCES analysis_runs(tenant_id, workspace_id, id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_runtime_snapshots_session + ON runtime_snapshots(tenant_id, workspace_id, session_id, created_at); + CREATE INDEX IF NOT EXISTS idx_runtime_snapshots_run + ON runtime_snapshots(tenant_id, workspace_id, run_id, created_at); + + CREATE TABLE IF NOT EXISTS provider_credentials ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + workspace_id TEXT, + owner_user_id TEXT, + scope TEXT NOT NULL, + name TEXT NOT NULL, + type TEXT NOT NULL, + models_json TEXT NOT NULL, + secret_ref TEXT NOT NULL, + policy_json TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (tenant_id) REFERENCES organizations(id) ON DELETE CASCADE, + FOREIGN KEY (tenant_id, workspace_id) REFERENCES workspaces(tenant_id, id) ON DELETE CASCADE, + FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL + ); + CREATE INDEX IF NOT EXISTS idx_provider_credentials_scope + ON provider_credentials(tenant_id, workspace_id, scope, created_at); + CREATE INDEX IF NOT EXISTS idx_provider_credentials_owner + ON provider_credentials(tenant_id, owner_user_id, created_at); + CREATE INDEX IF NOT EXISTS idx_provider_credentials_name + ON provider_credentials(tenant_id, workspace_id, name); + + CREATE TABLE IF NOT EXISTS report_artifacts ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + workspace_id TEXT NOT NULL, + session_id TEXT NOT NULL, + run_id TEXT NOT NULL, + local_path TEXT NOT NULL, + content_hash TEXT, + visibility TEXT NOT NULL, + created_by TEXT, + created_at INTEGER NOT NULL, + expires_at INTEGER, + FOREIGN KEY (tenant_id) REFERENCES organizations(id) ON DELETE CASCADE, + FOREIGN KEY (tenant_id, workspace_id) REFERENCES workspaces(tenant_id, id) ON DELETE CASCADE, + FOREIGN KEY (tenant_id, workspace_id, session_id) REFERENCES analysis_sessions(tenant_id, workspace_id, id) ON DELETE CASCADE, + FOREIGN KEY (tenant_id, workspace_id, run_id) REFERENCES analysis_runs(tenant_id, workspace_id, id) ON DELETE CASCADE, + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL + ); + CREATE INDEX IF NOT EXISTS idx_report_artifacts_owner_guard + ON report_artifacts(tenant_id, workspace_id, id); + CREATE INDEX IF NOT EXISTS idx_report_artifacts_session + ON report_artifacts(tenant_id, workspace_id, session_id, created_at); + CREATE INDEX IF NOT EXISTS idx_report_artifacts_run + ON report_artifacts(tenant_id, workspace_id, run_id, created_at); + CREATE INDEX IF NOT EXISTS idx_report_artifacts_expiry + ON report_artifacts(expires_at, created_at); + + CREATE TABLE IF NOT EXISTS memory_entries ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + workspace_id TEXT NOT NULL, + scope TEXT NOT NULL, + source_run_id TEXT, + content_json TEXT NOT NULL, + embedding_ref TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (tenant_id) REFERENCES organizations(id) ON DELETE CASCADE, + FOREIGN KEY (tenant_id, workspace_id) REFERENCES workspaces(tenant_id, id) ON DELETE CASCADE, + FOREIGN KEY (source_run_id) REFERENCES analysis_runs(id) ON DELETE SET NULL + ); + CREATE INDEX IF NOT EXISTS idx_memory_entries_scope + ON memory_entries(tenant_id, workspace_id, scope, updated_at); + CREATE INDEX IF NOT EXISTS idx_memory_entries_source_run + ON memory_entries(tenant_id, workspace_id, source_run_id); + + CREATE TABLE IF NOT EXISTS skill_registry_entries ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + workspace_id TEXT NOT NULL, + scope TEXT NOT NULL, + version TEXT NOT NULL, + enabled INTEGER NOT NULL, + source_path TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (tenant_id) REFERENCES organizations(id) ON DELETE CASCADE, + FOREIGN KEY (tenant_id, workspace_id) REFERENCES workspaces(tenant_id, id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_skill_registry_entries_scope + ON skill_registry_entries(tenant_id, workspace_id, scope, enabled, updated_at); + CREATE INDEX IF NOT EXISTS idx_skill_registry_entries_source + ON skill_registry_entries(tenant_id, workspace_id, source_path, version); + + CREATE TABLE IF NOT EXISTS tenant_tombstones ( + tenant_id TEXT PRIMARY KEY, + requested_by TEXT, + requested_at INTEGER NOT NULL, + purge_after INTEGER NOT NULL, + status TEXT NOT NULL, + proof_hash TEXT, + FOREIGN KEY (tenant_id) REFERENCES organizations(id) ON DELETE CASCADE, + FOREIGN KEY (requested_by) REFERENCES users(id) ON DELETE SET NULL + ); + CREATE INDEX IF NOT EXISTS idx_tenant_tombstones_status + ON tenant_tombstones(status, purge_after); + CREATE INDEX IF NOT EXISTS idx_tenant_tombstones_requested_by + ON tenant_tombstones(tenant_id, requested_by, requested_at); + `); + }, + }, ]; export function applyEnterpriseMinimalSchema(db: Database.Database): void { diff --git a/docs/features/enterprise-multi-tenant/README.md b/docs/features/enterprise-multi-tenant/README.md index c77b0ecc..06a226b7 100644 --- a/docs/features/enterprise-multi-tenant/README.md +++ b/docs/features/enterprise-multi-tenant/README.md @@ -40,7 +40,7 @@ ### 0.3 主线 B:存储与持久化(§18) - [x] 3.1 选 SQLite WAL 还是单 Postgres,落地 ADR;建立 repository 抽象(默认追加 tenantId/workspaceId filter) -- [ ] 3.2 实现 §10.2 全部核心表 + 索引 + migration(含 audit / tombstone) +- [x] 3.2 实现 §10.2 全部核心表 + 索引 + migration(含 audit / tombstone) - [ ] 3.3 trace metadata 入 DB;trace 文件迁到 `data/{tenantId}/{workspaceId}/traces/` - [ ] 3.4 report metadata 入 DB;report 内容迁到 `data/{tenantId}/{workspaceId}/reports/`(§14.2) - [ ] 3.5 `logs/claude_session_map.json` 迁到 `runtime_snapshots`