Add provider policy usage controls

This commit is contained in:
ZacharyZhang-NY committed 2026-05-23 17:48:46 -04:00
1 parent 1ee877e630
commit 276929b855
18 files changed
+434 -59

No files matched your search

@@ -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);
@@ -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'));
`;
@@ -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;
@@ -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" },
@@ -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<void> {
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() };
}
@@ -42,7 +42,7 @@ export class SnapshotService {
const workspaces = list<AppSnapshot["workspaces"][number]>(this.db, "workspaces", "created_at asc")
.map((workspace) => ({ ...workspace, isPrimary: toBool(workspace.isPrimary) }));
const providers = list<AppSnapshot["providers"][number]>(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<AppSnapshot["skillBindings"][number]>(this.db, "skill_bindings", "priority asc")
.map((binding) => ({ ...binding, enabled: toBool(binding.enabled) }));
const files = list<AppSnapshot["files"][number]>(this.db, "file_objects", "created_at desc")
+1
View File
@@ -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),
@@ -99,6 +99,7 @@ export function SettingsPage({ snapshot, actions }: PageProps) {
providers={providers}
catalog={snapshot.providerCatalog}
agents={agents}
providerRequests={providerRequests}
actions={actions}
/>
)}
@@ -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<ProviderRecord | null>(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
/>
))}
</div>
<ProviderPolicyTable
orgId={orgId}
providers={connected}
providerRequests={providerRequests}
actions={actions}
/>
</>
)}
@@ -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 (
<>
<div className="section-label" style={{ marginBottom: 10 }}>Runtime policy</div>
<div className="card" style={{ padding: 0, overflow: "hidden", marginBottom: 28 }}>
<div
className="table-row head"
style={{ gridTemplateColumns: "minmax(0, 1.4fr) minmax(140px, 1fr) 120px 132px 96px" }}
>
<div>Provider</div><div>Data policy</div><div>Background</div><div>Monthly limit</div><div>Usage</div>
</div>
{providers.map((provider, index) => (
<ProviderPolicyRow
key={`${provider.id}:${provider.dataPolicy}:${provider.backgroundEnabled}:${provider.monthlyRequestLimit}`}
orgId={orgId}
provider={provider}
requests={providerRequests.filter((request) => request.providerId === provider.id && inCurrentUtcMonth(request.createdAt))}
last={index === providers.length - 1}
actions={actions}
/>
))}
</div>
</>
);
}
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 (
<div
className="table-row"
style={{
gridTemplateColumns: "minmax(0, 1.4fr) minmax(140px, 1fr) 120px 132px 96px",
borderBottom: last ? "none" : "1px solid var(--border-soft)",
}}
>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 540, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{provider.label}</div>
<div className="mono" style={{ fontSize: 11, color: "var(--text-3)" }}>{provider.workspaceScope} scope</div>
</div>
<select
className="input"
aria-label={`${provider.label} data policy`}
value={dataPolicy}
onChange={(event) => updateDataPolicy(event.target.value as ProviderRecord["dataPolicy"])}
>
<option value={PROVIDER_DATA_POLICY.STANDARD}>Standard retention</option>
<option value={PROVIDER_DATA_POLICY.ZERO_DATA_RETENTION}>Zero data retention</option>
</select>
<label className="row" style={{ gap: 8, fontSize: 12, color: "var(--text-2)" }}>
<input
aria-label={`${provider.label} background execution`}
type="checkbox"
checked={backgroundEnabled}
disabled={dataPolicy === PROVIDER_DATA_POLICY.ZERO_DATA_RETENTION}
onChange={(event) => setBackgroundEnabled(event.target.checked)}
/>
Allow
</label>
<input
className="input"
aria-label={`${provider.label} monthly request limit`}
type="number"
min={1}
value={monthlyRequestLimit}
onChange={(event) => setMonthlyRequestLimit(event.target.value)}
/>
<div>
<div className="num mono">{requests.length} / {provider.monthlyRequestLimit}</div>
<button type="button" className="button small" aria-label={`Save ${provider.label} policy`} onClick={savePolicy}>
Save
</button>
</div>
</div>
);
}
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();
}
+12
View File
@@ -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 {
@@ -33,6 +33,7 @@ import type {
SetLegalHoldStateInput,
SetRetentionFreezeStateInput,
SetScheduledPostStateInput,
UpdateProviderPolicyInput,
UpdateTaskStatusInput,
} from "./commands";
@@ -57,6 +58,7 @@ export interface DesktopApi {
saveProviderCredential(input: SaveProviderCredentialInput): Promise<DesktopResult<AppSnapshot>>;
launchProviderSignIn(input: LaunchProviderSignInInput): Promise<DesktopResult<null>>;
checkProviderHealth(input: CheckProviderHealthInput): Promise<DesktopResult<AppSnapshot>>;
updateProviderPolicy(input: UpdateProviderPolicyInput): Promise<DesktopResult<AppSnapshot>>;
openExternalUrl(url: string): Promise<DesktopResult<null>>;
createProviderResponse(input: CreateProviderResponseInput): Promise<DesktopResult<AppSnapshot>>;
importSkillDirectory(input: ImportSkillDirectoryInput): Promise<DesktopResult<AppSnapshot>>;
+18 -49
View File
@@ -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> = T[keyof T];
export type TaskStatus = ValueOf<typeof TASK_STATUS>;
@@ -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;
}
+1
View File
@@ -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",
@@ -0,0 +1,14 @@
type ValueOf<T> = 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<typeof PROVIDER_DATA_POLICY>;
export type ProviderExecutionMode = ValueOf<typeof PROVIDER_EXECUTION_MODE>;
@@ -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();
@@ -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();
});
@@ -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();
});
@@ -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);