diff --git a/AgentCompany-New/src/main/app/ipc-handlers.ts b/AgentCompany-New/src/main/app/ipc-handlers.ts index 9ea1234f..d3573000 100644 --- a/AgentCompany-New/src/main/app/ipc-handlers.ts +++ b/AgentCompany-New/src/main/app/ipc-handlers.ts @@ -82,6 +82,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { ipcMain.handle(IPC_CHANNELS.sendMessage, (_event, input) => mutate(() => chat.sendMessage(input))); ipcMain.handle(IPC_CHANNELS.markChatSpaceRead, (_event, input) => mutate(() => chat.markSpaceRead(input))); ipcMain.handle(IPC_CHANNELS.saveProviderCredential, (_event, input) => mutate(() => providers.saveCredential(input))); + ipcMain.handle(IPC_CHANNELS.updateProviderPolicy, (_event, input) => mutate(() => providers.updatePolicy(input))); ipcMain.handle(IPC_CHANNELS.checkProviderHealth, async (_event, input) => { try { await providers.checkHealth(input.orgId, input.providerId); diff --git a/AgentCompany-New/src/main/database/migrations/014-provider-policy-usage.ts b/AgentCompany-New/src/main/database/migrations/014-provider-policy-usage.ts new file mode 100644 index 00000000..2e123bb6 --- /dev/null +++ b/AgentCompany-New/src/main/database/migrations/014-provider-policy-usage.ts @@ -0,0 +1,31 @@ +export const providerPolicyUsageSchema = ` + alter table providers add column data_policy text not null default 'standard'; + alter table providers add column background_enabled integer not null default 0; + alter table providers add column monthly_request_limit integer not null default 1000; + alter table providers add column workspace_scope text not null default 'organization'; + + alter table provider_requests add column input_char_count integer not null default 0; + alter table provider_requests add column output_char_count integer not null default 0; + alter table provider_requests add column execution_mode text not null default 'foreground'; + alter table provider_requests add column policy_decision text not null default 'allowed:standard'; + + create table if not exists provider_usage_windows ( + id text primary key, + org_id text not null references organizations(id) on delete cascade, + provider_id text not null references providers(id) on delete cascade, + window_start text not null, + window_end text not null, + request_count integer not null, + input_char_count integer not null, + output_char_count integer not null, + created_at text not null, + updated_at text not null, + unique(provider_id, window_start) + ); + + create index if not exists idx_provider_usage_windows_org_start + on provider_usage_windows(org_id, window_start); + + insert into schema_migrations(version, name, applied_at) + values (14, 'provider_policy_usage', datetime('now')); +`; diff --git a/AgentCompany-New/src/main/database/schema.ts b/AgentCompany-New/src/main/database/schema.ts index a614d138..182abe1c 100644 --- a/AgentCompany-New/src/main/database/schema.ts +++ b/AgentCompany-New/src/main/database/schema.ts @@ -12,6 +12,7 @@ import { fileHandoffSecuritySchema } from "./migrations/010-file-handoff-securit import { boardInterventionSchema } from "./migrations/011-board-interventions"; import { complianceControlsSchema } from "./migrations/012-compliance-controls"; import { searchDocumentsSchema } from "./migrations/013-search-documents"; +import { providerPolicyUsageSchema } from "./migrations/014-provider-policy-usage"; interface Migration { version: number; @@ -33,6 +34,7 @@ const MIGRATIONS: Migration[] = [ { version: 11, name: "board_interventions", sql: boardInterventionSchema }, { version: 12, name: "compliance_controls", sql: complianceControlsSchema }, { version: 13, name: "search_documents", sql: searchDocumentsSchema }, + { version: 14, name: "provider_policy_usage", sql: providerPolicyUsageSchema }, ]; 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 ea62bc09..b5e78d12 100644 --- a/AgentCompany-New/src/main/services/migration-service.ts +++ b/AgentCompany-New/src/main/services/migration-service.ts @@ -28,6 +28,7 @@ const LEGACY_TABLES = [ "chat_read_state", "providers", "provider_requests", + "provider_usage_windows", "credentials", "skill_definitions", "skill_revisions", @@ -130,6 +131,8 @@ 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_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: "credentials", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, { table: "skill_definitions", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, { table: "skill_revisions", column: "skill_id", referencedTable: "skill_definitions", referencedColumn: "id", reason: "dangling_skill_id" }, diff --git a/AgentCompany-New/src/main/services/provider-service.ts b/AgentCompany-New/src/main/services/provider-service.ts index 3d215fb6..ba82d91c 100644 --- a/AgentCompany-New/src/main/services/provider-service.ts +++ b/AgentCompany-New/src/main/services/provider-service.ts @@ -1,8 +1,8 @@ import type { DatabaseSync } from "node:sqlite"; import { createHash } from "node:crypto"; import { BrowserWindow, session } from "electron"; -import type { CreateProviderResponseInput, SaveProviderCredentialInput } from "../../shared/commands"; -import { PROVIDER_AUTH_TYPE, PROVIDER_REQUEST_STATE } from "../../shared/domain"; +import type { CreateProviderResponseInput, SaveProviderCredentialInput, UpdateProviderPolicyInput } from "../../shared/commands"; +import { PROVIDER_AUTH_TYPE, PROVIDER_DATA_POLICY, PROVIDER_EXECUTION_MODE, PROVIDER_REQUEST_STATE, type ProviderExecutionMode } from "../../shared/domain"; import { PROVIDER_CATALOG } from "../../shared/provider-catalog"; import { createId } from "../../shared/ids"; import { AuditService } from "./audit-service"; @@ -13,6 +13,7 @@ const AUTH_STATE_CONFIGURED = "configured"; const HEALTH_STATE_HEALTHY = "healthy"; const HEALTH_STATE_FAILED = "failed"; const HEALTH_STATE_UNCHECKED = "unchecked"; +const WORKSPACE_SCOPES = new Set(["organization", "workspace"]); function displayHint(secret: string): string { const clean = secret.trim(); @@ -136,10 +137,46 @@ export class ProviderService { }); } + updatePolicy(input: UpdateProviderPolicyInput): void { + const monthlyRequestLimit = Math.trunc(input.monthlyRequestLimit); + if (monthlyRequestLimit < 1) throw new Error("Monthly request limit must be at least 1."); + if (!Object.values(PROVIDER_DATA_POLICY).includes(input.dataPolicy)) throw new Error("Provider data policy is invalid."); + if (!WORKSPACE_SCOPES.has(input.workspaceScope)) throw new Error("Provider workspace scope is invalid."); + if (input.dataPolicy === PROVIDER_DATA_POLICY.ZERO_DATA_RETENTION && input.backgroundEnabled) { + throw new Error("Zero data retention policy requires foreground execution."); + } + const provider = this.db.prepare("select id, provider_key from providers where id = ? and org_id = ?") + .get(input.providerId, input.orgId) as { id: string; provider_key: string } | undefined; + if (!provider) throw new Error("Provider was not found."); + + this.db.prepare(` + update providers + set data_policy = ?, background_enabled = ?, monthly_request_limit = ?, workspace_scope = ? + where id = ? and org_id = ? + `).run(input.dataPolicy, input.backgroundEnabled ? 1 : 0, monthlyRequestLimit, input.workspaceScope, input.providerId, input.orgId); + this.audit.record({ + orgId: input.orgId, + entityType: "provider", + entityId: input.providerId, + actorType: "system", + actorId: null, + action: "policy_updated", + payload: { + providerKey: provider.provider_key, + dataPolicy: input.dataPolicy, + backgroundEnabled: input.backgroundEnabled, + monthlyRequestLimit, + workspaceScope: input.workspaceScope, + }, + }); + } + async createResponse(input: CreateProviderResponseInput): Promise { const prompt = input.prompt.trim(); if (!prompt) throw new Error("Prompt is required."); const provider = this.loadConfiguredProvider(input.orgId, input.providerId); + const executionMode = input.executionMode ?? PROVIDER_EXECUTION_MODE.FOREGROUND; + this.enforcePolicy(input.orgId, provider, executionMode); if (provider.auth_type !== PROVIDER_AUTH_TYPE.API_KEY) { throw new Error("Provider response execution requires an API key adapter."); } @@ -153,9 +190,10 @@ export class ProviderService { this.db.prepare(` insert into provider_requests( id, org_id, provider_id, provider_key, model, prompt_sha256, - response_text, raw_response_json, state, error_message, created_at, completed_at + response_text, raw_response_json, state, error_message, input_char_count, + output_char_count, execution_mode, policy_decision, created_at, completed_at ) - values (?, ?, ?, ?, ?, ?, null, null, ?, null, ?, null) + values (?, ?, ?, ?, ?, ?, null, null, ?, null, ?, 0, ?, ?, ?, null) `).run( requestId, input.orgId, @@ -164,6 +202,9 @@ export class ProviderService { provider.model, hashPrompt(prompt), PROVIDER_REQUEST_STATE.PENDING, + prompt.length, + executionMode, + `allowed:${provider.data_policy}`, now, ); @@ -177,9 +218,10 @@ export class ProviderService { }); this.db.prepare(` update provider_requests - set state = ?, response_text = ?, raw_response_json = ?, completed_at = ? + set state = ?, response_text = ?, raw_response_json = ?, output_char_count = ?, completed_at = ? where id = ? and org_id = ? - `).run(PROVIDER_REQUEST_STATE.SUCCEEDED, result.text, result.rawResponseJson, new Date().toISOString(), requestId, input.orgId); + `).run(PROVIDER_REQUEST_STATE.SUCCEEDED, result.text, result.rawResponseJson, result.text.length, new Date().toISOString(), requestId, input.orgId); + this.recordUsage(input.orgId, provider.id, prompt.length, result.text.length); this.recordProviderHealth(provider.id, input.orgId, HEALTH_STATE_HEALTHY, null); this.audit.record({ orgId: input.orgId, @@ -197,14 +239,34 @@ export class ProviderService { set state = ?, error_message = ?, completed_at = ? where id = ? and org_id = ? `).run(PROVIDER_REQUEST_STATE.FAILED, message, new Date().toISOString(), requestId, input.orgId); + this.recordUsage(input.orgId, provider.id, prompt.length, 0); this.recordProviderHealth(provider.id, input.orgId, HEALTH_STATE_FAILED, message); throw error; } } + private enforcePolicy(orgId: string, provider: ProviderRow, executionMode: ProviderExecutionMode): void { + if (!Object.values(PROVIDER_EXECUTION_MODE).includes(executionMode)) throw new Error("Provider execution mode is invalid."); + 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."); + } + if (executionMode === PROVIDER_EXECUTION_MODE.BACKGROUND && provider.background_enabled !== 1) { + throw new Error("Background execution is disabled for this provider policy."); + } + const window = currentMonthlyWindow(); + const usage = this.db.prepare(` + select request_count + from provider_usage_windows + where org_id = ? and provider_id = ? and window_start = ? + `).get(orgId, provider.id, window.start) as { request_count: number } | undefined; + const requestCount = usage?.request_count ?? 0; + if (requestCount >= provider.monthly_request_limit) throw new Error("Provider monthly request limit has been reached."); + } + private loadConfiguredProvider(orgId: string, providerId: string): ProviderRow { const provider = this.db.prepare(` - select id, provider_key, auth_type, model, credential_id, enabled, auth_state + select id, provider_key, auth_type, model, credential_id, enabled, auth_state, + data_policy, background_enabled, monthly_request_limit, workspace_scope from providers where id = ? and org_id = ? `).get(providerId, orgId) as ProviderRow | undefined; @@ -252,6 +314,22 @@ export class ProviderService { where id = ? and org_id = ? `).run(healthState, error, new Date().toISOString(), providerId, orgId); } + + private recordUsage(orgId: string, providerId: string, inputCharCount: number, outputCharCount: number): void { + const window = currentMonthlyWindow(); + const now = new Date().toISOString(); + this.db.prepare(` + insert into provider_usage_windows( + id, org_id, provider_id, window_start, window_end, request_count, + input_char_count, output_char_count, created_at, updated_at + ) values (?, ?, ?, ?, ?, 1, ?, ?, ?, ?) + on conflict(provider_id, window_start) do update set + request_count = request_count + 1, + input_char_count = input_char_count + excluded.input_char_count, + output_char_count = output_char_count + excluded.output_char_count, + updated_at = excluded.updated_at + `).run(createId("puw"), orgId, providerId, window.start, window.end, inputCharCount, outputCharCount, now, now); + } } interface ProviderRow { @@ -262,8 +340,19 @@ interface ProviderRow { credential_id: string | null; enabled: number; auth_state: string; + data_policy: string; + background_enabled: number; + monthly_request_limit: number; + workspace_scope: string; } function hashPrompt(prompt: string): string { return createHash("sha256").update(prompt).digest("hex"); } + +function currentMonthlyWindow(): { start: string; end: string } { + const now = new Date(); + const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); + const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)); + return { start: start.toISOString(), end: end.toISOString() }; +} diff --git a/AgentCompany-New/src/main/services/snapshot-service.ts b/AgentCompany-New/src/main/services/snapshot-service.ts index b9f4b4dc..912b9c7d 100644 --- a/AgentCompany-New/src/main/services/snapshot-service.ts +++ b/AgentCompany-New/src/main/services/snapshot-service.ts @@ -42,7 +42,7 @@ export class SnapshotService { const workspaces = list(this.db, "workspaces", "created_at asc") .map((workspace) => ({ ...workspace, isPrimary: toBool(workspace.isPrimary) })); const providers = list(this.db, "providers", "label asc") - .map((provider) => ({ ...provider, enabled: toBool(provider.enabled) })); + .map((provider) => ({ ...provider, enabled: toBool(provider.enabled), backgroundEnabled: toBool(provider.backgroundEnabled) })); const skillBindings = list(this.db, "skill_bindings", "priority asc") .map((binding) => ({ ...binding, enabled: toBool(binding.enabled) })); const files = list(this.db, "file_objects", "created_at desc") diff --git a/AgentCompany-New/src/preload/index.ts b/AgentCompany-New/src/preload/index.ts index da7aadd6..f5915069 100644 --- a/AgentCompany-New/src/preload/index.ts +++ b/AgentCompany-New/src/preload/index.ts @@ -23,6 +23,7 @@ const api: DesktopApi = { saveProviderCredential: (input) => invoke(IPC_CHANNELS.saveProviderCredential, input), launchProviderSignIn: (input) => invoke(IPC_CHANNELS.launchProviderSignIn, input), checkProviderHealth: (input) => invoke(IPC_CHANNELS.checkProviderHealth, input), + updateProviderPolicy: (input) => invoke(IPC_CHANNELS.updateProviderPolicy, input), openExternalUrl: (url) => invoke(IPC_CHANNELS.openExternalUrl, url), createProviderResponse: (input) => invoke(IPC_CHANNELS.createProviderResponse, input), importSkillDirectory: (input) => invoke(IPC_CHANNELS.importSkillDirectory, input), diff --git a/AgentCompany-New/src/renderer/src/pages/SettingsPage.tsx b/AgentCompany-New/src/renderer/src/pages/SettingsPage.tsx index bd238a1d..308784aa 100644 --- a/AgentCompany-New/src/renderer/src/pages/SettingsPage.tsx +++ b/AgentCompany-New/src/renderer/src/pages/SettingsPage.tsx @@ -99,6 +99,7 @@ export function SettingsPage({ snapshot, actions }: PageProps) { providers={providers} catalog={snapshot.providerCatalog} agents={agents} + providerRequests={providerRequests} actions={actions} /> )} diff --git a/AgentCompany-New/src/renderer/src/pages/settings/ProvidersPanel.tsx b/AgentCompany-New/src/renderer/src/pages/settings/ProvidersPanel.tsx index a948aeeb..c8b4dfd1 100644 --- a/AgentCompany-New/src/renderer/src/pages/settings/ProvidersPanel.tsx +++ b/AgentCompany-New/src/renderer/src/pages/settings/ProvidersPanel.tsx @@ -5,7 +5,9 @@ import type { AgentRecord, ProviderCatalogEntry, ProviderRecord, + ProviderRequestRecord, } from "../../../../shared/domain"; +import { PROVIDER_DATA_POLICY } from "../../../../shared/domain"; import { ProviderLogo } from "./ProviderLogo"; import type { SnapshotActions } from "../../app/useSnapshot"; @@ -14,6 +16,7 @@ interface ProvidersPanelProps { providers: ProviderRecord[]; catalog: ProviderCatalogEntry[]; agents: AgentRecord[]; + providerRequests: ProviderRequestRecord[]; actions: SnapshotActions; } @@ -26,7 +29,7 @@ interface CloudFields { const CLOUD_DEFAULTS: CloudFields = { baseUrl: "", region: "", projectId: "", deployment: "" }; -export function ProvidersPanel({ orgId, providers, catalog, agents, actions }: ProvidersPanelProps) { +export function ProvidersPanel({ orgId, providers, catalog, agents, providerRequests, actions }: ProvidersPanelProps) { const [active, setActive] = useState(null); const [signInLaunched, setSignInLaunched] = useState(false); const connected = providers.filter((provider) => provider.enabled); @@ -76,6 +79,12 @@ export function ProvidersPanel({ orgId, providers, catalog, agents, actions }: P /> ))} + )} @@ -110,6 +119,124 @@ export function ProvidersPanel({ orgId, providers, catalog, agents, actions }: P ); } +function ProviderPolicyTable({ + orgId, + providers, + providerRequests, + actions, +}: { + orgId: string; + providers: ProviderRecord[]; + providerRequests: ProviderRequestRecord[]; + actions: SnapshotActions; +}) { + return ( + <> +
Runtime policy
+
+
+
Provider
Data policy
Background
Monthly limit
Usage
+
+ {providers.map((provider, index) => ( + request.providerId === provider.id && inCurrentUtcMonth(request.createdAt))} + last={index === providers.length - 1} + actions={actions} + /> + ))} +
+ + ); +} + +function ProviderPolicyRow({ + orgId, + provider, + requests, + last, + actions, +}: { + orgId: string; + provider: ProviderRecord; + requests: ProviderRequestRecord[]; + last: boolean; + actions: SnapshotActions; +}) { + const [dataPolicy, setDataPolicy] = useState(provider.dataPolicy); + const [backgroundEnabled, setBackgroundEnabled] = useState(provider.backgroundEnabled); + const [monthlyRequestLimit, setMonthlyRequestLimit] = useState(String(provider.monthlyRequestLimit)); + + const savePolicy = () => { + void actions.run(() => window.agentCompany.updateProviderPolicy({ + orgId, + providerId: provider.id, + dataPolicy, + backgroundEnabled, + monthlyRequestLimit: Number(monthlyRequestLimit), + workspaceScope: provider.workspaceScope === "workspace" ? "workspace" : "organization", + })); + }; + + const updateDataPolicy = (value: ProviderRecord["dataPolicy"]) => { + setDataPolicy(value); + if (value === PROVIDER_DATA_POLICY.ZERO_DATA_RETENTION) setBackgroundEnabled(false); + }; + + return ( +
+
+
{provider.label}
+
{provider.workspaceScope} scope
+
+ + + setMonthlyRequestLimit(event.target.value)} + /> +
+
{requests.length} / {provider.monthlyRequestLimit}
+ +
+
+ ); +} + function ProviderConnectFields({ provider, entry, @@ -311,3 +438,9 @@ function readableAuth(authType: ProviderRecord["authType"]): string { if (authType === "cloud") return "Cloud"; return authType; } + +function inCurrentUtcMonth(iso: string): boolean { + const value = new Date(iso); + const now = new Date(); + return value.getUTCFullYear() === now.getUTCFullYear() && value.getUTCMonth() === now.getUTCMonth(); +} diff --git a/AgentCompany-New/src/shared/commands.ts b/AgentCompany-New/src/shared/commands.ts index bcf50366..8bb7b9b8 100644 --- a/AgentCompany-New/src/shared/commands.ts +++ b/AgentCompany-New/src/shared/commands.ts @@ -3,6 +3,8 @@ import type { ComplianceExportType, ComplianceSubjectType, LegalHoldEntityType, + ProviderDataPolicy, + ProviderExecutionMode, RetentionFreezeScopeType, RiskLevel, SearchEntityType, @@ -127,11 +129,21 @@ export interface CheckProviderHealthInput { providerId: string; } +export interface UpdateProviderPolicyInput { + orgId: string; + providerId: string; + dataPolicy: ProviderDataPolicy; + backgroundEnabled: boolean; + monthlyRequestLimit: number; + workspaceScope: "organization" | "workspace"; +} + export interface CreateProviderResponseInput { orgId: string; providerId: string; prompt: string; instructions: string | null; + executionMode?: ProviderExecutionMode; } export interface ImportSkillDirectoryInput { diff --git a/AgentCompany-New/src/shared/desktop-api.ts b/AgentCompany-New/src/shared/desktop-api.ts index 1be949c0..a92e795d 100644 --- a/AgentCompany-New/src/shared/desktop-api.ts +++ b/AgentCompany-New/src/shared/desktop-api.ts @@ -33,6 +33,7 @@ import type { SetLegalHoldStateInput, SetRetentionFreezeStateInput, SetScheduledPostStateInput, + UpdateProviderPolicyInput, UpdateTaskStatusInput, } from "./commands"; @@ -57,6 +58,7 @@ export interface DesktopApi { saveProviderCredential(input: SaveProviderCredentialInput): Promise>; launchProviderSignIn(input: LaunchProviderSignInInput): Promise>; checkProviderHealth(input: CheckProviderHealthInput): Promise>; + updateProviderPolicy(input: UpdateProviderPolicyInput): Promise>; openExternalUrl(url: string): Promise>; createProviderResponse(input: CreateProviderResponseInput): Promise>; importSkillDirectory(input: ImportSkillDirectoryInput): Promise>; diff --git a/AgentCompany-New/src/shared/domain.ts b/AgentCompany-New/src/shared/domain.ts index 247be7d6..385ccb15 100644 --- a/AgentCompany-New/src/shared/domain.ts +++ b/AgentCompany-New/src/shared/domain.ts @@ -1,57 +1,18 @@ import type { BoardInterventionRecord as BoardInterventionRecordModel, ChatThreadBoardState as ChatThreadBoardStateModel } from "./board-domain"; import type { ComplianceExportRecord, LegalHoldRecord, RetentionFreezeRecord } from "./compliance-domain"; +import type { ProviderDataPolicy, ProviderExecutionMode } from "./provider-policy-domain"; export { BOARD_INTERVENTION_ACTION, BOARD_INTERVENTION_MODE, CHAT_THREAD_BOARD_STATE, type BoardInterventionAction, type BoardInterventionMode, type BoardInterventionRecord, type ChatThreadBoardState } from "./board-domain"; export { COMPLIANCE_EXPORT_STATE, COMPLIANCE_EXPORT_TYPE, type ComplianceExportRecord, type ComplianceExportState, type ComplianceExportType, type ComplianceSubjectType, type LegalHoldEntityType, type LegalHoldRecord, type RetentionFreezeRecord, type RetentionFreezeScopeType } from "./compliance-domain"; +export { PROVIDER_DATA_POLICY, PROVIDER_EXECUTION_MODE, type ProviderDataPolicy, type ProviderExecutionMode } from "./provider-policy-domain"; export { SEARCH_ENTITY_TYPES, type SearchEntityType, type SearchResultRecord } from "./search-domain"; -export const TASK_STATUS = { - BACKLOG: "backlog", - IN_PROGRESS: "in_progress", - REVIEW: "review", - DONE: "done", -} as const; - -export const APPROVAL_STATE = { - PENDING: "pending", - APPROVED: "approved", - DECLINED: "declined", -} as const; - -export const RISK_LEVEL = { - LOW: "low", - MEDIUM: "medium", - HIGH: "high", -} as const; - -export const TASK_PRIORITY = { - LOW: "low", - NORMAL: "normal", - HIGH: "high", -} as const; - -export const PROVIDER_AUTH_TYPE = { - API_KEY: "api_key", - OAUTH_PKCE: "oauth_pkce", - ACCOUNT_SUBSCRIPTION: "account_subscription", - CLOUD: "cloud", -} as const; - -export const SOCIAL_PLATFORM = { - X: "x", - LINKEDIN: "linkedin", - YOUTUBE: "youtube", - INSTAGRAM: "instagram", -} as const; - -export const PROVIDER_REQUEST_STATE = { - PENDING: "pending", - SUCCEEDED: "succeeded", - FAILED: "failed", -} as const; - -export const FILE_SCAN_STATE = { - APPROVED: "approved", - BLOCKED: "blocked", -} as const; +export const TASK_STATUS = { BACKLOG: "backlog", IN_PROGRESS: "in_progress", REVIEW: "review", DONE: "done" } as const; +export const APPROVAL_STATE = { PENDING: "pending", APPROVED: "approved", DECLINED: "declined" } as const; +export const RISK_LEVEL = { LOW: "low", MEDIUM: "medium", HIGH: "high" } as const; +export const TASK_PRIORITY = { LOW: "low", NORMAL: "normal", HIGH: "high" } as const; +export const PROVIDER_AUTH_TYPE = { API_KEY: "api_key", OAUTH_PKCE: "oauth_pkce", ACCOUNT_SUBSCRIPTION: "account_subscription", CLOUD: "cloud" } as const; +export const SOCIAL_PLATFORM = { X: "x", LINKEDIN: "linkedin", YOUTUBE: "youtube", INSTAGRAM: "instagram" } as const; +export const PROVIDER_REQUEST_STATE = { PENDING: "pending", SUCCEEDED: "succeeded", FAILED: "failed" } as const; +export const FILE_SCAN_STATE = { APPROVED: "approved", BLOCKED: "blocked" } as const; export type ValueOf = T[keyof T]; export type TaskStatus = ValueOf; @@ -323,6 +284,10 @@ export interface ProviderRecord { healthState: string; lastCheckedAt: string | null; lastError: string | null; + dataPolicy: ProviderDataPolicy; + backgroundEnabled: boolean; + monthlyRequestLimit: number; + workspaceScope: string; createdAt: string; } @@ -353,6 +318,10 @@ export interface ProviderRequestRecord { rawResponseJson: string | null; state: ProviderRequestState; errorMessage: string | null; + inputCharCount: number; + outputCharCount: number; + executionMode: ProviderExecutionMode; + policyDecision: string; createdAt: string; completedAt: string | null; } diff --git a/AgentCompany-New/src/shared/ipc.ts b/AgentCompany-New/src/shared/ipc.ts index 9abc3c5d..781997a5 100644 --- a/AgentCompany-New/src/shared/ipc.ts +++ b/AgentCompany-New/src/shared/ipc.ts @@ -15,6 +15,7 @@ export const IPC_CHANNELS = { saveProviderCredential: "agentcompany:save-provider-credential", launchProviderSignIn: "agentcompany:launch-provider-sign-in", checkProviderHealth: "agentcompany:check-provider-health", + updateProviderPolicy: "agentcompany:update-provider-policy", openExternalUrl: "agentcompany:open-external-url", createProviderResponse: "agentcompany:create-provider-response", importSkillDirectory: "agentcompany:import-skill-directory", diff --git a/AgentCompany-New/src/shared/provider-policy-domain.ts b/AgentCompany-New/src/shared/provider-policy-domain.ts new file mode 100644 index 00000000..91379c00 --- /dev/null +++ b/AgentCompany-New/src/shared/provider-policy-domain.ts @@ -0,0 +1,14 @@ +type ValueOf = T[keyof T]; + +export const PROVIDER_DATA_POLICY = { + STANDARD: "standard", + ZERO_DATA_RETENTION: "zero_data_retention", +} as const; + +export const PROVIDER_EXECUTION_MODE = { + FOREGROUND: "foreground", + BACKGROUND: "background", +} as const; + +export type ProviderDataPolicy = ValueOf; +export type ProviderExecutionMode = ValueOf; diff --git a/AgentCompany-New/tests/e2e/desktop.spec.ts b/AgentCompany-New/tests/e2e/desktop.spec.ts index c94933a7..b0dc295c 100644 --- a/AgentCompany-New/tests/e2e/desktop.spec.ts +++ b/AgentCompany-New/tests/e2e/desktop.spec.ts @@ -97,6 +97,12 @@ test("onboards, hires, and creates work in the Electron shell", async () => { await expect(page.getByText("Published").first()).toBeVisible(); await page.getByRole("button", { name: "Settings" }).click(); + await page.getByRole("button", { name: "Providers" }).click(); + await page.getByLabel("OpenAI data policy").selectOption("zero_data_retention"); + await expect(page.getByLabel("OpenAI background execution")).not.toBeChecked(); + await page.getByLabel("OpenAI monthly request limit").fill("25"); + await page.getByRole("button", { name: "Save OpenAI policy" }).click(); + await expect(page.getByText("0 / 25").first()).toBeVisible(); await page.getByRole("button", { name: "Compliance" }).click(); await page.getByRole("button", { name: "Create" }).click(); await expect(page.getByText("gdpr export ยท employee").first()).toBeVisible(); diff --git a/AgentCompany-New/tests/unit/domain-services.test.ts b/AgentCompany-New/tests/unit/domain-services.test.ts index 5d97af25..d2828f86 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]); + expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]); connection.close(); }); diff --git a/AgentCompany-New/tests/unit/migration-service.test.ts b/AgentCompany-New/tests/unit/migration-service.test.ts index c7bebc66..848d450d 100644 --- a/AgentCompany-New/tests/unit/migration-service.test.ts +++ b/AgentCompany-New/tests/unit/migration-service.test.ts @@ -112,6 +112,7 @@ describe("migration service", () => { 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_usage_windows(id text primary key, org_id text, provider_id text); create table credentials(id text primary key, org_id text); create table skill_definitions(id text primary key, org_id text); create table skill_revisions(id text primary key, skill_id text); @@ -157,6 +158,7 @@ describe("migration service", () => { 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_usage_windows(id, org_id, provider_id) values ('usage-1', 'org-1', 'prov-1'); insert into skill_definitions(id, org_id) values ('skill-1', 'org-1'); insert into skill_revisions(id, skill_id) values ('rev-1', 'skill-1'), ('rev-2', 'skill-1'); insert into skill_artifacts(id, revision_id) values ('artifact-1', 'rev-1'); @@ -191,12 +193,14 @@ describe("migration service", () => { expect(body.tableCounts.board_interventions).toBe(1); expect(body.tableCounts.file_access_grants).toBe(1); expect(body.tableCounts.provider_requests).toBe(1); + expect(body.tableCounts.provider_usage_windows).toBe(1); expect(body.tableCounts.compliance_exports).toBe(1); expect(body.tableCounts.search_documents).toBe(1); expect(body.tableChecksums.audit_events).toHaveLength(64); expect(body.idMaps.skill_runtime_snapshots).toEqual(["snapshot-1"]); expect(body.idMaps.board_interventions).toEqual(["bdi-1"]); expect(body.idMaps.retention_freezes).toEqual(["freeze-1"]); + expect(body.idMaps.provider_usage_windows).toEqual(["usage-1"]); connection.close(); }); diff --git a/AgentCompany-New/tests/unit/provider-service.test.ts b/AgentCompany-New/tests/unit/provider-service.test.ts index d65218b9..81cb977d 100644 --- a/AgentCompany-New/tests/unit/provider-service.test.ts +++ b/AgentCompany-New/tests/unit/provider-service.test.ts @@ -7,6 +7,7 @@ import { OrganizationService } from "../../src/main/services/organization-servic import { ProviderService } from "../../src/main/services/provider-service"; import { providerAdapters, type ProviderAdapterRegistry, type ProviderAuthProfile } from "../../src/main/services/provider-adapters"; import { SnapshotService } from "../../src/main/services/snapshot-service"; +import { PROVIDER_DATA_POLICY, PROVIDER_EXECUTION_MODE } from "../../src/shared/domain"; import { PROVIDER_CATALOG } from "../../src/shared/provider-catalog"; function createConnection(): DatabaseConnection { @@ -121,6 +122,111 @@ describe("provider service", () => { connection.close(); }); + test("tracks provider usage windows and enforces monthly request limits", 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() }), + createResponse: async ({ prompt }) => ({ text: `accepted:${prompt}`, rawResponseJson: "{}" }), + }, + }; + const providers = new ProviderService(connection.db, adapters, testVault); + let orgId = ""; + let openAiProviderId = ""; + + connection.transaction(() => { + orgId = createOrganization(organization); + openAiProviderId = providerId(connection, orgId, "openai"); + 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: 1, + workspaceScope: "organization", + }); + }); + await providers.createResponse({ + orgId, + providerId: openAiProviderId, + prompt: "Draft board memo", + instructions: null, + }); + await expect(providers.createResponse({ + orgId, + providerId: openAiProviderId, + prompt: "Second board memo", + instructions: null, + })).rejects.toThrow("Provider monthly request limit has been reached."); + + const usage = connection.db.prepare(` + select request_count, input_char_count, output_char_count + from provider_usage_windows + where org_id = ? and provider_id = ? + `).get(orgId, openAiProviderId) as { request_count: number; input_char_count: number; output_char_count: number }; + expect(usage).toEqual({ request_count: 1, input_char_count: 16, output_char_count: 25 }); + expect(snapshot.load().providerRequests[0]).toMatchObject({ + providerId: openAiProviderId, + inputCharCount: 16, + outputCharCount: 25, + executionMode: PROVIDER_EXECUTION_MODE.FOREGROUND, + policyDecision: "allowed:standard", + }); + connection.close(); + }); + + test("blocks background execution under zero data retention policy", async () => { + const connection = createConnection(); + const organization = new OrganizationService(connection.db); + let adapterCalled = false; + const adapters: ProviderAdapterRegistry = { + ...providerAdapters, + openai: { + ...providerAdapters.openai, + parseAuthProfile: (secret) => ({ type: "api_key", apiKey: secret.trim() }), + checkHealth: async () => ({ checkedAt: new Date().toISOString() }), + createResponse: async () => { + adapterCalled = true; + return { text: "blocked", rawResponseJson: "{}" }; + }, + }, + }; + const providers = new ProviderService(connection.db, adapters, testVault); + let orgId = ""; + let openAiProviderId = ""; + + connection.transaction(() => { + orgId = createOrganization(organization); + openAiProviderId = providerId(connection, orgId, "openai"); + providers.saveCredential({ orgId, providerId: openAiProviderId, secret: "sk-live-provider", model: "gpt-5.4" }); + providers.updatePolicy({ + orgId, + providerId: openAiProviderId, + dataPolicy: PROVIDER_DATA_POLICY.ZERO_DATA_RETENTION, + backgroundEnabled: false, + monthlyRequestLimit: 10, + workspaceScope: "organization", + }); + }); + + await expect(providers.createResponse({ + orgId, + providerId: openAiProviderId, + prompt: "Run background analysis", + instructions: null, + executionMode: PROVIDER_EXECUTION_MODE.BACKGROUND, + })).rejects.toThrow("Background execution conflicts with zero data retention policy."); + expect(adapterCalled).toBe(false); + expect(connection.db.prepare("select count(*) as count from provider_requests").get()).toEqual({ count: 0 }); + connection.close(); + }); + test("validates cloud auth profiles before saving credentials", () => { const connection = createConnection(); const organization = new OrganizationService(connection.db);