diff --git a/AgentCompany-New/src/main/database/migrations/031-provider-request-workspace.ts b/AgentCompany-New/src/main/database/migrations/031-provider-request-workspace.ts new file mode 100644 index 00000000..cfbe7501 --- /dev/null +++ b/AgentCompany-New/src/main/database/migrations/031-provider-request-workspace.ts @@ -0,0 +1,10 @@ +export const providerRequestWorkspaceSchema = ` + alter table provider_requests add column workspace_id text references workspaces(id) on delete set null; + + create index if not exists idx_provider_requests_workspace + on provider_requests(org_id, workspace_id, created_at) + where workspace_id is not null; + + insert into schema_migrations(version, name, applied_at) + values (31, 'provider_request_workspace', datetime('now')); +`; diff --git a/AgentCompany-New/src/main/database/schema.ts b/AgentCompany-New/src/main/database/schema.ts index 9d6beb01..5828b629 100644 --- a/AgentCompany-New/src/main/database/schema.ts +++ b/AgentCompany-New/src/main/database/schema.ts @@ -29,6 +29,7 @@ import { socialAuditRecordsSchema } from "./migrations/027-social-audit-records" import { migrationCutoverJournalSchema } from "./migrations/028-migration-cutover-journal"; import { fileHandoffRecordsSchema } from "./migrations/029-file-handoff-records"; import { approvalSourceRefsSchema } from "./migrations/030-approval-source-refs"; +import { providerRequestWorkspaceSchema } from "./migrations/031-provider-request-workspace"; interface Migration { version: number; @@ -67,6 +68,7 @@ const MIGRATIONS: Migration[] = [ { version: 28, name: "migration_cutover_journal", sql: migrationCutoverJournalSchema }, { version: 29, name: "file_handoff_records", sql: fileHandoffRecordsSchema }, { version: 30, name: "approval_source_refs", sql: approvalSourceRefsSchema }, + { version: 31, name: "provider_request_workspace", sql: providerRequestWorkspaceSchema }, ]; const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version; diff --git a/AgentCompany-New/src/main/services/migration-service.ts b/AgentCompany-New/src/main/services/migration-service.ts index f32c29ac..6df2ce14 100644 --- a/AgentCompany-New/src/main/services/migration-service.ts +++ b/AgentCompany-New/src/main/services/migration-service.ts @@ -194,6 +194,7 @@ const RELATION_CHECKS: RelationCheck[] = [ { table: "providers", column: "credential_id", referencedTable: "credentials", referencedColumn: "id", reason: "dangling_credential_id" }, { table: "provider_requests", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, { table: "provider_requests", column: "provider_id", referencedTable: "providers", referencedColumn: "id", reason: "dangling_provider_id" }, + { table: "provider_requests", column: "workspace_id", referencedTable: "workspaces", referencedColumn: "id", reason: "dangling_workspace_id" }, { table: "provider_usage_windows", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, { table: "provider_usage_windows", column: "provider_id", referencedTable: "providers", referencedColumn: "id", reason: "dangling_provider_id" }, { table: "provider_rate_limit_observations", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, diff --git a/AgentCompany-New/src/main/services/provider-background-service.ts b/AgentCompany-New/src/main/services/provider-background-service.ts index aff62ef0..c98117b0 100644 --- a/AgentCompany-New/src/main/services/provider-background-service.ts +++ b/AgentCompany-New/src/main/services/provider-background-service.ts @@ -57,6 +57,7 @@ export class ProviderBackgroundService { orgId: input.orgId, provider: execution.provider, model: execution.provider.model, + workspaceId: execution.workspaceId, promptSha256: hashPrompt(execution.prompt), inputCharCount: execution.prompt.length, executionMode: PROVIDER_EXECUTION_MODE.BACKGROUND, @@ -201,12 +202,14 @@ export class ProviderBackgroundService { const prompt = input.prompt.trim(); if (!prompt) throw new Error("Prompt is required."); const provider = this.loadConfiguredProvider(input.orgId, input.providerId); - this.enforcePolicy(input.orgId, provider, input.executionMode ?? PROVIDER_EXECUTION_MODE.BACKGROUND); + const workspaceId = this.normalizeWorkspaceId(input.orgId, input.workspaceId ?? null); + this.enforcePolicy(input.orgId, provider, input.executionMode ?? PROVIDER_EXECUTION_MODE.BACKGROUND, workspaceId); if (provider.auth_type !== PROVIDER_AUTH_TYPE.API_KEY) { throw new Error("Provider background execution requires an API key adapter."); } if (!provider.model) throw new Error("Provider model is required."); return { + workspaceId, prompt, instructions: input.instructions?.trim() || null, provider: { ...provider, model: provider.model }, @@ -215,8 +218,12 @@ export class ProviderBackgroundService { }; } - private enforcePolicy(orgId: string, provider: ProviderRow, executionMode: ProviderExecutionMode): void { + private enforcePolicy(orgId: string, provider: ProviderRow, executionMode: ProviderExecutionMode, workspaceId: string | null): void { if (executionMode !== PROVIDER_EXECUTION_MODE.BACKGROUND) throw new Error("Provider background execution mode is required."); + if (provider.workspace_scope !== "organization" && provider.workspace_scope !== "workspace") throw new Error("Provider workspace scope is invalid."); + if (provider.workspace_scope === "workspace" && !workspaceId) { + throw new Error("Provider workspace scope requires a workspace context."); + } if (provider.data_policy === PROVIDER_DATA_POLICY.ZERO_DATA_RETENTION) { throw new Error("Background execution conflicts with zero data retention policy."); } @@ -234,6 +241,15 @@ export class ProviderBackgroundService { if (requestCount >= provider.monthly_request_limit) throw new Error("Provider monthly request limit has been reached."); } + private normalizeWorkspaceId(orgId: string, workspaceId: string | null): string | null { + const normalized = workspaceId?.trim() || null; + if (!normalized) return null; + const workspace = this.db.prepare("select id from workspaces where id = ? and org_id = ?") + .get(normalized, orgId) as { id: string } | undefined; + if (!workspace) throw new Error("Provider workspace context was not found."); + return normalized; + } + private loadConfiguredProvider(orgId: string, providerId: string): ProviderRow { const provider = this.db.prepare(` select id, provider_key, auth_type, model, credential_id, enabled, auth_state, @@ -292,6 +308,7 @@ export class ProviderBackgroundService { } interface PreparedBackgroundExecution { + workspaceId: string | null; prompt: string; instructions: string | null; provider: ProviderRow & { model: string }; diff --git a/AgentCompany-New/src/main/services/provider-request-store.ts b/AgentCompany-New/src/main/services/provider-request-store.ts index b5c80cf0..b80f9fa0 100644 --- a/AgentCompany-New/src/main/services/provider-request-store.ts +++ b/AgentCompany-New/src/main/services/provider-request-store.ts @@ -7,12 +7,14 @@ export interface ProviderRequestOwner { id: string; provider_key: string; data_policy: string; + workspace_scope: string; } export interface CreateProviderRequestInput { orgId: string; provider: ProviderRequestOwner; model: string; + workspaceId: string | null; promptSha256: string; inputCharCount: number; executionMode: ProviderExecutionMode; @@ -99,23 +101,24 @@ export class ProviderRequestStore { const requestId = createId("preq"); this.db.prepare(` insert into provider_requests( - id, org_id, provider_id, provider_key, model, prompt_sha256, + id, org_id, provider_id, provider_key, workspace_id, model, prompt_sha256, response_text, raw_response_json, state, error_message, input_char_count, output_char_count, execution_mode, policy_decision, external_response_id, stream_event_count, cancelled_at, created_at, completed_at ) - values (?, ?, ?, ?, ?, ?, null, null, ?, null, ?, 0, ?, ?, null, 0, null, ?, null) + values (?, ?, ?, ?, ?, ?, ?, null, null, ?, null, ?, 0, ?, ?, null, 0, null, ?, null) `).run( requestId, input.orgId, input.provider.id, input.provider.provider_key, + input.workspaceId, input.model, input.promptSha256, PROVIDER_REQUEST_STATE.PENDING, input.inputCharCount, input.executionMode, - `allowed:${input.provider.data_policy}`, + policyDecision(input.provider, input.workspaceId), input.createdAt, ); return requestId; @@ -254,3 +257,8 @@ export class ProviderRequestStore { `).run(createId("puw"), orgId, providerId, window.start, window.end, inputCharCount, outputCharCount, now, now); } } + +function policyDecision(provider: ProviderRequestOwner, workspaceId: string | null): string { + if (provider.workspace_scope === "workspace") return `allowed:${provider.data_policy}:workspace:${workspaceId}`; + return `allowed:${provider.data_policy}:organization`; +} diff --git a/AgentCompany-New/src/main/services/provider-service.ts b/AgentCompany-New/src/main/services/provider-service.ts index b3d7c87f..4861d986 100644 --- a/AgentCompany-New/src/main/services/provider-service.ts +++ b/AgentCompany-New/src/main/services/provider-service.ts @@ -361,14 +361,16 @@ export class ProviderService { const prompt = input.prompt.trim(); if (!prompt) throw new Error("Prompt is required."); const provider = this.loadConfiguredProvider(input.orgId, input.providerId); + const workspaceId = this.normalizeWorkspaceId(input.orgId, input.workspaceId ?? null); const executionMode = input.executionMode ?? PROVIDER_EXECUTION_MODE.FOREGROUND; - this.enforcePolicy(input.orgId, provider, executionMode); + this.enforcePolicy(input.orgId, provider, executionMode, workspaceId); if (provider.auth_type !== PROVIDER_AUTH_TYPE.API_KEY) { throw new Error("Provider response execution requires an API key adapter."); } if (!provider.model) throw new Error("Provider model is required."); return { orgId: input.orgId, + workspaceId, prompt, instructions: input.instructions?.trim() || null, executionMode, @@ -383,6 +385,7 @@ export class ProviderService { orgId: execution.orgId, provider: execution.provider, model: execution.provider.model, + workspaceId: execution.workspaceId, promptSha256: hashPrompt(execution.prompt), inputCharCount: execution.prompt.length, executionMode: execution.executionMode, @@ -390,8 +393,12 @@ export class ProviderService { }); } - private enforcePolicy(orgId: string, provider: ProviderRow, executionMode: ProviderExecutionMode): void { + private enforcePolicy(orgId: string, provider: ProviderRow, executionMode: ProviderExecutionMode, workspaceId: string | null): void { if (!Object.values(PROVIDER_EXECUTION_MODE).includes(executionMode)) throw new Error("Provider execution mode is invalid."); + if (!WORKSPACE_SCOPES.has(provider.workspace_scope)) throw new Error("Provider workspace scope is invalid."); + if (provider.workspace_scope === "workspace" && !workspaceId) { + throw new Error("Provider workspace scope requires a workspace context."); + } if (executionMode === PROVIDER_EXECUTION_MODE.BACKGROUND && provider.data_policy === PROVIDER_DATA_POLICY.ZERO_DATA_RETENTION) { throw new Error("Background execution conflicts with zero data retention policy."); } @@ -411,6 +418,15 @@ export class ProviderService { if (requestCount >= provider.monthly_request_limit) throw new Error("Provider monthly request limit has been reached."); } + private normalizeWorkspaceId(orgId: string, workspaceId: string | null): string | null { + const normalized = workspaceId?.trim() || null; + if (!normalized) return null; + const workspace = this.db.prepare("select id from workspaces where id = ? and org_id = ?") + .get(normalized, orgId) as { id: string } | undefined; + if (!workspace) throw new Error("Provider workspace context was not found."); + return normalized; + } + private loadConfiguredProvider(orgId: string, providerId: string): ProviderRow { const provider = this.db.prepare(` select id, provider_key, auth_type, model, credential_id, enabled, auth_state, @@ -476,6 +492,7 @@ export class ProviderService { interface PreparedProviderExecution { orgId: string; + workspaceId: string | null; prompt: string; instructions: string | null; executionMode: ProviderExecutionMode; diff --git a/AgentCompany-New/src/main/services/sdlc-service.ts b/AgentCompany-New/src/main/services/sdlc-service.ts index 00d87023..2ad44d1a 100644 --- a/AgentCompany-New/src/main/services/sdlc-service.ts +++ b/AgentCompany-New/src/main/services/sdlc-service.ts @@ -262,7 +262,7 @@ export class SdlcService { { key: "provider_adapter_coverage", passed: missing.length === 0, value: `${adapterKeys.length}/${catalogKeys.length}` }, { key: "provider_rate_limit_registry", passed: rateLimitRegistry === 1, value: rateLimitRegistry }, { key: "provider_runtime_contract", passed: runtimeContractReady, value: runtimeContractReady }, - { key: "schema_version", passed: schemaVersion >= 30, value: schemaVersion }, + { key: "schema_version", passed: schemaVersion >= 31, value: schemaVersion }, ]; } diff --git a/AgentCompany-New/src/main/services/task-execution-service.ts b/AgentCompany-New/src/main/services/task-execution-service.ts index c177f26a..f073520a 100644 --- a/AgentCompany-New/src/main/services/task-execution-service.ts +++ b/AgentCompany-New/src/main/services/task-execution-service.ts @@ -88,6 +88,7 @@ export class TaskExecutionService { const requestId = await this.providers.createResponse({ orgId: input.orgId, providerId: provider.id, + workspaceId: task.workspace_id, prompt: buildTaskPrompt({ task, agent, diff --git a/AgentCompany-New/src/shared/commands.ts b/AgentCompany-New/src/shared/commands.ts index eaa325b5..336f36e3 100644 --- a/AgentCompany-New/src/shared/commands.ts +++ b/AgentCompany-New/src/shared/commands.ts @@ -189,6 +189,7 @@ export interface UpdateProviderPolicyInput { export interface CreateProviderResponseInput { orgId: string; providerId: string; + workspaceId?: string | null; prompt: string; instructions: string | null; executionMode?: ProviderExecutionMode; diff --git a/AgentCompany-New/src/shared/provider-request-domain.ts b/AgentCompany-New/src/shared/provider-request-domain.ts index 61eeefeb..041d35d1 100644 --- a/AgentCompany-New/src/shared/provider-request-domain.ts +++ b/AgentCompany-New/src/shared/provider-request-domain.ts @@ -15,6 +15,7 @@ export interface ProviderRequestRecord { orgId: string; providerId: string; providerKey: string; + workspaceId: string | null; model: string; promptSha256: string; responseText: string | null; diff --git a/AgentCompany-New/tests/unit/domain-services.test.ts b/AgentCompany-New/tests/unit/domain-services.test.ts index 54b511ae..8965e03e 100644 --- a/AgentCompany-New/tests/unit/domain-services.test.ts +++ b/AgentCompany-New/tests/unit/domain-services.test.ts @@ -23,7 +23,7 @@ describe("domain services", () => { const connection = createConnection(); const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>; - expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30]); + expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31]); connection.close(); }); diff --git a/AgentCompany-New/tests/unit/migration-service.test.ts b/AgentCompany-New/tests/unit/migration-service.test.ts index 9215bd70..54580c8d 100644 --- a/AgentCompany-New/tests/unit/migration-service.test.ts +++ b/AgentCompany-New/tests/unit/migration-service.test.ts @@ -149,7 +149,7 @@ describe("migration service", () => { create table chat_presence(principal_type text, principal_id text); create table chat_read_state(org_id text, employee_id text, space_id text, last_read_message_id text); create table providers(id text primary key, org_id text, credential_id text); - create table provider_requests(id text primary key, org_id text, provider_id text); + create table provider_requests(id text primary key, org_id text, provider_id text, workspace_id text); create table provider_usage_windows(id text primary key, org_id text, provider_id text); create table provider_rate_limit_observations(id text primary key, org_id text, provider_id text); create table credentials(id text primary key, org_id text); @@ -225,7 +225,7 @@ describe("migration service", () => { insert into board_interventions(id, org_id, thread_id, message_id, approval_id) values ('bdi-1', 'org-1', 'thread-1', 'msg-1', 'approval-1'); insert into chat_presence(principal_type, principal_id) values ('agent', 'agent-1'); insert into chat_read_state(org_id, employee_id, space_id, last_read_message_id) values ('org-1', 'emp-1', 'space-1', 'msg-1'); - insert into provider_requests(id, org_id, provider_id) values ('req-1', 'org-1', 'prov-1'); + insert into provider_requests(id, org_id, provider_id, workspace_id) values ('req-1', 'org-1', 'prov-1', 'wsp-1'); insert into provider_usage_windows(id, org_id, provider_id) values ('usage-1', 'org-1', 'prov-1'); insert into provider_rate_limit_observations(id, org_id, provider_id) values ('quota-1', 'org-1', 'prov-1'); insert into skill_definitions(id, org_id) values ('skill-1', 'org-1'); diff --git a/AgentCompany-New/tests/unit/provider-service-streaming.test.ts b/AgentCompany-New/tests/unit/provider-service-streaming.test.ts index f2ac91d9..48f913c4 100644 --- a/AgentCompany-New/tests/unit/provider-service-streaming.test.ts +++ b/AgentCompany-New/tests/unit/provider-service-streaming.test.ts @@ -142,11 +142,11 @@ describe("provider service streaming", () => { providers.saveCredential({ orgId, providerId: openAiProviderId, secret: "sk-live-provider", model: "gpt-5.4" }); connection.db.prepare(` insert into provider_requests( - id, org_id, provider_id, provider_key, model, prompt_sha256, + id, org_id, provider_id, provider_key, workspace_id, model, prompt_sha256, response_text, raw_response_json, state, error_message, input_char_count, output_char_count, execution_mode, policy_decision, external_response_id, stream_event_count, cancelled_at, created_at, completed_at - ) values (?, ?, ?, 'openai', 'gpt-5.4', ?, 'partial', '[]', ?, null, 6, 7, ?, 'allowed:standard', ?, 2, null, ?, null) + ) values (?, ?, ?, 'openai', null, 'gpt-5.4', ?, 'partial', '[]', ?, null, 6, 7, ?, 'allowed:standard:organization', ?, 2, null, ?, null) `).run( requestId, orgId, diff --git a/AgentCompany-New/tests/unit/provider-service.test.ts b/AgentCompany-New/tests/unit/provider-service.test.ts index 436e9bb6..a49274eb 100644 --- a/AgentCompany-New/tests/unit/provider-service.test.ts +++ b/AgentCompany-New/tests/unit/provider-service.test.ts @@ -380,7 +380,7 @@ describe("provider service", () => { inputCharCount: 16, outputCharCount: 25, executionMode: PROVIDER_EXECUTION_MODE.FOREGROUND, - policyDecision: "allowed:standard", + policyDecision: "allowed:standard:organization", }); expect(state.providerRateLimits.find((item) => item.providerId === openAiProviderId)).toMatchObject({ providerId: openAiProviderId, @@ -394,6 +394,68 @@ describe("provider service", () => { connection.close(); }); + test("requires workspace context for workspace-scoped provider execution", async () => { + const connection = createConnection(); + const organization = new OrganizationService(connection.db); + const snapshot = new SnapshotService(connection.db); + const adapters: ProviderAdapterRegistry = { + ...providerAdapters, + openai: { + ...providerAdapters.openai, + parseAuthProfile: (secret) => ({ type: "api_key", apiKey: secret.trim() }), + checkHealth: async () => ({ checkedAt: new Date().toISOString(), rateLimitState: null }), + createResponse: async ({ prompt }) => ({ text: `accepted:${prompt}`, rawResponseJson: "{}", rateLimitState: null }), + }, + }; + const providers = new ProviderService(connection.db, adapters, testVault); + let orgId = ""; + let openAiProviderId = ""; + let workspaceId = ""; + + connection.transaction(() => { + orgId = createOrganization(organization); + openAiProviderId = providerId(connection, orgId, "openai"); + workspaceId = "wsp_release"; + const now = new Date().toISOString(); + connection.db.prepare(` + insert into workspaces(id, org_id, name, local_path, repo_url, repo_ref, project_id, is_primary, created_at, updated_at) + values (?, ?, ?, ?, ?, ?, null, ?, ?, ?) + `).run(workspaceId, orgId, "Release", "C:/work/release", "https://example.com/release.git", "main", 1, now, now); + providers.saveCredential({ orgId, providerId: openAiProviderId, secret: "sk-live-provider", model: "gpt-5.4" }); + providers.updatePolicy({ + orgId, + providerId: openAiProviderId, + dataPolicy: PROVIDER_DATA_POLICY.STANDARD, + backgroundEnabled: false, + monthlyRequestLimit: 10, + workspaceScope: "workspace", + }); + }); + + await expect(providers.createResponse({ + orgId, + providerId: openAiProviderId, + prompt: "Draft workspace memo", + instructions: null, + })).rejects.toThrow("Provider workspace scope requires a workspace context."); + expect(connection.db.prepare("select count(*) as count from provider_requests").get()).toEqual({ count: 0 }); + + await providers.createResponse({ + orgId, + providerId: openAiProviderId, + workspaceId, + prompt: "Draft workspace memo", + instructions: null, + }); + + expect(snapshot.load().providerRequests[0]).toMatchObject({ + providerId: openAiProviderId, + workspaceId, + policyDecision: `allowed:standard:workspace:${workspaceId}`, + }); + connection.close(); + }); + test("blocks background execution under zero data retention policy", async () => { const connection = createConnection(); const organization = new OrganizationService(connection.db); diff --git a/AgentCompany-New/tests/unit/task-execution-service.test.ts b/AgentCompany-New/tests/unit/task-execution-service.test.ts index 107b6244..52d7d9ca 100644 --- a/AgentCompany-New/tests/unit/task-execution-service.test.ts +++ b/AgentCompany-New/tests/unit/task-execution-service.test.ts @@ -50,6 +50,7 @@ describe("task execution service", () => { let orgId = ""; let providerId = ""; let taskId = ""; + let workspaceId = ""; connection.transaction(() => { orgId = organization.createOrganization({ @@ -60,6 +61,12 @@ describe("task execution service", () => { }); providerId = openAiProviderId(connection, orgId); providers.saveCredential({ orgId, providerId, secret: "sk-live-provider", model: "gpt-5.4" }); + workspaceId = "wsp_task_execution"; + const now = new Date().toISOString(); + connection.db.prepare(` + insert into workspaces(id, org_id, name, local_path, repo_url, repo_ref, project_id, is_primary, created_at, updated_at) + values (?, ?, ?, ?, ?, ?, null, ?, ?, ?) + `).run(workspaceId, orgId, "Task Workspace", "C:/work/task", "https://example.com/task.git", "main", 1, now, now); const agentId = organization.createAgent({ orgId, name: "Ada", @@ -79,7 +86,7 @@ describe("task execution service", () => { risk: RISK_LEVEL.MEDIUM, projectId: null, goalId: null, - workspaceId: null, + workspaceId, priority: TASK_PRIORITY.HIGH, requiresFilesystem: false, }); @@ -91,9 +98,10 @@ describe("task execution service", () => { const request = state.providerRequests.find((item) => item.id === run?.providerRequestId); expect(run).toMatchObject({ taskId, agentId: expect.any(String), providerId, state: "completed", errorMessage: null }); - expect(request).toMatchObject({ providerId, state: "succeeded", responseText: "executed:gpt-5.4" }); + expect(request).toMatchObject({ providerId, workspaceId, state: "succeeded", responseText: "executed:gpt-5.4" }); expect(capturedPrompt).toContain("Task: Draft launch checklist"); expect(capturedPrompt).toContain("Assignee: Ada (Execution lead)"); + expect(capturedPrompt).toContain("Workspace: Task Workspace"); expect(state.auditEvents.some((event) => event.entityType === "task_run" && event.action === "completed")).toBe(true); connection.close(); });