Scope provider requests to workspaces

This commit is contained in:
ZacharyZhang-NY committed 2026-05-23 22:16:37 -04:00
1 parent ee5eff7725
commit 335d57b77f
15 files changed
+144 -16

No files matched your search

@@ -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'));
`;
@@ -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;
@@ -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" },
@@ -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 };
@@ -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`;
}
@@ -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;
@@ -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 },
];
}
@@ -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,
+1
View File
@@ -189,6 +189,7 @@ export interface UpdateProviderPolicyInput {
export interface CreateProviderResponseInput {
orgId: string;
providerId: string;
workspaceId?: string | null;
prompt: string;
instructions: string | null;
executionMode?: ProviderExecutionMode;
@@ -15,6 +15,7 @@ export interface ProviderRequestRecord {
orgId: string;
providerId: string;
providerKey: string;
workspaceId: string | null;
model: string;
promptSha256: string;
responseText: string | null;
@@ -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();
});
@@ -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');
@@ -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,
@@ -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);
@@ -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();
});